From d0baf2895aa5a4ac120c2e12d4c67f06e5750c6c Mon Sep 17 00:00:00 2001 From: ewiwi Date: Sat, 12 Sep 2026 21:50:57 +0800 Subject: [PATCH 1/7] feat(l1): make latest event alerts a live, read-only feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L1 "最新事件告警" block only listed the first five rows of supply_chain_events ordered by id, so it stayed empty until an L2 user manually registered an event and never surfaced overwritten events as new. Alerts now come from get_latest_event_alerts(), which re-queries the database on every rerun: - confirmed: supply_chain_events inside the time window, ordered by created_at (add_risk_event overwrites in place, so id order was stale), joined to the originating supply_chain_news row for title/URL, with a severity derived from impact_days. - candidates: AI-classified news with estimated_delay > 0 that no event references yet. These appear as soon as the scheduler or an L2 refresh writes news, before anyone clicks 登錄, and disappear once registered. The feed requires RISK_OVERVIEW_READ and denies before any data read; the overview renderer now receives the live actor from the page. No new write paths, tables, or capabilities. Co-Authored-By: Claude Opus 5 --- backend/l1_monitoring.py | 174 +++++++++++++++++++++ backend/supply_chain_risk.py | 9 +- frontend/components/risk_overview.py | 114 ++++++++++++-- frontend/page_supply_chain_risk.py | 4 +- tests/test_l1_event_alerts.py | 224 +++++++++++++++++++++++++++ tests/test_tier_navigation.py | 4 +- 6 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 tests/test_l1_event_alerts.py diff --git a/backend/l1_monitoring.py b/backend/l1_monitoring.py index 98af2a1..076c7e0 100644 --- a/backend/l1_monitoring.py +++ b/backend/l1_monitoring.py @@ -2,6 +2,21 @@ from __future__ import annotations +from datetime import datetime, timedelta +import sqlite3 + +from backend import database +from backend.access_control import RISK_OVERVIEW_READ, require_capability + + +# 告警嚴重度依預估延遲天數分級;L1 只讀不寫,分級規則放在後端以便 LINE / Web 共用。 +ALERT_SEVERITY_HIGH_DAYS = 14 +ALERT_SEVERITY_MEDIUM_DAYS = 7 + +ALERT_SOURCE_NEWS = "新聞登錄" +ALERT_SOURCE_MANUAL = "人工登錄" +CANDIDATE_STATUS = "AI 偵測待確認" + def _text(value) -> str: if value is None: @@ -143,3 +158,162 @@ def map_purchase_rows_to_events( mapped_rows.append(row) return mapped_rows + + +# ── 最新事件告警(唯讀 feed) ────────────────────────────────────────── + + +def classify_alert_severity(impact_days) -> str: + """依預估延遲天數回傳「高/中/低/無」。""" + days = _impact_days({"impact_days": impact_days}) + if days >= ALERT_SEVERITY_HIGH_DAYS: + return "高" + if days >= ALERT_SEVERITY_MEDIUM_DAYS: + return "中" + if days >= 1: + return "低" + return "無" + + +def _window_start(since_days: int, *, now: datetime | None = None) -> str: + days = max(0, int(since_days or 0)) + reference = now or datetime.now() + return (reference - timedelta(days=days)).strftime("%Y-%m-%d") + + +def _load_confirmed_alerts(conn: sqlite3.Connection, *, since: str, limit: int) -> list[dict]: + rows = conn.execute( + """ + SELECT e.id, e.event_type, e.region, e.country, e.impact_days, + e.description, e.created_at, e.news_id, + n.title AS news_title, n.url AS news_url, n.source AS news_source + FROM supply_chain_events e + LEFT JOIN supply_chain_news n ON n.id = e.news_id + WHERE substr(COALESCE(e.created_at, ''), 1, 10) >= ? + ORDER BY COALESCE(e.created_at, '') DESC, e.id DESC + LIMIT ? + """, + (since, limit), + ).fetchall() + alerts = [] + for row in rows: + ( + event_id, event_type, region, country, impact_days, + description, created_at, news_id, news_title, news_url, news_source, + ) = row + alerts.append( + { + "id": event_id, + "event_type": _text(event_type) or "未分類", + "country": _text(country), + "region": _text(region), + "impact_days": _impact_days({"impact_days": impact_days}), + "severity": classify_alert_severity(impact_days), + "description": _text(description), + "created_at": _text(created_at), + "news_id": news_id, + "source": ALERT_SOURCE_NEWS if news_id is not None else ALERT_SOURCE_MANUAL, + "news_title": _text(news_title), + "news_url": _text(news_url), + "news_source": _text(news_source), + } + ) + return alerts + + +def _load_candidate_alerts(conn: sqlite3.Connection, *, since: str, limit: int) -> list[dict]: + """尚未登錄為正式事件、但 AI 判定有實質延遲的新聞。 + + 這層讓 L1 在 L2 尚未按「登錄」之前就能看到新偵測到的風險;資料只來自 + 排程/L2 已寫入的 supply_chain_news,本函式不觸發抓取也不寫入。 + """ + rows = conn.execute( + """ + SELECT n.id, n.category, n.region, n.country, n.estimated_delay, + n.title, n.summary, n.url, n.source, n.published_at, n.fetched_at + FROM supply_chain_news n + WHERE COALESCE(n.is_relevant, 1) = 1 + AND COALESCE(n.estimated_delay, 0) > 0 + AND COALESCE(date(n.published_at), date(n.fetched_at), '') >= ? + AND NOT EXISTS ( + SELECT 1 FROM supply_chain_events e WHERE e.news_id = n.id + ) + ORDER BY COALESCE(date(n.published_at), date(n.fetched_at), '') DESC, + n.estimated_delay DESC, n.id DESC + """, + (since,), + ).fetchall() + candidates = [] + seen: set[tuple[str, str]] = set() + for row in rows: + ( + news_id, category, region, country, estimated_delay, + title, summary, url, source, published_at, fetched_at, + ) = row + dedupe_key = (_text(title)[:200], _text(url)) + if dedupe_key in seen or dedupe_key == ("", ""): + continue + seen.add(dedupe_key) + candidates.append( + { + "news_id": news_id, + "event_type": _text(category) or "其他", + "country": _text(country), + "region": _text(region), + "impact_days": _impact_days({"impact_days": estimated_delay}), + "severity": classify_alert_severity(estimated_delay), + "title": _text(title), + "summary": _text(summary), + "url": _text(url), + "news_source": _text(source), + "observed_at": _text(published_at) or _text(fetched_at), + "status": CANDIDATE_STATUS, + } + ) + if len(candidates) >= limit: + break + return candidates + + +def get_latest_event_alerts( + *, + actor: str | None, + since_days: int = 30, + limit: int = 10, + conn: sqlite3.Connection | None = None, + now: datetime | None = None, +) -> dict: + """L1 告警 feed:已確認事件 + AI 偵測待確認候選,皆為唯讀。 + + authorization 先於任何資料讀取;缺少 RISK_OVERVIEW_READ 直接拒絕。 + 每次呼叫都重新查詢資料庫,所以排程或 L2 寫入新聞/事件後, + L1 下一次 rerun 就會看到更新,不依賴 session state。 + """ + require_capability(actor, RISK_OVERVIEW_READ, conn=conn) + limit = max(1, int(limit or 1)) + since = _window_start(since_days, now=now) + + def _load(active_conn: sqlite3.Connection) -> dict: + confirmed = _load_confirmed_alerts(active_conn, since=since, limit=limit) + candidates = _load_candidate_alerts(active_conn, since=since, limit=limit) + severities = [item["severity"] for item in confirmed + candidates] + highest = "無" + for level in ("高", "中", "低"): + if level in severities: + highest = level + break + return { + "since": since, + "since_days": max(0, int(since_days or 0)), + "generated_at": (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + "confirmed": confirmed, + "candidates": candidates, + "confirmed_count": len(confirmed), + "candidate_count": len(candidates), + "highest_severity": highest, + } + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index bcbf9a3..6277518 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -873,10 +873,15 @@ def what_if_simulation( # ── 風險事件與交期 ──────────────────────────────────────────────────── def get_risk_events_list(limit=20): - """取得風險事件列表(id, event_type, region, country, impact_days, description, created_at)。""" + """取得風險事件列表(id, event_type, region, country, impact_days, description, created_at)。 + + add_risk_event 對同一地區是覆寫(id 不變、created_at 更新),所以「最新」 + 必須依 created_at 排序,否則被更新的舊事件永遠排在後面。 + """ conn = sqlite3.connect(DB_FILE) df = __pd_read( - "SELECT id, event_type, region, country, impact_days, description, created_at, news_id FROM supply_chain_events ORDER BY id DESC LIMIT ?", + "SELECT id, event_type, region, country, impact_days, description, created_at, news_id " + "FROM supply_chain_events ORDER BY COALESCE(created_at, '') DESC, id DESC LIMIT ?", conn, params=(limit,), ) diff --git a/frontend/components/risk_overview.py b/frontend/components/risk_overview.py index 148e732..cf4c737 100644 --- a/frontend/components/risk_overview.py +++ b/frontend/components/risk_overview.py @@ -8,7 +8,10 @@ build_purchase_order_template_csv, parse_purchase_order_csv, ) -from backend.l1_monitoring import map_purchase_rows_to_events +from backend.l1_monitoring import ( + get_latest_event_alerts, + map_purchase_rows_to_events, +) from backend.supply_chain_risk import ( get_risk_events_list, get_supply_chain_summary_kpis, @@ -17,6 +20,11 @@ from frontend.ui_utils import show_error +_ALERT_WINDOW_OPTIONS = {"近 7 天": 7, "近 30 天": 30, "近 90 天": 90} +_ALERT_LIMIT = 10 +_SEVERITY_ICONS = {"高": "🔴 高", "中": "🟠 中", "低": "🟡 低", "無": "⚪ 無"} + + _L1_DISPLAY_COLUMNS = { "po_id": "採購單", "supplier_id": "供應商", @@ -44,23 +52,99 @@ def _load_supplier_context(supplier_ids: set[str]) -> dict[str, dict]: return {row["supplier_id"]: dict(row) for row in rows} -def _render_latest_event_alerts(events: list[dict]) -> None: +def _location_label(item: dict) -> str: + parts = [part for part in (item.get("country"), item.get("region")) if part] + return "/".join(parts) or "未設定" + + +def _render_latest_event_alerts(*, actor: str) -> None: st.markdown("#### 🚨 最新事件告警") - if not events: - st.info("目前尚無已登錄的供應鏈風險事件。") + header_left, header_right = st.columns([3, 1]) + with header_left: + st.caption( + "已確認事件來自 L2 登錄;「AI 偵測待確認」來自排程或 L2 更新新聞後、" + "尚未登錄為正式事件的情報。每次重新整理都會直接讀取最新資料。" + ) + with header_right: + window_label = st.selectbox( + "告警時間範圍", + list(_ALERT_WINDOW_OPTIONS), + index=1, + key="l1_alert_window", + label_visibility="collapsed", + ) + since_days = _ALERT_WINDOW_OPTIONS[window_label] + + try: + feed = get_latest_event_alerts( + actor=actor, since_days=since_days, limit=_ALERT_LIMIT + ) + except PermissionError: + st.error("此帳號沒有讀取事件告警的權限。") return + except sqlite3.Error as exc: + show_error("事件告警讀取失敗", exc) + return + + metric_a, metric_b, metric_c = st.columns(3) + metric_a.metric("已確認事件", f"{feed['confirmed_count']} 筆") + metric_b.metric("AI 偵測待確認", f"{feed['candidate_count']} 筆") + metric_c.metric("最高嚴重度", _SEVERITY_ICONS.get(feed["highest_severity"], feed["highest_severity"])) + st.caption(f"統計區間自 {feed['since']} 起 ・ 更新時間 {feed['generated_at']}") + + st.markdown("**已確認事件**") + if not feed["confirmed"]: + st.info("此區間內尚無已登錄的供應鏈風險事件。") + else: + confirmed_rows = [ + { + "嚴重度": _SEVERITY_ICONS.get(item["severity"], item["severity"]), + "事件": item["event_type"], + "國家/地區": _location_label(item), + "預估延遲": f"{item['impact_days']} 天", + "登錄時間": item["created_at"] or "未記錄", + "來源": item["source"], + "來源新聞": item["news_title"] or "—", + "原文連結": item["news_url"] or "", + "事件說明": item["description"] or "未提供", + } + for item in feed["confirmed"] + ] + st.dataframe( + pd.DataFrame(confirmed_rows), + width="stretch", + hide_index=True, + column_config={ + "原文連結": st.column_config.LinkColumn("原文連結", display_text="開啟"), + }, + ) - event_rows = [] - for event in events[:5]: - event_rows.append( + st.markdown("**AI 偵測待確認**") + if not feed["candidates"]: + st.success("此區間內沒有尚未登錄的高風險情報。") + else: + candidate_rows = [ { - "事件": event.get("event_type") or "未分類", - "地區": event.get("region") or event.get("country") or "未設定", - "預估延遲": f"{int(event.get('impact_days') or 0)} 天", - "事件說明": event.get("description") or "未提供", + "嚴重度": _SEVERITY_ICONS.get(item["severity"], item["severity"]), + "類型": item["event_type"], + "國家/地區": _location_label(item), + "預估延遲": f"{item['impact_days']} 天", + "情報時間": item["observed_at"] or "未記錄", + "新聞標題": item["title"] or "(無標題)", + "原文連結": item["url"] or "", + "狀態": item["status"], } + for item in feed["candidates"] + ] + st.dataframe( + pd.DataFrame(candidate_rows), + width="stretch", + hide_index=True, + column_config={ + "原文連結": st.column_config.LinkColumn("原文連結", display_text="開啟"), + }, ) - st.dataframe(pd.DataFrame(event_rows), width="stretch", hide_index=True) + st.caption("待確認情報需由具 L2 權限的人員在「情報與決策」頁登錄後,才會成為正式事件並進入對映。") def _render_read_only_mapping(events: list[dict]) -> None: @@ -143,7 +227,7 @@ def _render_read_only_mapping(events: list[dict]) -> None: key="l1_monitor_download_alerts", ) -def render_risk_overview(): +def render_risk_overview(*, actor: str): """渲染 L1 唯讀閉環:事件告警、熱圖、資料對映與通知預覽。""" st.markdown("#### 📊 供應鏈風險總覽 (Risk Overview)") @@ -177,6 +261,9 @@ def render_risk_overview(): render_risk_heatmap(key="overview_heatmap") st.markdown("
", unsafe_allow_html=True) + _render_latest_event_alerts(actor=actor) + + # CSV 對映只比對「已確認」事件;候選情報尚未登錄,不參與對映。 try: event_frame = get_risk_events_list(limit=30) events = [] if event_frame is None or event_frame.empty else event_frame.to_dict("records") @@ -184,6 +271,5 @@ def render_risk_overview(): show_error("風險事件讀取失敗", exc) events = [] - _render_latest_event_alerts(events) st.markdown("
", unsafe_allow_html=True) _render_read_only_mapping(events) diff --git a/frontend/page_supply_chain_risk.py b/frontend/page_supply_chain_risk.py index e506373..a4be4dc 100644 --- a/frontend/page_supply_chain_risk.py +++ b/frontend/page_supply_chain_risk.py @@ -32,13 +32,13 @@ def render( st.markdown("
🌱 供應鏈與風險監控
", unsafe_allow_html=True) if "analysis" not in sections and "what_if" not in sections: - render_risk_overview() + render_risk_overview(actor=principal.username) return overview_tab, analysis_tab = st.tabs(["📊 L1 風險總覽", "🧭 L2 情報與決策"]) with overview_tab: - render_risk_overview() + render_risk_overview(actor=principal.username) with analysis_tab: # Step 1: Intelligence Hub diff --git a/tests/test_l1_event_alerts.py b/tests/test_l1_event_alerts.py new file mode 100644 index 0000000..957e7f4 --- /dev/null +++ b/tests/test_l1_event_alerts.py @@ -0,0 +1,224 @@ +"""L1 最新事件告警 feed:唯讀、fail-closed、資料直接來自 DB(非 session state)。""" + +from __future__ import annotations + +import ast +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest + +from backend import database +from backend import l1_monitoring +from backend import supply_chain_risk as risk + + +NOW = datetime(2026, 9, 12, 9, 0, 0) +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def alert_db(tmp_path, monkeypatch): + db_path = tmp_path / "l1-alerts.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + database.init_db() + + with sqlite3.connect(db_path) as conn: + conn.executemany( + """ + INSERT INTO supply_chain_news + (id, country, region, title, summary, url, source, published_at, + relevance_tag, fetched_at, category, is_relevant, estimated_delay) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + [ + # 已登錄為事件 → 不應再出現在候選 + (1, "日本", "關東", "Registered port strike", "s", "https://n/1", + "test", "2026-09-10 08:00", "supply_chain", "2026-09-10 09:00", + "罷工", 1, 10), + # 候選:高嚴重度、時間最新 + (2, "台灣", "北區", "Typhoon closes port", "s", "https://n/2", + "test", "2026-09-11 06:00", "supply_chain", "2026-09-11 07:00", + "氣候", 1, 21), + # 候選:published_at 無法解析 → 退回 fetched_at + (3, "越南", "", "Customs slowdown", "s", "https://n/3", + "test", "Thu, 10 Sep 2026 00:00:00 GMT", "supply_chain", + "2026-09-10 12:00", "政策", 1, 5), + # 與 3 重複(同標題同網址)→ 去重 + (4, "越南", "", "Customs slowdown", "s", "https://n/3", + "test", "2026-09-10 12:30", "supply_chain", "2026-09-10 12:30", + "政策", 1, 5), + # 延遲 0 天 → 排除 + (5, "美國", "", "General economy news", "s", "https://n/5", + "test", "2026-09-11 01:00", "supply_chain", "2026-09-11 01:00", + "其他", 1, 0), + # 標記不相關 → 排除 + (6, "德國", "", "Irrelevant", "s", "https://n/6", + "test", "2026-09-11 01:00", "supply_chain", "2026-09-11 01:00", + "其他", 0, 9), + # 超出 30 天視窗 → 排除 + (7, "墨西哥", "", "Old strike", "s", "https://n/7", + "test", "2026-07-01 01:00", "supply_chain", "2026-07-01 01:00", + "罷工", 1, 14), + ], + ) + conn.executemany( + """ + INSERT INTO supply_chain_events + (id, event_type, region, country, impact_days, description, + created_at, news_id) + VALUES (?,?,?,?,?,?,?,?) + """, + [ + # id 最小但 created_at 最新(覆寫更新的情境) + (1, "罷工", "關東", "日本", 10, "由新聞登錄", "2026-09-11 10:00", 1), + (2, "地震", "關西", "日本", 3, "人工登錄", "2026-09-05 10:00", None), + # 超出視窗 + (3, "戰爭", "", "伊朗", 45, "舊事件", "2026-06-01 10:00", None), + ], + ) + conn.commit() + return db_path + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_alert_feed_is_denied_before_any_read(alert_db, monkeypatch, actor): + reads = [] + + def forbidden_read(*args, **kwargs): + reads.append(args) + raise AssertionError("alert data was read before authorization") + + monkeypatch.setattr(l1_monitoring, "_load_confirmed_alerts", forbidden_read) + monkeypatch.setattr(l1_monitoring, "_load_candidate_alerts", forbidden_read) + + with pytest.raises(PermissionError): + l1_monitoring.get_latest_event_alerts(actor=actor, now=NOW) + assert reads == [] + + +@pytest.mark.parametrize("actor", ["viewer", "planner", "approver", "admin"]) +def test_roles_with_overview_read_can_load_feed(alert_db, actor): + feed = l1_monitoring.get_latest_event_alerts(actor=actor, now=NOW) + assert feed["confirmed_count"] == 2 + assert feed["candidate_count"] == 2 + + +def test_confirmed_alerts_are_windowed_ordered_by_time_and_linked_to_news(alert_db): + feed = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + + assert feed["since"] == "2026-08-13" + assert [item["id"] for item in feed["confirmed"]] == [1, 2] + + newest = feed["confirmed"][0] + assert newest["severity"] == "中" + assert newest["source"] == l1_monitoring.ALERT_SOURCE_NEWS + assert newest["news_title"] == "Registered port strike" + assert newest["news_url"] == "https://n/1" + + manual = feed["confirmed"][1] + assert manual["source"] == l1_monitoring.ALERT_SOURCE_MANUAL + assert manual["news_title"] == "" + assert manual["severity"] == "低" + + +def test_candidates_exclude_registered_zero_delay_irrelevant_old_and_duplicates(alert_db): + feed = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + + assert feed["candidate_count"] == 2 + typhoon, customs = feed["candidates"] + assert typhoon["news_id"] == 2 + assert typhoon["severity"] == "高" + assert typhoon["status"] == l1_monitoring.CANDIDATE_STATUS + assert typhoon["url"] == "https://n/2" + # 3 與 4 是同一則新聞的重複列,只能出現一次 + assert customs["news_id"] in {3, 4} + assert customs["title"] == "Customs slowdown" + assert customs["impact_days"] == 5 + assert feed["highest_severity"] == "高" + + +def test_feed_reflects_new_news_and_new_registration_without_session_state(alert_db): + before = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + assert before["candidate_count"] == 2 + + # 排程/L2 寫入一則新新聞 → 下一次讀取立即成為候選 + with sqlite3.connect(alert_db) as conn: + conn.execute( + """ + INSERT INTO supply_chain_news + (id, country, region, title, summary, url, source, published_at, + relevance_tag, fetched_at, category, is_relevant, estimated_delay) + VALUES (8, '南韓', '', 'New rail strike', 's', 'https://n/8', 'test', + '2026-09-12 08:00', 'supply_chain', '2026-09-12 08:30', + '罷工', 1, 12) + """ + ) + conn.commit() + after_news = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + assert after_news["candidate_count"] == 3 + assert after_news["candidates"][0]["news_id"] == 8 + + # L2 登錄該新聞 → 候選消失、已確認增加,並帶著來源新聞 + risk.add_risk_event("罷工", "", "南韓", 12, "登錄", news_id=8, actor="planner") + after_register = l1_monitoring.get_latest_event_alerts(actor="viewer", now=NOW) + assert after_register["candidate_count"] == 2 + assert after_register["confirmed"][0]["news_id"] == 8 + assert after_register["confirmed"][0]["news_title"] == "New rail strike" + + +def test_window_and_limit_are_respected(alert_db): + # 2026-09-12 往前 5 天 = 2026-09-07;事件 2(09-05)落在視窗外 + recent = l1_monitoring.get_latest_event_alerts(actor="viewer", since_days=5, now=NOW) + assert [item["id"] for item in recent["confirmed"]] == [1] + # 視窗邊界含當日:往前 7 天 = 2026-09-05,事件 2 剛好納入 + week = l1_monitoring.get_latest_event_alerts(actor="viewer", since_days=7, now=NOW) + assert [item["id"] for item in week["confirmed"]] == [1, 2] + + quarter = l1_monitoring.get_latest_event_alerts(actor="viewer", since_days=120, now=NOW) + assert [item["id"] for item in quarter["confirmed"]] == [1, 2, 3] + assert 7 in {item["news_id"] for item in quarter["candidates"]} + + capped = l1_monitoring.get_latest_event_alerts(actor="viewer", limit=1, now=NOW) + assert capped["confirmed_count"] == 1 + assert capped["candidate_count"] == 1 + + +def test_severity_classification_thresholds(): + assert l1_monitoring.classify_alert_severity(14) == "高" + assert l1_monitoring.classify_alert_severity(7) == "中" + assert l1_monitoring.classify_alert_severity(1) == "低" + assert l1_monitoring.classify_alert_severity(0) == "無" + assert l1_monitoring.classify_alert_severity(None) == "無" + assert l1_monitoring.classify_alert_severity("bad") == "無" + + +def test_risk_events_list_orders_by_created_at_not_id(alert_db): + frame = risk.get_risk_events_list(limit=10) + assert frame["id"].tolist() == [1, 2, 3] + + +def test_overview_component_forwards_live_actor_to_alert_feed(): + path = ROOT / "frontend/components/risk_overview.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + + render_signature = None + feed_calls = [] + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "render_risk_overview": + render_signature = {a.arg for a in node.args.args + node.args.kwonlyargs} + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "get_latest_event_alerts" + ): + feed_calls.append(node) + assert any(kw.arg == "actor" for kw in node.keywords) + + assert render_signature is not None and "actor" in render_signature + assert feed_calls + + page = (ROOT / "frontend/page_supply_chain_risk.py").read_text(encoding="utf-8") + assert "render_risk_overview(actor=principal.username)" in page diff --git a/tests/test_tier_navigation.py b/tests/test_tier_navigation.py index ea196a8..c045b80 100644 --- a/tests/test_tier_navigation.py +++ b/tests/test_tier_navigation.py @@ -178,7 +178,9 @@ def test_tier_pages_derive_sections_from_live_principal(): assert "principal = load_principal(username)" in risk_source assert "sections = risk_sections(principal)" in risk_source - assert risk_source.count("actor=principal.username") == 5 + # L2 五個渲染器 + L1 總覽的兩個呼叫點(單頁/分頁)都必須轉發 live actor + assert risk_source.count("actor=principal.username") == 7 + assert risk_source.count("render_risk_overview(actor=principal.username)") == 2 assert "principal = load_principal(username)" in exchange_source assert "sections = exchange_sections(principal)" in exchange_source assert "actor=current_actor" in exchange_source From 407bf055c97b7efa3e08bb7d4b81cf501b54d8a6 Mon Sep 17 00:00:00 2001 From: ewiwi Date: Sun, 13 Sep 2026 23:48:33 +0800 Subject: [PATCH 2/7] feat(llm): support extra headers and request timeout for OpenAI-compatible endpoints OpenCode Go (/zen/go/v1) rejects requests without an x-opencode-session header, and occasionally holds a request open for minutes without replying. Neither could be handled from .env before. - LLM_EXTRA_HEADERS (JSON object) is forwarded as litellm extra_headers on every call; malformed values are ignored with a warning instead of breaking module import. - LLM_TIMEOUT (seconds, default 120, was litellm's 600) bounds each request so a hung upstream fails over to LLM_FALLBACK_MODELS quickly. - scripts/check_llm_env.py prints the resolved endpoint settings, verifies LLM_MODEL exists on the endpoint's /models list, and with --call sends one completion through backend.llm_client to prove the key works. Co-Authored-By: Claude Opus 5 --- .env.example | 5 ++ backend/agent_orchestrator.py | 40 +++++++++- scripts/check_llm_env.py | 137 ++++++++++++++++++++++++++++++++ tests/test_llm_extra_headers.py | 78 ++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 scripts/check_llm_env.py create mode 100644 tests/test_llm_extra_headers.py diff --git a/.env.example b/.env.example index de37a5d..796d4f2 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ LLM_FALLBACK_MODELS=openai/kimi-k2.6,gemini/gemini-2.5-flash # ── 供應商金鑰(依你選的模型填對應的)── # OPENAI_API_BASE=https://opencode.ai/zen/go/v1 # OPENAI_API_KEY=replace_with_key +# OpenCode Go 端點(/zen/go/v1)每個請求都要帶 x-opencode-session,否則回 MissingSessionID。 +# JSON 物件;其他端點不需要時留空。 +# LLM_EXTRA_HEADERS={"x-opencode-session": "erp-inventory"} +# 單次 LLM 請求逾時秒數(預設 120);超時視為該模型失敗,改打 LLM_FALLBACK_MODELS +# LLM_TIMEOUT=120 GEMINI_API_KEY=replace_with_gemini_api_key # ── 新聞來源(供應鏈風險頁)── diff --git a/backend/agent_orchestrator.py b/backend/agent_orchestrator.py index c4f3a9d..33a7d99 100644 --- a/backend/agent_orchestrator.py +++ b/backend/agent_orchestrator.py @@ -55,6 +55,42 @@ ] +# ── 額外 HTTP header(選填,JSON 物件):部分 OpenAI 相容端點要求自報身分 ── +# 例:OpenCode Go 每個請求都要帶 x-opencode-session,否則回 MissingSessionID。 +# 格式錯誤時視為未設定(啟動時印警告),不讓 .env 打錯字把整個 LLM 層拖垮。 +def _load_extra_headers() -> dict[str, str]: + raw = os.getenv("LLM_EXTRA_HEADERS", "").strip() + if not raw: + return {} + try: + headers = json.loads(raw) + if not isinstance(headers, dict): + raise ValueError("must be a JSON object") + return {str(k): str(v) for k, v in headers.items()} + except ValueError as e: + print(f"[llm] ignoring LLM_EXTRA_HEADERS: {e}") + return {} + + +_EXTRA_HEADERS = _load_extra_headers() + + +# ── 單次請求逾時(秒):上游卡住時及早放棄、讓 fallback 接手 ────────────── +# litellm 預設 600 秒;OpenCode 這類代理端點偶爾會吞掉請求不回應, +# 現場等 10 分鐘不如 2 分鐘換一家。非法值視為預設。 +def _load_timeout(default: float = 120.0) -> float: + raw = os.getenv("LLM_TIMEOUT", "").strip() + try: + value = float(raw) if raw else default + except ValueError: + print(f"[llm] ignoring LLM_TIMEOUT={raw!r}: not a number") + return default + return value if value > 0 else default + + +_LLM_TIMEOUT = _load_timeout() + + # ════════════════════════════════════════════════════════════════════════ # 0) 各 Agent 的 system prompt(由 registry 組出,DRY + 單一真實來源) # ════════════════════════════════════════════════════════════════════════ @@ -201,7 +237,7 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False, 用量記帳:每次成功呼叫記一列 llm_usage_logs(tokens + 成本), usage_tag 標記用途(route / agent: / aggregate / smalltalk)供歸因。 """ - kw = {"messages": messages, "temperature": temperature} + kw = {"messages": messages, "temperature": temperature, "timeout": _LLM_TIMEOUT} if tools: kw["tools"] = tools kw["tool_choice"] = "auto" @@ -211,6 +247,8 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False, kw["api_key"] = api_key if api_base: kw["api_base"] = api_base + if _EXTRA_HEADERS: + kw["extra_headers"] = dict(_EXTRA_HEADERS) explicit = bool(model or api_key or api_base) chain = [model or DEFAULT_MODEL] diff --git a/scripts/check_llm_env.py b/scripts/check_llm_env.py new file mode 100644 index 0000000..185b179 --- /dev/null +++ b/scripts/check_llm_env.py @@ -0,0 +1,137 @@ +""" +scripts/check_llm_env.py +檢查 .env 的 LLM 端點設定(LLM_MODEL / OPENAI_API_BASE / OPENAI_API_KEY …), +並可選擇真的打一次模型(走 backend.llm_client.complete_text,跟分析頁同一條路)。 + +用法: + python scripts/check_llm_env.py # 只檢查設定 + 查端點模型清單 + python scripts/check_llm_env.py --call # 額外送一句 "ping" 確認金鑰可用 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + +from dotenv import load_dotenv + + +ROOT_DIR = Path(__file__).resolve().parents[1] +DEFAULT_ENV_PATH = ROOT_DIR / ".env" + + +def mask_value(value: str) -> str: + if not value: + return "" + if len(value) <= 8: + return "*" * len(value) + return f"{value[:4]}...{value[-4:]}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Check LLM endpoint settings in .env.") + parser.add_argument("--env", default=str(DEFAULT_ENV_PATH), help="Path to the .env file.") + parser.add_argument("--call", action="store_true", help="Send one real completion to verify the key.") + return parser.parse_args() + + +def _provider_and_name(model: str) -> tuple[str, str]: + provider, _, name = model.partition("/") + return (provider, name) if name else ("", model) + + +def _list_models(api_base: str) -> list[str] | None: + """GET {api_base}/models(OpenAI 相容端點多半不需金鑰)。失敗回 None。""" + try: + req = urllib.request.Request(f"{api_base.rstrip('/')}/models", + headers={"User-Agent": "erp-inventory/check_llm_env"}) + with urllib.request.urlopen(req, timeout=15) as resp: + payload = json.load(resp) + return [m["id"] for m in payload.get("data", [])] + except (urllib.error.URLError, ValueError, KeyError, TimeoutError) as exc: + print(f"- model list: unavailable ({type(exc).__name__})") + return None + + +def main() -> int: + args = parse_args() + env_path = Path(args.env) + if env_path.exists(): + load_dotenv(env_path) + print(f"Loaded env file: {env_path}") + else: + print(f"Env file not found: {env_path}") + + model = os.environ.get("LLM_MODEL", "").strip() + analysis_model = os.environ.get("LLM_ANALYSIS_MODEL", "").strip() + fallbacks = [m.strip() for m in os.environ.get("LLM_FALLBACK_MODELS", "").split(",") if m.strip()] + api_base = os.environ.get("OPENAI_API_BASE", "").strip() + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + gemini_key = os.environ.get("GEMINI_API_KEY", "").strip() + + problems: list[str] = [] + print("\nModel settings:") + print(f"- LLM_MODEL: {model or 'MISSING'}") + print(f"- LLM_ANALYSIS_MODEL: {analysis_model or '(same as LLM_MODEL)'}") + print(f"- LLM_FALLBACK_MODELS: {', '.join(fallbacks) if fallbacks else '(none; primary failure will surface as an error)'}") + print(f"- LLM_EXTRA_HEADERS: {os.environ.get('LLM_EXTRA_HEADERS', '').strip() or '(none)'}") + print(f"- LLM_TIMEOUT: {os.environ.get('LLM_TIMEOUT', '').strip() or '(default 120s)'}") + if not model: + problems.append("LLM_MODEL is empty") + + provider, model_name = _provider_and_name(model) + print("\nProvider keys:") + if provider == "openai": + print(f"- OPENAI_API_BASE: {api_base or '(default api.openai.com)'}") + print(f"- OPENAI_API_KEY: {'OK (' + mask_value(api_key) + ')' if api_key else 'MISSING'}") + if not api_key: + problems.append("OPENAI_API_KEY is empty") + if api_base: + ids = _list_models(api_base) + if ids is not None: + print(f"- model list: {len(ids)} models at {api_base}") + if model_name in ids: + print(f"- {model_name}: found on endpoint") + else: + problems.append(f"{model_name!r} is not in the endpoint model list") + print(f"- {model_name}: NOT FOUND on endpoint") + elif provider == "gemini": + print(f"- GEMINI_API_KEY: {'OK (' + mask_value(gemini_key) + ')' if gemini_key else 'MISSING'}") + if not gemini_key: + problems.append("GEMINI_API_KEY is empty") + elif model: + print(f"- provider {provider!r}: key is read by litellm from its own env var; not checked here") + + for fb in fallbacks: + fb_provider, _ = _provider_and_name(fb) + if fb_provider == "gemini" and not gemini_key: + problems.append(f"fallback {fb} needs GEMINI_API_KEY") + if fb_provider == "openai" and not api_key: + problems.append(f"fallback {fb} needs OPENAI_API_KEY") + + if args.call and not problems: + print("\nLive call:") + sys.path.insert(0, str(ROOT_DIR)) + from backend.llm_client import complete_text + try: + reply = complete_text("Reply with the single word: pong", temperature=0, tag="check_llm_env") + print(f"- {model}: OK -> {reply[:80]!r}") + except Exception as exc: # 診斷工具,錯誤原樣印出 + problems.append(f"live call failed: {type(exc).__name__}: {str(exc)[:300]}") + print(f"- {model}: FAILED") + + if problems: + print("\nLLM readiness: NOT READY") + for p in problems: + print(f"- {p}") + return 1 + print("\nLLM readiness: READY" + ("" if args.call else " (settings only; add --call to verify the key)")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_llm_extra_headers.py b/tests/test_llm_extra_headers.py new file mode 100644 index 0000000..c5cbc35 --- /dev/null +++ b/tests/test_llm_extra_headers.py @@ -0,0 +1,78 @@ +""" +tests/test_llm_extra_headers.py +LLM_EXTRA_HEADERS / LLM_TIMEOUT:讓 .env 指定每次 litellm 呼叫都要附帶的 HTTP header +(OpenCode Go 要求 x-opencode-session,缺了直接回 MissingSessionID)。 +""" + +from types import SimpleNamespace + +from backend import agent_orchestrator as orch + + +def _resp(text="ok"): + msg = SimpleNamespace(content=text, tool_calls=None) + return SimpleNamespace(choices=[SimpleNamespace(message=msg)]) + + +def _capture(monkeypatch): + calls = [] + + def fake_completion(**kw): + calls.append(kw) + return _resp() + + monkeypatch.setattr(orch.litellm, "completion", fake_completion) + monkeypatch.setattr(orch, "_FALLBACK_MODELS", []) + return calls + + +def test_extra_headers_forwarded_to_litellm(monkeypatch): + monkeypatch.setattr(orch, "_EXTRA_HEADERS", {"x-opencode-session": "erp"}) + calls = _capture(monkeypatch) + + orch._llm([{"role": "user", "content": "hi"}]) + + assert calls[0]["extra_headers"] == {"x-opencode-session": "erp"} + + +def test_no_extra_headers_when_unset(monkeypatch): + monkeypatch.setattr(orch, "_EXTRA_HEADERS", {}) + calls = _capture(monkeypatch) + + orch._llm([{"role": "user", "content": "hi"}]) + + assert "extra_headers" not in calls[0] + + +def test_load_extra_headers_parses_json(monkeypatch): + monkeypatch.setenv("LLM_EXTRA_HEADERS", '{"x-opencode-session": "erp", "x-n": 1}') + assert orch._load_extra_headers() == {"x-opencode-session": "erp", "x-n": "1"} + + +def test_load_extra_headers_ignores_bad_values(monkeypatch, capsys): + """打錯字或不是物件 → 當作未設定並印警告,不讓整個模組 import 失敗。""" + for bad in ("not json", '["a", "b"]', " "): + monkeypatch.setenv("LLM_EXTRA_HEADERS", bad) + assert orch._load_extra_headers() == {} + assert "LLM_EXTRA_HEADERS" in capsys.readouterr().out + + +def test_timeout_forwarded_to_litellm(monkeypatch): + """上游卡住時要能放棄換 fallback,所以每次呼叫都帶 timeout。""" + monkeypatch.setattr(orch, "_EXTRA_HEADERS", {}) + monkeypatch.setattr(orch, "_LLM_TIMEOUT", 42.0) + calls = _capture(monkeypatch) + + orch._llm([{"role": "user", "content": "hi"}]) + + assert calls[0]["timeout"] == 42.0 + + +def test_load_timeout_defaults_and_rejects_bad_values(monkeypatch): + monkeypatch.delenv("LLM_TIMEOUT", raising=False) + assert orch._load_timeout() == 120.0 + monkeypatch.setenv("LLM_TIMEOUT", "45") + assert orch._load_timeout() == 45.0 + for bad in ("abc", "0", "-5"): + monkeypatch.setenv("LLM_TIMEOUT", bad) + assert orch._load_timeout() == 120.0 From 51d731ef39b751c32180f5cd12164929ec955286 Mon Sep 17 00:00:00 2001 From: ewiwi Date: Mon, 14 Sep 2026 00:10:13 +0800 Subject: [PATCH 3/7] feat(l2): make the risk workspace explainable, persistent and evidence-gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six issues on the L2 supply-chain risk page, all visible on the demo data: 1. Heatmap defaults were flat: any matching event added +40, so every hotspot with an event sat at 60%. Scoring now weights the most severe event by impact days (30d→50, 14d→40, 7d→30, 1d→20), adds +5 per extra event (cap 15), halves events older than 30 days, and records a human-readable `risk_reason` per hotspot. Country-level events no longer light up every hotspot in the same region; only region-only events do. Lookback is 200 events by created_at instead of the last 20 ids. 2. "曝險金額" was always $0 because the demo DB has no purchase orders. `get_region_exposure(region_key)` returns open-PO count/amount plus supplier counts, the card explains "無未結採購單 ・ 供應商 N 家" instead of a misleading $0, and init_db seeds one open PO per official supplier when ERP_ENABLE_DEMO_SEED is on (opt-in, empty-table only). 3. Cards only recognised manual events, so news-registered hotspots stayed "待評估". `events_for_location` / `is_news_event` classify both; a hotspot with registered news shows "📰 已登錄 N 則情報" and a one-click "建立應變計畫(type・days)" pre-filled from the most severe news event. Events are queried once per render instead of once per card. 4. `add_risk_event` overwrote any earlier manual event for the same (country, region), silently losing history. The dedupe key now includes event_type; `update_risk_event(id, …)` edits a specific event, and the card's "更新應變建議" uses it instead of relying on overwrite. 5. The AI summary lived only in session_state. Every result is now stored in `risk_ai_summaries` (summary, updates, events, audit, counts, actor); L2 reloads the latest on page open, L1 shows it read-only through `l1_monitoring.get_latest_risk_summary` (RISK_OVERVIEW_READ), and the scheduler path persists too, so its suggested events are no longer dropped. 6. Nothing stopped the model from inventing "美國 戰爭 30 天". Updates and events are now gated against evidence built from the news rows' country/region/category/delay and the registered events: regions with no evidence are dropped, delay days are capped at 2× the evidence maximum (floor 7), types contradicting classified evidence become "其他", and types backed only by unclassified news are flagged for human review. The audit list is shown under the summary on L2 and L1. The prompt also tells the model to stay within the supplied data. `get_heatmap_ai_summary` keeps its 3-tuple contract; new code uses `analyze_heatmap_risk`, which returns the full structured result. Co-Authored-By: Claude Opus 5 --- backend/database.py | 56 +++ backend/l1_monitoring.py | 14 + backend/prompts.py | 1 + backend/supply_chain_news.py | 54 +-- backend/supply_chain_risk.py | 541 ++++++++++++++++++++++++-- frontend/components/risk_dashboard.py | 19 - frontend/components/risk_overview.py | 45 +++ frontend/components/supply_map.py | 484 ++++++++++++----------- tests/test_l2_risk_workspace.py | 368 ++++++++++++++++++ 9 files changed, 1274 insertions(+), 308 deletions(-) create mode 100644 tests/test_l2_risk_workspace.py diff --git a/backend/database.py b/backend/database.py index 4af17ec..fa475b4 100644 --- a/backend/database.py +++ b/backend/database.py @@ -52,6 +52,44 @@ def _ensure_db_dir(): os.makedirs(d, exist_ok=True) +def is_demo_seed_enabled() -> bool: + """合成示範資料(採購單等)只能由環境變數明確啟用,避免污染真實資料。""" + return os.getenv("ERP_ENABLE_DEMO_SEED", "").strip().lower() in {"1", "true", "yes", "on"} + + +def _seed_demo_purchase_orders(c) -> int: + """為每家正式供應商建一張進行中採購單(含明細)。表非空或無商品時不動作。""" + if c.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] > 0: + return 0 + products = c.execute( + "SELECT product_id, COALESCE(cost, price, 1000) FROM inventory ORDER BY product_id" + ).fetchall() + suppliers = c.execute( + "SELECT supplier_id FROM suppliers WHERE is_official = 1 ORDER BY supplier_id" + ).fetchall() + if not products or not suppliers: + return 0 + statuses = ("已下單", "生產中", "運送中") + created = 0 + for idx, (supplier_id,) in enumerate(suppliers): + product_id, unit_cost = products[idx % len(products)] + qty = 20 + (idx % 5) * 10 + unit_price = round(float(unit_cost or 1000), 2) + po_id = f"PO-DEMO-{idx + 1:03d}" + order_date = (datetime.now() - timedelta(days=3 + idx % 12)).strftime("%Y-%m-%d") + c.execute( + "INSERT OR IGNORE INTO purchase_orders (po_id, supplier_id, order_date, status, total_amount, note) " + "VALUES (?,?,?,?,?,?)", + (po_id, supplier_id, order_date, statuses[idx % len(statuses)], qty * unit_price, "demo seed"), + ) + c.execute( + "INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)", + (po_id, product_id, qty, unit_price), + ) + created += 1 + return created + + def init_db(): _ensure_db_dir() conn = sqlite3.connect(DB_FILE) @@ -170,6 +208,18 @@ def init_db(): ai_summary TEXT, updated_at TEXT )''') + c.execute('''CREATE TABLE IF NOT EXISTS risk_ai_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + actor TEXT, + reference_date TEXT, + summary TEXT NOT NULL, + updates_json TEXT NOT NULL, + events_json TEXT NOT NULL, + audit_json TEXT NOT NULL, + news_count INTEGER DEFAULT 0, + event_count INTEGER DEFAULT 0 + )''') c.execute('''CREATE TABLE IF NOT EXISTS esg_targets (id INTEGER PRIMARY KEY AUTOINCREMENT, target_year INTEGER, scope INTEGER, baseline_kg_co2 REAL, target_kg_co2 REAL, note TEXT)''') # 永續 ESG:風險管理係數(地區/事件類型/供應商類別 → 風險分數 0–100、權重) c.execute('''CREATE TABLE IF NOT EXISTS esg_risk_factors (id INTEGER PRIMARY KEY AUTOINCREMENT, risk_type TEXT, risk_key TEXT, risk_score REAL, weight REAL, note TEXT, updated_at TEXT, UNIQUE(risk_type, risk_key))''') @@ -740,6 +790,12 @@ def init_db(): for row in top20: c.execute("UPDATE suppliers SET is_official = 1 WHERE supplier_id=?", (row[0],)) + # Demo 曝險資料:供應鏈風險卡片的「曝險金額」算的是未結採購單,demo 供應商 + # 原本沒有任何採購單,所有據點永遠 $0。只在明確 opt-in(ERP_ENABLE_DEMO_SEED) + # 且採購單表為空時,替正式供應商各建一張進行中的採購單。 + if is_demo_mode_enabled() and is_demo_seed_enabled(): + _seed_demo_purchase_orders(c) + # N3:既有 DB 的 legacy 明文密碼一次性升級為 salted hash(自我修復式遷移) try: from backend.passwords import hash_password, is_hashed diff --git a/backend/l1_monitoring.py b/backend/l1_monitoring.py index 076c7e0..d2cac2e 100644 --- a/backend/l1_monitoring.py +++ b/backend/l1_monitoring.py @@ -317,3 +317,17 @@ def _load(active_conn: sqlite3.Connection) -> dict: return _load(conn) with sqlite3.connect(database.DB_FILE) as owned_conn: return _load(owned_conn) + + +# ── 最新 AI 風險摘要(唯讀) ────────────────────────────────────────── + + +def get_latest_risk_summary(*, actor: str | None, conn: sqlite3.Connection | None = None) -> dict | None: + """L2/排程最近一次產生並落地的 AI 風險摘要;L1 只讀、不觸發任何模型呼叫。 + + authorization 先於任何資料讀取;缺少 RISK_OVERVIEW_READ 直接拒絕。 + """ + require_capability(actor, RISK_OVERVIEW_READ, conn=conn) + from backend.supply_chain_risk import get_latest_ai_risk_summary + + return get_latest_ai_risk_summary(conn=conn) diff --git a/backend/prompts.py b/backend/prompts.py index 0afa71a..90ae72d 100644 --- a/backend/prompts.py +++ b/backend/prompts.py @@ -36,6 +36,7 @@ 2. 「更新」與「事件」中的地區名稱請從上方合法區域清單挑選;新聞若只提到國家(如「台灣」),請展開為清單內對應的完整名稱。不在清單上的地點可略過(系統會自動過濾)。 3. 延遲天數參考:戰爭 30-90、罷工 7-21、氣候 3-14、政策 7-30、交通 1-7。 4. 摘要中每提到一個受影響區域,「更新」與「事件」就各對應一筆,天數需與摘要一致。 +5. 只能根據上方「已登錄事件」與「採集新聞」下判斷;資料裡沒有出現的地區、事件類型或延遲天數不要自行推測(系統會依證據過濾)。 【輸出格式】只輸出以下 JSON 物件(頂層必須是物件、不要其他文字): {{ diff --git a/backend/supply_chain_news.py b/backend/supply_chain_news.py index 9634c59..be79bd9 100644 --- a/backend/supply_chain_news.py +++ b/backend/supply_chain_news.py @@ -10,9 +10,9 @@ from datetime import datetime, timedelta import email.utils from typing import List, Optional -from urllib.parse import quote_plus - -from .access_control import RISK_WORKSPACE_WRITE, require_capability +from urllib.parse import quote_plus + +from .access_control import RISK_WORKSPACE_WRITE, require_capability # 國家名稱 → 英文搜尋用 / 雙碼(給 GNews API 用) COUNTRY_MAP = { @@ -245,20 +245,20 @@ def get_news_from_db( return [dict(r) for r in rows] -def refresh_news_for_countries( - countries: List[str], - gemini_api_key: Optional[str] = None, - gnews_api_key: Optional[str] = None, - max_per_country: int = 15, - within_days: int = 7, - gemini_model: str = "gemini-2.5-flash", - *, - actor: str | None = None, -) -> dict: - """ - 為多個國家平行抓取新聞,並使用批量 AI 歸類以極大化提升效能。 - """ - require_capability(actor, RISK_WORKSPACE_WRITE) +def refresh_news_for_countries( + countries: List[str], + gemini_api_key: Optional[str] = None, + gnews_api_key: Optional[str] = None, + max_per_country: int = 15, + within_days: int = 7, + gemini_model: str = "gemini-2.5-flash", + *, + actor: str | None = None, +) -> dict: + """ + 為多個國家平行抓取新聞,並使用批量 AI 歸類以極大化提升效能。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) import concurrent.futures from .supply_chain_risk import batch_infer_affected_region_from_news from .llm_client import llm_available @@ -323,18 +323,18 @@ def fetch_job(c): try: from .supply_chain_risk import get_heatmap_ai_summary, apply_heatmap_updates all_news = get_news_from_db(limit=25, order_by_latest=True, within_days=30) - news_context = "\n".join([ - f"{(n.get('title') or '')} {(n.get('summary') or '')[:150]} [{n.get('published_at') or n.get('fetched_at') or ''}]" - for n in all_news - ]) ref_date = datetime.now().strftime("%Y-%m-%d") - summary_text, updates, _ = get_heatmap_ai_summary(news_context=news_context, reference_date=ref_date) + # news_items 讓摘要同時拿到證據(國家/類別/天數);actor 讓結果落地到 + # risk_ai_summaries,排程產生的建議事件不再被丟掉,L1/L2 重開頁面都看得到。 + summary_text, updates, _ = get_heatmap_ai_summary( + news_context="", reference_date=ref_date, news_items=all_news, actor=actor, + ) if updates: - apply_heatmap_updates(updates, summary_text, actor=actor) - except PermissionError: - raise - except Exception: - pass + apply_heatmap_updates(updates, summary_text, actor=actor) + except PermissionError: + raise + except Exception: + pass return { "updated": total_saved, diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index 6277518..214b84a 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -153,7 +153,8 @@ def get_recent_events_for_delay(limit=50): """取得近期供應鏈事件,供地圖判定出貨延遲狀況。""" conn = sqlite3.connect(DB_FILE) df = __pd_read( - "SELECT event_type, region, country, impact_days FROM supply_chain_events ORDER BY id DESC LIMIT ?", + "SELECT event_type, region, country, impact_days, created_at, news_id " + "FROM supply_chain_events ORDER BY COALESCE(created_at, '') DESC, id DESC LIMIT ?", conn, params=(limit,), ) @@ -161,6 +162,94 @@ def get_recent_events_for_delay(limit=50): return df +# ── 熱圖事件加權 ────────────────────────────────────────────────────── +# 原本「只要有任何事件就 +40」會讓每個有事件的據點都停在 60%,看不出差異。 +# 改為:依「最嚴重事件的延遲天數」給分,多筆事件再加成,逾期事件減半。 +HEATMAP_BASE_RISK = 20.0 +HEATMAP_EVENT_POINTS = ((30, 50), (14, 40), (7, 30), (1, 20), (0, 10)) # (延遲天數下限, 加權) +HEATMAP_EXTRA_EVENT_BONUS = 5 # 每多一筆事件 +HEATMAP_EXTRA_EVENT_CAP = 15 +HEATMAP_EVENT_STALE_DAYS = 30 # 登錄超過此天數的事件加權減半 +HEATMAP_EVENT_LOOKBACK = 200 # 參與計算的事件筆數上限(依登錄時間新→舊) + + +def _event_points(impact_days) -> float: + try: + days = max(0, int(impact_days or 0)) + except (TypeError, ValueError): + days = 0 + for floor, points in HEATMAP_EVENT_POINTS: + if days >= floor: + return float(points) + return 0.0 + + +def _clean_text(value) -> str: + """DataFrame 的 NaN/None 一律視為空字串(str(nan) 會變成 "nan" 而誤判為有值)。""" + if value is None: + return "" + try: + if pd.isna(value): + return "" + except (TypeError, ValueError): + pass + text = str(value).strip() + return "" if text.casefold() in {"nan", "none"} else text + + +def _event_matches_location(ev: dict, country: str, region: str) -> bool: + """事件有國家 → 只比對國家;沒有國家(純大區域事件,如「中東」)→ 才比對地區。 + + 原本「國家或地區任一命中」會讓「台灣/亞洲」的事件同時點亮越南、日本等所有亞洲據點。 + """ + ev_country = _clean_text(ev.get("country")) + ev_region = _clean_text(ev.get("region")) + if ev_country: + return bool(country) and ev_country in country + return bool(ev_region and region and ev_region in region) + + +def score_region_events(country: str, region: str, events, *, now=None) -> dict: + """算出單一據點的事件加權與可讀理由。 + + 回傳 {"points", "count", "max_days", "reason"};events 可為 DataFrame 或 list[dict]。 + """ + if events is None: + rows = [] + elif hasattr(events, "iterrows"): + rows = [r.to_dict() for _, r in events.iterrows()] + else: + rows = list(events) + matched = [ev for ev in rows if _event_matches_location(ev, country or "", region or "")] + if not matched: + return {"points": 0.0, "count": 0, "max_days": 0, "reason": "近期無登錄事件"} + + reference = now or datetime.now() + best = 0.0 + max_days = 0 + stale = 0 + for ev in matched: + points = _event_points(ev.get("impact_days")) + try: + max_days = max(max_days, int(ev.get("impact_days") or 0)) + except (TypeError, ValueError): + pass + created = str(ev.get("created_at") or "")[:10] + try: + age = (reference - datetime.strptime(created, "%Y-%m-%d")).days + except ValueError: + age = 0 + if age > HEATMAP_EVENT_STALE_DAYS: + points *= 0.5 + stale += 1 + best = max(best, points) + bonus = min(HEATMAP_EXTRA_EVENT_CAP, HEATMAP_EXTRA_EVENT_BONUS * (len(matched) - 1)) + reason = f"{len(matched)} 則事件・最長延遲 {max_days} 天" + if stale: + reason += f"({stale} 則已逾 {HEATMAP_EVENT_STALE_DAYS} 天)" + return {"points": best + bonus, "count": len(matched), "max_days": max_days, "reason": reason} + + def get_region_procurement_share(): """依地區彙總採購金額,計算各地區採購佔比(該地區供應商之採購額 / 全公司採購額)。 回傳 list of dict: region_key, display_name, procurement_ratio (0~1), total_amount, supplier_count。 @@ -236,10 +325,9 @@ def get_risk_heatmap_data(): suppliers = get_suppliers_for_map() if suppliers is None or suppliers.empty: return [] - events = get_recent_events_for_delay(20) + events = get_recent_events_for_delay(HEATMAP_EVENT_LOOKBACK) region_scores = get_region_risk_scores() procurement_by_region = get_region_procurement_share() - default_risk = 20.0 seen = set() default_rows = [] for _, s in suppliers.iterrows(): @@ -249,24 +337,26 @@ def get_risk_heatmap_data(): if key in seen: continue seen.add(key) - risk = default_risk - if events is not None and not events.empty: - for _, ev in events.iterrows(): - if (ev.get("country") and ev["country"] in country) or (ev.get("region") and ev["region"] in region): - risk = min(100, risk + 40) - break + event_score = score_region_events(country, region, events) + risk = min(100.0, HEATMAP_BASE_RISK + event_score["points"]) + reasons = [event_score["reason"]] for k, v in region_scores.items(): if k in region or k in country: + if v > risk: + reasons.append(f"地區係數 {k} {v:.0f}%") risk = max(risk, min(100, v)) break if key in procurement_by_region: ratio = procurement_by_region[key]["procurement_ratio"] if ratio >= 0.35: - risk = max(risk, 70) + floor = 70 elif ratio >= 0.15: - risk = max(risk, 45) + floor = 45 else: - risk = max(risk, min(35, 20 + ratio * 100)) + floor = min(35, 20 + ratio * 100) + if floor > risk: + reasons.append(f"採購集中度 {ratio:.0%}") + risk = max(risk, floor) lat, lon = s.get("latitude"), s.get("longitude") if lat is None or lon is None: continue @@ -276,6 +366,9 @@ def get_risk_heatmap_data(): "latitude": float(lat), "longitude": float(lon), "risk_pct": round(risk, 1), + "risk_reason": ";".join(reasons), + "event_count": event_score["count"], + "event_max_days": event_score["max_days"], "ai_summary": None, "updated_at": None, }) @@ -304,12 +397,17 @@ def get_risk_heatmap_data(): rk = row["region_key"] if rk in overrides: o = overrides[rk] + overridden = o.get("risk_pct") is not None out.append({ "region_key": rk, "display_name": row["display_name"], "latitude": o.get("latitude") if o.get("latitude") is not None else row["latitude"], "longitude": o.get("longitude") if o.get("longitude") is not None else row["longitude"], - "risk_pct": o.get("risk_pct") if o.get("risk_pct") is not None else row["risk_pct"], + "risk_pct": o.get("risk_pct") if overridden else row["risk_pct"], + "risk_reason": (f"AI/人工設定({o.get('updated_at') or '時間未記錄'})" if overridden + else row["risk_reason"]), + "event_count": row["event_count"], + "event_max_days": row["event_max_days"], "ai_summary": o.get("ai_summary"), "updated_at": o.get("updated_at"), }) @@ -397,10 +495,248 @@ def _coerce_heatmap_events(raw_events) -> list[dict]: return out -def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_date: str = "2026-04-11", model: str | None = None) -> tuple[str, list[dict], list[dict]]: +# ── AI 風險摘要:證據閘門 + 持久化 ─────────────────────────────────── +# 摘要原本只活在 session_state:重新整理就消失、L1 看不到、排程產生的建議事件直接丟掉。 +# 現在每次產生都寫進 risk_ai_summaries,L2 重開頁面與 L1 總覽都讀最新一筆。 + +AI_SUMMARY_TABLE = "risk_ai_summaries" +EVIDENCE_DAYS_MULTIPLIER = 2 # AI 建議延遲天數上限 = 證據最長天數 × 此倍率 +EVIDENCE_DAYS_FLOOR = 7 # …但至少允許到這個天數(證據只有 1-2 天時仍可合理外推) + + +def _ensure_summary_table(conn: sqlite3.Connection) -> None: + conn.execute( + f"""CREATE TABLE IF NOT EXISTS {AI_SUMMARY_TABLE} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + actor TEXT, + reference_date TEXT, + summary TEXT NOT NULL, + updates_json TEXT NOT NULL, + events_json TEXT NOT NULL, + audit_json TEXT NOT NULL, + news_count INTEGER DEFAULT 0, + event_count INTEGER DEFAULT 0 + )""" + ) + + +def save_ai_risk_summary(result: dict, *, actor=None, conn=None) -> int: + """把一次 AI 摘要結果落地;回傳新列 id。寫入需 RISK_WORKSPACE_WRITE。""" + import json + require_capability(actor, RISK_WORKSPACE_WRITE, conn=conn) + owned = conn is None + conn = conn or sqlite3.connect(DB_FILE) + try: + _ensure_summary_table(conn) + cur = conn.execute( + f"""INSERT INTO {AI_SUMMARY_TABLE} + (created_at, actor, reference_date, summary, updates_json, events_json, + audit_json, news_count, event_count) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + result.get("generated_at") or datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + actor, + result.get("reference_date"), + result.get("summary") or "", + json.dumps(result.get("updates") or [], ensure_ascii=False), + json.dumps(result.get("events") or [], ensure_ascii=False), + json.dumps(result.get("audit") or [], ensure_ascii=False), + int(result.get("news_count") or 0), + int(result.get("event_count") or 0), + ), + ) + conn.commit() + return int(cur.lastrowid) + finally: + if owned: + conn.close() + + +def get_latest_ai_risk_summary(conn=None) -> dict | None: + """最新一筆已落地的 AI 摘要;沒有回 None。回傳鍵與 analyze_heatmap_risk 一致。""" + import json + owned = conn is None + conn = conn or sqlite3.connect(DB_FILE) + try: + _ensure_summary_table(conn) + row = conn.execute( + f"""SELECT id, created_at, actor, reference_date, summary, updates_json, + events_json, audit_json, news_count, event_count + FROM {AI_SUMMARY_TABLE} ORDER BY id DESC LIMIT 1""" + ).fetchone() + finally: + if owned: + conn.close() + if not row: + return None + (row_id, created_at, actor, reference_date, summary, updates_json, + events_json, audit_json, news_count, event_count) = row + + def _load(raw): + try: + value = json.loads(raw or "[]") + return value if isinstance(value, list) else [] + except ValueError: + return [] + + return { + "summary_id": row_id, + "generated_at": created_at, + "actor": actor, + "reference_date": reference_date, + "summary": summary or "", + "updates": _load(updates_json), + "events": _load(events_json), + "audit": _load(audit_json), + "news_count": int(news_count or 0), + "event_count": int(event_count or 0), + "error": False, + } + + +def _norm_loc(value) -> str: + return "".join(str(value or "").strip().casefold().split()) + + +def _loc_hits(name: str, known: set[str]) -> bool: + """AI 給的地區名(如「台灣 北區」「美國」)是否被任一證據地點涵蓋。""" + target = _norm_loc(name) + if not target: + return False + parts = [_norm_loc(part) for part in str(name).split() if part.strip()] + for loc in known: + if not loc or len(loc) < 2: + continue + if loc == target or loc in target or target in loc: + return True + if any(part and (loc == part or loc in part or part in loc) for part in parts): + return True + return False + + +def build_risk_evidence(news_items, events) -> dict: + """把新聞(含抓取時 AI 標的國家/地區/類別/天數)與已登錄事件整理成證據表。 + + 回傳 {"locations": {地點: {"types": set, "max_days": int}}};地點以正規化字串為鍵。 """ - 獲取 AI 熱圖摘要,並整合現有的正式事件,確保「情報 -> 摘要 -> 應變」流程連貫。 + locations: dict[str, dict] = {} + + def _add(country, region, etype, days): + try: + days = max(0, int(days or 0)) + except (TypeError, ValueError): + days = 0 + for raw in (country, region): + key = _norm_loc(raw) + if not key: + continue + entry = locations.setdefault(key, {"types": set(), "max_days": 0}) + if etype: + entry["types"].add(str(etype).strip()) + entry["max_days"] = max(entry["max_days"], days) + + for n in news_items or []: + _add(n.get("country"), n.get("region"), n.get("category"), n.get("estimated_delay")) + if events is not None: + rows = [r.to_dict() for _, r in events.iterrows()] if hasattr(events, "iterrows") else list(events) + for ev in rows: + _add(ev.get("country"), ev.get("region"), ev.get("event_type"), ev.get("impact_days")) + return {"locations": locations} + + +def _evidence_for(name: str, evidence: dict) -> dict | None: + """回傳最能對應 name 的證據項(合併所有命中的地點)。""" + merged = None + for loc, entry in (evidence.get("locations") or {}).items(): + if _loc_hits(name, {loc}): + if merged is None: + merged = {"types": set(), "max_days": 0} + merged["types"] |= set(entry["types"]) + merged["max_days"] = max(merged["max_days"], entry["max_days"]) + return merged + + +def gate_by_evidence(updates: list[dict], events: list[dict], evidence: dict) -> tuple[list, list, list]: + """證據閘門:AI 提到但資料裡沒有的地區一律略過;事件類型/天數超出證據則調整。 + + 回傳 (kept_updates, kept_events, audit)。audit 每筆: + {"kind": "更新"|"事件", "name", "action": "略過"|"調整", "reason"} + 證據表為空(例如無新聞也無事件)時不做閘門、原樣放行——沒有依據可比就不該猜。 + """ + if not (evidence.get("locations") or {}): + return list(updates or []), list(events or []), [] + audit: list[dict] = [] + kept_updates = [] + for u in updates or []: + name = str(u.get("display_name") or "").strip() + if _evidence_for(name, evidence) is None: + audit.append({"kind": "更新", "name": name, "action": "略過", + "reason": "新聞與已登錄事件中都沒有此地區"}) + continue + kept_updates.append(u) + + kept_events = [] + for e in events or []: + country = str(e.get("country") or "").strip() + region = str(e.get("region") or "").strip() + # AI 常把「地區」填成完整節點名(如「美國 北美洲」),避免重複成「美國 美國 北美洲」 + name = region if (country and region.startswith(country)) else " ".join(p for p in (country, region) if p) + ev = _evidence_for(name or region or country, evidence) + if ev is None: + audit.append({"kind": "事件", "name": name, "action": "略過", + "reason": "新聞與已登錄事件中都沒有此地區"}) + continue + item = dict(e) + notes = [] + cap = max(EVIDENCE_DAYS_FLOOR, ev["max_days"] * EVIDENCE_DAYS_MULTIPLIER) + try: + days = int(item.get("impact_days") or 0) + except (TypeError, ValueError): + days = 0 + if days > cap: + notes.append(f"延遲 {days} 天超出證據上限({cap} 天),已調整") + item["impact_days"] = cap + etype = str(item.get("event_type") or "其他").strip() + specific_types = {t for t in ev["types"] if t and t != "其他"} + if etype != "其他": + if specific_types and etype not in specific_types: + notes.append(f"類型「{etype}」無新聞依據(證據類型:{'、'.join(sorted(specific_types))}),改為「其他」") + item["event_type"] = "其他" + elif not specific_types: + # 證據只有未分類(其他)新聞:不改寫,但提醒人工確認類型 + audit.append({"kind": "事件", "name": name, "action": "提醒", + "reason": f"類型「{etype}」僅由未分類新聞推得,請人工確認"}) + if notes: + audit.append({"kind": "事件", "name": name, "action": "調整", "reason": ";".join(notes)}) + kept_events.append(item) + return kept_updates, kept_events, audit + + +def _summary_news_context(news_items) -> str: + return "\n".join( + (n.get("title") or "") + " " + (n.get("summary") or "")[:200] + + f" [{n.get('published_at') or n.get('fetched_at') or ''}, 預估延遲: {n.get('estimated_delay') or 0}天]" + for n in (news_items or []) + ) + + +def analyze_heatmap_risk( + news_items=None, *, news_context: str = "", reference_date: str | None = None, + actor=None, persist: bool = True, +) -> dict: + """AI 熱圖摘要(結構化)。 + + - news_items:新聞列(含 country/region/category/estimated_delay)→ 同時當 prompt 素材與證據 + - news_context:舊介面的純文字素材(沒有 news_items 時使用;證據只剩已登錄事件) + - actor 有給且 persist=True 時把結果寫進 risk_ai_summaries(需 RISK_WORKSPACE_WRITE) + 回傳 dict:summary / updates / events / audit / evidence_locations / generated_at / + reference_date / news_count / event_count / summary_id / error """ + import json + from backend.llm_client import complete_text + + reference_date = reference_date or datetime.now().strftime("%Y-%m-%d") events_df = get_active_risk_events() events_text = "目前尚無已登錄事件。" if events_df is not None and not events_df.empty: @@ -428,6 +764,8 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ finally: conn.close() + if news_items: + news_context = _summary_news_context(news_items) prompt = HEATMAP_AI_SUMMARY_PROMPT_V2.format( reference_date=reference_date, events_text=events_text, @@ -449,26 +787,54 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ if v not in name_expansions[r]: name_expansions[r].append(v) + evidence = build_risk_evidence(news_items, events_df) + result = { + "summary": "", + "updates": [], + "events": [], + "audit": [], + "evidence_locations": sorted(evidence["locations"]), + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "reference_date": reference_date, + "news_count": len(news_items or []), + "event_count": 0 if events_df is None else int(len(events_df)), + "summary_id": None, + "error": False, + } try: # issue #27/#47:統一 LLM 入口 + 結構化輸出(JSON)。 - # 合法區域檢核由 _gate_heatmap_updates 執行,prompt 只做平述引導; - # 原本的 UPDATE:/EVENT: 行解析與正文回填 regex 全數移除。 - import json - from backend.llm_client import complete_text + # 合法區域檢核由 _gate_heatmap_updates 執行、證據檢核由 gate_by_evidence 執行, + # prompt 只做平述引導。 raw = (complete_text(prompt, temperature=0.3, json_mode=True, tag="analysis:heatmap") or "").strip() if not raw: - return "AI 摘要失敗:模型未回傳內容。", [], [] + result.update({"summary": "AI 摘要失敗:模型未回傳內容。", "error": True}) + return result payload = json.loads(re.sub(r"```json\s*|```\s*", "", raw)) summary = str(payload.get("摘要") or "").strip() or "(AI 未提供摘要內容)" updates = _gate_heatmap_updates(payload.get("更新"), valid_list, name_expansions) suggested_events = _coerce_heatmap_events(payload.get("事件")) - return summary, updates, suggested_events + updates, suggested_events, audit = gate_by_evidence(updates, suggested_events, evidence) + result.update({"summary": summary, "updates": updates, "events": suggested_events, "audit": audit}) except Exception as e: import traceback traceback.print_exc() - return f"AI 摘要解析失敗:{e}", [], [] + result.update({"summary": f"AI 摘要解析失敗:{e}", "error": True}) + return result + + if persist and actor: + result["summary_id"] = save_ai_risk_summary(result, actor=actor) + return result + + +def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_date: str = "2026-04-11", + model: str | None = None, *, news_items=None, actor=None) -> tuple[str, list[dict], list[dict]]: + """舊介面:回傳 (摘要, 更新, 事件) 三元組。新程式請用 analyze_heatmap_risk 拿完整結果。""" + result = analyze_heatmap_risk( + news_items, news_context=news_context, reference_date=reference_date, actor=actor, + ) + return result["summary"], result["updates"], result["events"] def apply_heatmap_updates(updates, ai_summary=None, *, actor=None): @@ -584,6 +950,45 @@ def generate_communication_draft(api_key: str = "", context: str = "", target_ty +def get_region_exposure(region_key) -> dict: + """單一據點的曝險資訊:未結採購單金額/張數 + 該區供應商數。 + + 「曝險金額」只算 status 不在 (已完成, 已取消) 的採購單;沒有採購單時金額為 0, + 前端應改顯示供應商家數而不是誤導的 $0。 + """ + conn = sqlite3.connect(DB_FILE) + try: + if "|" in str(region_key): + # 熱圖 region_key「國家|地區」→ 精準對到該據點,不展開大區域 + country, region = [part.strip() for part in str(region_key).split("|", 1)] + where_sub = ["COALESCE(s.country, '') = ? AND COALESCE(s.region, '') = ?"] + params_sub = [country, region] + else: + where_sub, params_sub = _get_expanded_region_where(region_key, None, prefix="s.") + supplier_where = " AND ".join(where_sub) or "1=1" + sup_row = conn.execute( + f"""SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_official=1 THEN 1 ELSE 0 END), 0) + FROM suppliers s WHERE {supplier_where}""", + tuple(params_sub), + ).fetchone() + po_row = conn.execute( + f"""SELECT COUNT(p.po_id), COALESCE(SUM(p.total_amount), 0) + FROM purchase_orders p + JOIN suppliers s ON p.supplier_id = s.supplier_id + WHERE (p.status IS NULL OR p.status NOT IN ('已完成','已取消')) + AND {supplier_where}""", + tuple(params_sub), + ).fetchone() + finally: + conn.close() + return { + "supplier_count": int(sup_row[0] or 0), + "official_supplier_count": int(sup_row[1] or 0), + "open_po_count": int(po_row[0] or 0), + "open_po_amount": float(po_row[1] or 0), + } + + def get_total_impact_amount(region_key): """計算特定地區受波及的採購總金額 (美元)。""" conn = sqlite3.connect(DB_FILE) @@ -944,26 +1349,31 @@ def get_historical_event_precedents(): def add_risk_event( event_type, region, country, impact_days, description, news_id=None, *, actor=None ): - """新增或更新風險事件(如果該區域已存在事件則覆蓋)。""" + """新增風險事件;同一 (國家, 地區, 事件類型, 來源新聞) 已存在時更新該筆。 + + 原本只比對 (國家, 地區),導致同一地區先後登錄「罷工」再登錄「地震」時 + 前者被靜默覆蓋、歷史消失。不同類型現在會各自保留,熱圖加權也會依筆數加成。 + 要修改既有事件請用 update_risk_event(event_id, …)。 + """ require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) c = conn.cursor() - - # 核心優化:直接覆寫同區域的正式事件 (news_id 為空者) + c.execute( - """SELECT id FROM supply_chain_events - WHERE COALESCE(country, '') = ? AND COALESCE(region, '') = ? AND news_id IS ?""", - (country or "", region or "", news_id) + """SELECT id FROM supply_chain_events + WHERE COALESCE(country, '') = ? AND COALESCE(region, '') = ? + AND COALESCE(event_type, '') = ? AND news_id IS ?""", + (country or "", region or "", event_type or "", news_id) ) existing = c.fetchone() - + if existing: event_id = existing[0] c.execute( - """UPDATE supply_chain_events - SET event_type=?, impact_days=?, description=?, created_at=? + """UPDATE supply_chain_events + SET impact_days=?, description=?, created_at=? WHERE id=?""", - (event_type, impact_days, description or None, datetime.now().strftime("%Y-%m-%d %H:%M"), event_id) + (impact_days, description or None, datetime.now().strftime("%Y-%m-%d %H:%M"), event_id) ) conn.commit() conn.close() @@ -979,6 +1389,37 @@ def add_risk_event( return new_id +def update_risk_event( + event_id, *, event_type=None, impact_days=None, description=None, actor=None +): + """就地修改一筆既有事件(AI 建議天數異動、人工修正)。只更新有給的欄位。 + + 回傳 True 表示有更新到資料;找不到 event_id 回 False。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) + fields, params = [], [] + if event_type is not None: + fields.append("event_type=?"); params.append(event_type) + if impact_days is not None: + fields.append("impact_days=?"); params.append(int(impact_days)) + if description is not None: + fields.append("description=?"); params.append(description or None) + if not fields: + return False + fields.append("created_at=?") + params.append(datetime.now().strftime("%Y-%m-%d %H:%M")) + params.append(int(event_id)) + conn = sqlite3.connect(DB_FILE) + try: + cur = conn.execute( + f"UPDATE supply_chain_events SET {', '.join(fields)} WHERE id=?", tuple(params) + ) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + def delete_risk_event(event_id, *, actor=None): """刪除一筆風險事件。""" require_capability(actor, RISK_WORKSPACE_WRITE) @@ -1507,3 +1948,39 @@ def __empty_df(): def __pd_concat(a, b): import pandas as pd return pd.concat([a, b], ignore_index=True) + + +def events_for_location(country: str, region: str, events=None) -> list[dict]: + """某據點命中的事件(含新聞登錄與人工登錄),依延遲天數→登錄時間新到舊排序。 + + 前端卡片用它判斷「已有情報/已有應變計畫」,避免每張卡各自重查資料庫。 + """ + if events is None: + events = get_active_risk_events(limit=HEATMAP_EVENT_LOOKBACK) + if events is None: + rows = [] + elif hasattr(events, "iterrows"): + rows = [r.to_dict() for _, r in events.iterrows()] + else: + rows = list(events) + matched = [ev for ev in rows if _event_matches_location(ev, country or "", region or "")] + + def _days(ev): + try: + return int(ev.get("impact_days") or 0) + except (TypeError, ValueError): + return 0 + + matched.sort(key=lambda ev: (_days(ev), str(ev.get("created_at") or "")), reverse=True) + return matched + + +def is_news_event(ev: dict) -> bool: + """news_id 非空 → 由新聞一鍵登錄;否則為人工/AI 建議建立的正式應變事件。""" + news_id = ev.get("news_id") + if news_id is None: + return False + try: + return not pd.isna(news_id) + except (TypeError, ValueError): + return True diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index 1731a53..a7548b0 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -29,25 +29,6 @@ def can_write_erp_policy(actor: str) -> bool: return has_capability(actor, ERP_POLICY_WRITE) -def _auto_refresh_heatmap_ai(api_key, gemini_model): - from backend.supply_chain_risk import get_heatmap_ai_summary - from datetime import datetime - import streamlit as st - news_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30) - news_context = "" - if news_list: - news_context = "\n".join([ - (n.get("title") or "") + " " + (n.get("summary") or "")[:200] - for n in news_list - ]) - ref_date = datetime.now().strftime("%Y-%m-%d") - s, u, evs = get_heatmap_ai_summary(api_key, news_context, reference_date=ref_date, model=gemini_model) - st.session_state["heatmap_ai_summary"] = s - st.session_state["heatmap_updates"] = u - st.session_state["suggested_events"] = evs - if "heatmap_needs_refresh" in st.session_state: - del st.session_state["heatmap_needs_refresh"] - def render_intelligence_gathering( api_key: str = "", gnews_api_key: str = "", diff --git a/frontend/components/risk_overview.py b/frontend/components/risk_overview.py index cf4c737..922feb6 100644 --- a/frontend/components/risk_overview.py +++ b/frontend/components/risk_overview.py @@ -10,6 +10,7 @@ ) from backend.l1_monitoring import ( get_latest_event_alerts, + get_latest_risk_summary, map_purchase_rows_to_events, ) from backend.supply_chain_risk import ( @@ -147,6 +148,47 @@ def _render_latest_event_alerts(*, actor: str) -> None: st.caption("待確認情報需由具 L2 權限的人員在「情報與決策」頁登錄後,才會成為正式事件並進入對映。") +def _render_latest_ai_summary(*, actor: str) -> None: + """L2/排程最近一次產生的 AI 風險摘要(唯讀)。""" + st.markdown("#### 🤖 最新 AI 風險摘要") + try: + latest = get_latest_risk_summary(actor=actor) + except PermissionError: + st.error("此帳號沒有讀取 AI 風險摘要的權限。") + return + except sqlite3.Error as exc: + show_error("AI 風險摘要讀取失敗", exc) + return + if not latest: + st.info("尚未產生 AI 風險摘要;由 L2 在「情報與決策」按「產生/更新即時風險摘要」或排程更新新聞後產生。") + return + st.caption( + f"產生時間 {latest['generated_at']} ・ 由 {latest.get('actor') or '排程'} 產生 ・ " + f"依據 {latest['news_count']} 則新聞、{latest['event_count']} 筆已登錄事件" + ) + with st.container(border=True): + st.markdown(latest["summary"]) + if latest["events"]: + st.markdown("**AI 建議事件(待 L2 確認)**") + st.dataframe( + pd.DataFrame([ + { + "類型": e.get("event_type") or "其他", + "國家/地區": _location_label(e), + "預估延遲": f"{e.get('impact_days') or 0} 天", + "說明": e.get("description") or "", + } + for e in latest["events"] + ]), + width="stretch", + hide_index=True, + ) + if latest["audit"]: + with st.expander(f"證據檢核:{len(latest['audit'])} 項 AI 建議被略過或調整"): + for item in latest["audit"]: + st.caption(f"{item.get('kind')}「{item.get('name')}」{item.get('action')}:{item.get('reason')}") + + def _render_read_only_mapping(events: list[dict]) -> None: st.markdown("#### 🔔 L1 告警與通知中心") st.caption( @@ -263,6 +305,9 @@ def render_risk_overview(*, actor: str): st.markdown("
", unsafe_allow_html=True) _render_latest_event_alerts(actor=actor) + st.markdown("
", unsafe_allow_html=True) + _render_latest_ai_summary(actor=actor) + # CSV 對映只比對「已確認」事件;候選情報尚未登錄,不參與對映。 try: event_frame = get_risk_events_list(limit=30) diff --git a/frontend/components/supply_map.py b/frontend/components/supply_map.py index 0511191..a6e9318 100644 --- a/frontend/components/supply_map.py +++ b/frontend/components/supply_map.py @@ -4,7 +4,8 @@ from backend.supply_chain_news import get_news_from_db from backend.supply_chain_risk import ( get_risk_heatmap_data, - get_heatmap_ai_summary, + analyze_heatmap_risk, + get_latest_ai_risk_summary, apply_heatmap_updates, upsert_risk_heatmap, reset_risk_heatmap_to_initial, @@ -31,6 +32,22 @@ def _wrap_text(text, width=40): return "
".join(lines) +def _store_summary_result(result: dict) -> None: + """analyze_heatmap_risk / get_latest_ai_risk_summary 的結果 → session_state(產生與載入共用)。""" + st.session_state["heatmap_ai_summary"] = result.get("summary") or "" + st.session_state["heatmap_updates"] = list(result.get("updates") or []) + st.session_state["suggested_events"] = [dict(e) for e in (result.get("events") or [])] + st.session_state["heatmap_ai_meta"] = { + "generated_at": result.get("generated_at"), + "actor": result.get("actor"), + "news_count": result.get("news_count", 0), + "event_count": result.get("event_count", 0), + "audit": list(result.get("audit") or []), + "summary_id": result.get("summary_id"), + "error": bool(result.get("error")), + } + + def render_risk_heatmap(key: str = "risk_heatmap", heatmap_rows=None): """僅渲染風險熱圖 (Plotly Chart)。""" if heatmap_rows is None: @@ -70,196 +87,197 @@ def render_risk_heatmap(key: str = "risk_heatmap", heatmap_rows=None): ) st.plotly_chart(fig, use_container_width=True, key=key) +_BADGE_STYLE = "font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; display: inline-block; margin-bottom: 5px;" +_CARD_BADGES = { + "view": ("#D1FAE5", "#065F46", "✅ 應變執行中"), + "update": ("#FEF3C7", "#92400E", "⚠️ 數據異動(建議更新)"), + "ready": ("#DBEAFE", "#1E40AF", "⚡ AI 建議啟動應變"), + "news": ("#EDE9FE", "#5B21B6", "📰 已登錄情報"), + "add": ("#F3F4F6", "#374151", "🔍 待評估"), +} + + +def _split_display_name(display_name: str) -> tuple[str, str]: + parts = (display_name or "").split(" ", 1) + return parts[0].strip(), (parts[1].strip() if len(parts) > 1 else "") + + +def _find_suggestion(display_name: str, suggested_events) -> dict | None: + """AI 建議事件裡是否有對應此據點的(沿用寬鬆的包含比對)。""" + target = (display_name or "").strip().lower() + for sev in suggested_events or []: + s_r = (sev.get("region") or "").strip().lower() + s_c = (sev.get("country") or "").strip().lower() + if (s_r and s_r in target) or (s_c and s_c in target) or (target and (target in s_r or target in s_c)): + return sev + return None + + +def _exposure_line(exposure: dict) -> str: + """曝險金額只在真的有未結採購單時才顯示金額;否則說清楚為什麼沒有數字。""" + if exposure.get("open_po_count"): + return (f"曝險金額: ${exposure['open_po_amount']:,.0f}" + f"({exposure['open_po_count']} 張未結採購單)") + return (f"無未結採購單 ・ 供應商 {exposure.get('supplier_count', 0)} 家" + f"(正式 {exposure.get('official_supplier_count', 0)} 家)") + + +def _card_state(found_ev, match_suggest, news_events) -> str: + if found_ev is not None: + suggested_days = int(match_suggest.get("impact_days", 0)) if match_suggest else None + actual_days = int(found_ev.get("impact_days") or 0) + # 只有當 AI 建議的天數與現有計畫「不一致」時,才顯示「更新應變建議」 + return "update" if (suggested_days is not None and suggested_days != actual_days) else "view" + if match_suggest: + return "ready" + if news_events: + return "news" + return "add" + + def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): """區域風險快速分析小卡。""" + from backend.supply_chain_risk import ( + add_risk_event, + events_for_location, + get_active_risk_events, + get_region_exposure, + is_news_event, + update_risk_event, + ) + if heatmap_rows is None: heatmap_rows = get_risk_heatmap_data() - if heatmap_rows: - # 如果有新情報登錄且尚未重新摘要,給予提示 - if st.session_state.get("heatmap_needs_refresh"): - st.warning("⚠️ 偵測到新的風險登錄,請點擊下方「產生分析」以更新地圖與摘要。") - - high_risk_regions = [r for r in heatmap_rows if (r.get("risk_pct") or 0) > 20] - # 依風險百分比由高至低排序 - high_risk_regions.sort(key=lambda x: x.get("risk_pct") or 0, reverse=True) - - if high_risk_regions: - st.markdown("#### ⚡ 區域風險快速分析") - st.caption("點擊下方區域即可快速登錄事件或查看現有應變計畫。點擊下方的「分析衝擊」會自動帶您進入詳細應變區。") - - # 建立 3 列的小卡片 - cols = st.columns(3) - from backend.supply_chain_risk import get_active_risk_events - active_events = get_active_risk_events() - - for i, reg in enumerate(high_risk_regions[:6]): # 最多顯示 6 個 - with cols[i % 3]: - color = "#EF4444" if reg['risk_pct'] > 60 else "#F59E0B" - - # 1. 取得總結名稱並計算曝險金額 - reg_display = reg.get('display_name') or "" - from backend.supply_chain_risk import get_total_impact_amount - impact_amt = get_total_impact_amount(reg_display) - impact_display = f"${impact_amt:,.0f}" - - # 2. 尋找現有正式事件 (非新聞初篩登錄) - from backend.supply_chain_risk import get_active_risk_events - active_events = get_active_risk_events() - found_ev = None - if active_events is not None and not active_events.empty: - # 分別對應國家與地區 - dn_parts = (reg.get('display_name') or "").split(" ", 1) - c_name = dn_parts[0].strip().lower() - r_name = dn_parts[1].strip().lower() if len(dn_parts) > 1 else "" - - for _, ev in active_events.iterrows(): - # 只比對正式事件 (news_id 為空) - if pd.isna(ev.get('news_id')): - ev_c = (ev.get('country') or "").strip().lower() - ev_r = (ev.get('region') or "").strip().lower() - if ev_c == c_name and ev_r == r_name: - found_ev = ev - break - - # 3. 比對 AI 最新建議 (檢查是否天數有更新) - s_events = st.session_state.get("suggested_events", []) - match_suggest = None - for sev in s_events: - s_r, s_c = (sev.get('region') or "").strip().lower(), (sev.get('country') or "").strip().lower() - if (s_r and s_r in reg_display.lower()) or (s_c and s_c in reg_display.lower()): - match_suggest = sev - break - - # 判定按鈕狀態 - btn_state = "add" # 待登錄 - if found_ev is not None: - # 只有當 AI 建議的天數與現有計畫「不一致」時,才顯示「更新應變建議」 - # 這樣一鍵更新後,兩者數據一致,狀態就會自動變回綠色的「查看分析」 - suggested_days = int(match_suggest.get('impact_days', 0)) if match_suggest else None - actual_days = int(found_ev.get('impact_days', 0)) - - if suggested_days is not None and suggested_days != actual_days: - btn_state = "update" - else: - btn_state = "view" # 執行中 - elif match_suggest: - btn_state = "ready" # 建議啟動 - - status_badge = "" - if btn_state == "view": - status_badge = '
✅ 應變執行中
' - elif btn_state == "update": - status_badge = '
⚠️ 數據異動(建議更新)
' - elif btn_state == "ready": - status_badge = '
⚡ AI 建議啟動應變
' - else: - status_badge = '
🔍 待評估
' - - st.markdown(f""" -
- {status_badge} -
{reg['display_name']}
-
{reg['risk_pct']:.0f}% 風險
-
曝險金額: {impact_display}
-
- """, unsafe_allow_html=True) - - if btn_state == "view": - if st.button(f"📊 查看分析", key=f"{key}_quick_anal_{reg.get('region_key') or i}_{i}", use_container_width=True, type="secondary"): - st.session_state["active_risk_event_id"] = found_ev["id"] - st.rerun() - elif btn_state == "update": - if st.button(f"🔄 更新應變建議", key=f"{key}_upd_{reg.get('region_key') or i}", use_container_width=True, type="primary"): - # 執行覆寫更新 - impact_days = match_suggest.get('impact_days', 7) - etype = match_suggest.get('event_type', '其他') - desc = f"【AI 建議更新】{match_suggest.get('description', '')}" - dn_parts = reg_display.split(" ", 1) - ev_c = dn_parts[0].strip() - ev_r = dn_parts[1].strip() if len(dn_parts) > 1 else "" - - from backend.supply_chain_risk import add_risk_event - add_risk_event( - etype, ev_r, ev_c, impact_days, desc, actor=actor - ) - st.toast(f"✅ 已將 {reg_display} 的數據更新", icon="🔄") - st.rerun() - elif btn_state == "ready": - if st.button("⚡ 啟動 AI 建議應變", key=f"{key}_heat_ana_ready_{i}_{reg_display}", use_container_width=True, type="primary"): - st.session_state["selected_region_for_response"] = reg_display - impact_days = match_suggest.get('impact_days', 7) - etype = match_suggest.get('event_type', '其他') - desc = f"AI 熱圖分析:{match_suggest.get('description', '')}" - dn_parts = reg_display.split(" ", 1) - ev_c = dn_parts[0].strip() - ev_r = dn_parts[1].strip() if len(dn_parts) > 1 else "" - from backend.supply_chain_risk import add_risk_event - add_risk_event( - etype, ev_r, ev_c, impact_days, desc, actor=actor - ) - st.session_state["heatmap_needs_refresh"] = True - st.toast(f"📍 已啟動 {reg_display} 應變計畫", icon="🤖") - st.rerun() + if not heatmap_rows: + return + # 如果有新情報登錄且尚未重新摘要,給予提示 + if st.session_state.get("heatmap_needs_refresh"): + st.warning("⚠️ 偵測到新的風險登錄,請點擊上方「產生/更新即時風險摘要」以更新地圖與摘要。") + + high_risk_regions = [r for r in heatmap_rows if (r.get("risk_pct") or 0) > 20] + # 依風險百分比由高至低排序 + high_risk_regions.sort(key=lambda x: x.get("risk_pct") or 0, reverse=True) + if not high_risk_regions: + return + + st.markdown("#### ⚡ 區域風險快速分析") + st.caption("卡片依風險由高至低。已有情報的據點可一鍵建立應變計畫;已有計畫的據點可查看分析或套用 AI 更新。") + + # 事件只查一次,每張卡片再從記憶體篩選(原本每張卡各查一次資料庫) + all_events = get_active_risk_events(limit=200) + s_events = st.session_state.get("suggested_events", []) + + cols = st.columns(3) + for i, reg in enumerate(high_risk_regions[:6]): # 最多顯示 6 個 + with cols[i % 3]: + color = "#EF4444" if reg["risk_pct"] > 60 else "#F59E0B" + reg_display = reg.get("display_name") or "" + region_key = reg.get("region_key") or reg_display + ev_country, ev_region = _split_display_name(reg_display) + + exposure = get_region_exposure(region_key) + matched = events_for_location(ev_country, ev_region, all_events) + news_events = [ev for ev in matched if is_news_event(ev)] + formal_events = [ev for ev in matched if not is_news_event(ev)] + found_ev = formal_events[0] if formal_events else None # 已依天數排序,取最嚴重者 + match_suggest = _find_suggestion(reg_display, s_events) + btn_state = _card_state(found_ev, match_suggest, news_events) + + bg, fg, label = _CARD_BADGES[btn_state] + if btn_state == "news": + label = f"📰 已登錄 {len(news_events)} 則情報" + status_badge = f'
{label}
' + reason = reg.get("risk_reason") or "" + + st.markdown(f""" +
+ {status_badge} +
{reg_display}
+
{reg['risk_pct']:.0f}% 風險
+
依據:{reason}
+
{_exposure_line(exposure)}
+
+ """, unsafe_allow_html=True) + + if btn_state == "view": + if st.button("📊 查看分析", key=f"{key}_quick_anal_{region_key}_{i}", use_container_width=True, type="secondary"): + st.session_state["active_risk_event_id"] = found_ev["id"] + st.rerun() + elif btn_state == "update": + if st.button("🔄 更新應變建議", key=f"{key}_upd_{region_key}", use_container_width=True, type="primary"): + # 就地更新同一筆事件,不再靠「同區域覆寫」的副作用 + update_risk_event( + found_ev["id"], + event_type=match_suggest.get("event_type", found_ev.get("event_type")), + impact_days=match_suggest.get("impact_days", 7), + description=f"【AI 建議更新】{match_suggest.get('description', '')}", + actor=actor, + ) + st.toast(f"✅ 已將 {reg_display} 的數據更新", icon="🔄") + st.rerun() + elif btn_state == "ready": + if st.button("⚡ 啟動 AI 建議應變", key=f"{key}_heat_ana_ready_{i}_{reg_display}", use_container_width=True, type="primary"): + st.session_state["selected_region_for_response"] = reg_display + add_risk_event( + match_suggest.get("event_type", "其他"), ev_region, ev_country, + match_suggest.get("impact_days", 7), + f"AI 熱圖分析:{match_suggest.get('description', '')}", + actor=actor, + ) + st.session_state["heatmap_needs_refresh"] = True + st.toast(f"📍 已啟動 {reg_display} 應變計畫", icon="🤖") + st.rerun() + elif btn_state == "news": + # 用最嚴重那則情報的類型/天數建立正式應變事件,不再硬塞「其他/7 天」 + top = news_events[0] + top_days = int(top.get("impact_days") or 0) or 7 + top_type = top.get("event_type") or "其他" + if st.button(f"🏗️ 建立應變計畫({top_type}・{top_days} 天)", key=f"{key}_from_news_{i}_{region_key}", use_container_width=True, type="primary"): + st.session_state["selected_region_for_response"] = reg_display + add_risk_event( + top_type, ev_region, ev_country, top_days, + f"依 {len(news_events)} 則已登錄情報建立:{(top.get('description') or '')[:80]}", + actor=actor, + ) + st.session_state["heatmap_needs_refresh"] = True + st.toast(f"📍 已依情報建立 {reg_display} 應變計畫", icon="📰") + st.rerun() + else: + if st.button("🏗️ 加入應變計畫", key=f"{key}_heat_ana_manual_{i}_{reg_display}", use_container_width=True): + st.session_state["selected_region_for_response"] = reg_display + add_risk_event( + "其他", ev_region, ev_country, 7, f"手動加入:偵測到 {reg_display} 高風險。", actor=actor + ) + st.session_state["heatmap_needs_refresh"] = True + st.rerun() + + if len(high_risk_regions) > 6: + st.markdown("
", unsafe_allow_html=True) + with st.expander(f"➕ 查看並登錄其他 {len(high_risk_regions) - 6} 個高風險區域", expanded=False): + other_regs = high_risk_regions[6:] + c1, c2, c3 = st.columns([2, 1, 1]) + with c1: + opt_names = [f"{r['display_name']} ({r['risk_pct']}%)" for r in other_regs] + sel_idx = st.selectbox("選擇其他高風險區域", range(len(opt_names)), format_func=lambda i: opt_names[i], key=f"{key}_other_reg_sel", label_visibility="collapsed") + selected_r = other_regs[sel_idx] + with c2: + exposure = get_region_exposure(selected_r.get("region_key") or selected_r.get("display_name")) + st.markdown(f"
{_exposure_line(exposure)}
", unsafe_allow_html=True) + with c3: + if st.button("🏗️ 加入應變計畫", key=f"{key}_other_reg_btn", use_container_width=True, type="secondary"): + sel_country, sel_region = _split_display_name(selected_r["display_name"]) + match = _find_suggestion(selected_r["display_name"], s_events) + if match: + impact_days = match.get("impact_days", 7) + etype = match.get("event_type", "其他") + desc = f"AI 熱圖分析建議:{match.get('description', '建議登錄應變計畫')}" else: - if st.button("🏗️ 加入應變計畫", key=f"{key}_heat_ana_manual_{i}_{reg_display}", use_container_width=True): - st.session_state["selected_region_for_response"] = reg_display - impact_days, etype, desc = 7, "其他", f"手動加入:偵測到 {reg_display} 高風險。" - dn_parts = reg_display.split(" ", 1) - ev_c = dn_parts[0].strip() - ev_r = dn_parts[1].strip() if len(dn_parts) > 1 else "" - from backend.supply_chain_risk import add_risk_event - add_risk_event( - etype, ev_r, ev_c, impact_days, desc, actor=actor - ) - st.session_state["heatmap_needs_refresh"] = True - st.rerun() - - if len(high_risk_regions) > 6: - st.markdown("
", unsafe_allow_html=True) - with st.expander(f"➕ 查看並登錄其他 {len(high_risk_regions) - 6} 個高風險區域", expanded=False): - other_regs = high_risk_regions[6:] - c1, c2, c3 = st.columns([2, 1, 1]) - with c1: - opt_names = [f"{r['display_name']} ({r['risk_pct']}%)" for r in other_regs] - sel_idx = st.selectbox("選擇其他高風險區域", range(len(opt_names)), format_func=lambda i: opt_names[i], key=f"{key}_other_reg_sel", label_visibility="collapsed") - selected_r = other_regs[sel_idx] - with c2: - # 顯示曝險金額 - from backend.supply_chain_risk import get_total_impact_amount - impact_amt = get_total_impact_amount(selected_r.get('display_name')) - st.markdown(f"
曝險金額: ${impact_amt:,.0f}
", unsafe_allow_html=True) - with c3: - if st.button("🏗️ 加入應變計畫", key=f"{key}_other_reg_btn", use_container_width=True, type="secondary"): - import re - clean_loc = re.sub(r'[\(\d\.%\)]', '', selected_r['display_name']).strip() - s_events = st.session_state.get("suggested_events", []) - # 更加寬容的匹配 - def find_match(r_name, evs): - for e in evs: - sr, sc = (e.get('region') or "").strip(), (e.get('country') or "").strip() - if (sr and sr in r_name) or (sc and sc in r_name) or (r_name in sr) or (r_name in sc): - return e - return None - - match = find_match(selected_r['display_name'], s_events) - if match: - impact_days = match.get('impact_days', 7) - etype = match.get('event_type', '其他') - desc = f"AI 熱圖分析建議:{match.get('description', '建議登錄應變計畫')}" - else: - impact_days, etype, desc = 7, "其他", f"快速登錄:AI 偵測到 {selected_r['display_name']} 之 {selected_r['risk_pct']}% 地理風險。" - from backend.supply_chain_risk import add_risk_event - new_id = add_risk_event( - etype, - clean_loc, - clean_loc, - impact_days, - desc, - actor=actor, - ) - st.session_state["heatmap_needs_refresh"] = True - if match: st.toast(f"📍 已採用 AI 建議之 {impact_days} 天延遲 (類型: {etype})", icon="🤖") - st.rerun() + impact_days, etype, desc = 7, "其他", f"快速登錄:AI 偵測到 {selected_r['display_name']} 之 {selected_r['risk_pct']}% 地理風險。" + add_risk_event(etype, sel_region, sel_country, impact_days, desc, actor=actor) + st.session_state["heatmap_needs_refresh"] = True + if match: + st.toast(f"📍 已採用 AI 建議之 {impact_days} 天延遲 (類型: {etype})", icon="🤖") + st.rerun() def render_supply_chain_map( api_key: str, @@ -279,55 +297,69 @@ def render_supply_chain_map( # AI 摘要(使用最近最新新聞) st.markdown("**AI 摘要**") - news_context = "" + news_items = [] try: - news_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30) - if news_list: - news_context = "\\n".join([ - (n.get("title") or "") + " " + (n.get("summary") or "")[:200] + - f" [{n.get('published_at') or n.get('fetched_at') or ''}, 預估延遲: {n.get('estimated_delay') or 0}天]" - for n in news_list - ]) + news_items = get_news_from_db(limit=10, order_by_latest=True, within_days=30) or [] except Exception: pass from datetime import datetime ref_date = datetime.now().strftime("%Y-%m-%d") + + # 頁面剛開或重新整理:session 沒有摘要就載入最近一次落地的結果(L2 換頁不會再遺失) + if "heatmap_ai_summary" not in st.session_state and not st.session_state.get("heatmap_summary_dismissed"): + latest = get_latest_ai_risk_summary() + if latest: + _store_summary_result(latest) + col_ai_btn, col_reset = st.columns(2) with col_ai_btn: if st.button("🔄 產生/更新即時風險摘要", key="heatmap_ai_btn"): - with st.spinner("AI 正在分析情報並偵測風險等級..."): - summary_text, updates, suggested_events = get_heatmap_ai_summary(api_key, news_context, reference_date=ref_date, model=gemini_model) - st.session_state["heatmap_ai_summary"] = summary_text - st.session_state["heatmap_updates"] = updates - st.session_state["suggested_events"] = suggested_events + with st.spinner("AI 正在分析情報並偵測風險等級(思考型模型約需 1~2 分鐘)..."): + result = analyze_heatmap_risk(news_items, reference_date=ref_date, actor=actor) + _store_summary_result(result) + st.session_state.pop("heatmap_summary_dismissed", None) if "heatmap_needs_refresh" in st.session_state: del st.session_state["heatmap_needs_refresh"] st.rerun() with col_reset: if st.button("🔄 重置為初始熱圖", key="reset_heatmap_btn"): reset_risk_heatmap_to_initial(actor=actor) - for key in ["heatmap_ai_summary", "heatmap_updates", "suggested_events"]: + for key in ["heatmap_ai_summary", "heatmap_updates", "suggested_events", "heatmap_ai_meta"]: if key in st.session_state: del st.session_state[key] + st.session_state["heatmap_summary_dismissed"] = True st.success("已重置為初始熱圖。") st.rerun() if "heatmap_ai_summary" in st.session_state: # issue #47 P1-1:摘要已由後端以結構化 JSON 產出(純敘事 markdown), # 原本剝離 UPDATE:/EVENT: 技術指令行的 regex 邏輯不再需要。 + meta = st.session_state.get("heatmap_ai_meta") or {} with st.container(border=True): st.markdown("### 🤖 AI 供應鏈與地理風險深度分析") + if meta.get("generated_at"): + st.caption( + f"產生時間 {meta['generated_at']} ・ 依據 {meta.get('news_count', 0)} 則新聞、" + f"{meta.get('event_count', 0)} 筆已登錄事件" + + (f" ・ 由 {meta['actor']} 產生" if meta.get("actor") else "") + ) st.markdown(st.session_state["heatmap_ai_summary"]) - + audit = meta.get("audit") or [] + if audit: + with st.expander(f"🔎 證據檢核:{len(audit)} 項 AI 建議被略過或調整", expanded=False): + st.caption("AI 提到但新聞與已登錄事件裡都沒有的地區會被略過;延遲天數或事件類型超出證據範圍的會被調整。") + for item in audit: + st.markdown(f"- {item.get('kind')}「**{item.get('name')}**」{item.get('action')}:{item.get('reason')}") + # --- 選擇性帶入:風險建議值 (Selective Apply Risk Updates) --- # 核心策略:完全使用熱圖節點清單(供應商產生),而不依賴 AI 的名稱自由發揮 # AI 的更新建議只用來「查詢風險百分比」,最後對應到正確的熱圖節點名稱 heatmap_rows_for_update = get_risk_heatmap_data() h_updates_raw = st.session_state.get("heatmap_updates", []) - + if heatmap_rows_for_update: st.markdown("##### 🎯 審核並套用 AI 風險建議") st.caption("下表依照您的供應商據點清單產生,AI 的建議風險值已對應至每個確切節點。") - + # 建立 AI 更新字典:key 為國家名(或完整節點名),value 為風險百分比 ai_risk_by_name: dict = {} for u in h_updates_raw: @@ -335,34 +367,33 @@ def render_supply_chain_map( pct = u.get("risk_pct") if name and pct is not None: ai_risk_by_name[name] = pct - + # 為每個熱圖節點找出 AI 建議的風險值與延遲天數 table_rows = [] s_events = st.session_state.get("suggested_events", []) - + for row in heatmap_rows_for_update: node_name = row.get("display_name", "") node_country = node_name.split(" ")[0] if " " in node_name else node_name - + # 1. 匹配風險百分比 risk_val = ai_risk_by_name.get(node_name) or ai_risk_by_name.get(node_country) - + # 2. 匹配建議延遲天數 (從 suggested_events 找) suggested_days = 7 - for sev in s_events: - s_reg, s_cnt = (sev.get('region') or "").strip(), (sev.get('country') or "").strip() - if (s_reg and s_reg in node_name) or (s_cnt and s_cnt in node_name) or (node_name in s_reg) or (node_name in s_cnt): - suggested_days = sev.get('impact_days', 7) - break - + sev = _find_suggestion(node_name, s_events) + if sev: + suggested_days = sev.get("impact_days", 7) + if risk_val is not None: table_rows.append({ - "套用": True, - "地區": node_name, + "套用": True, + "地區": node_name, + "目前 (%)": float(row.get("risk_pct") or 0), "預估風險 (%)": float(risk_val), "預估延遲 (天)": int(suggested_days) }) - + if table_rows: df_upd = pd.DataFrame(table_rows) edited_risk_df = st.data_editor( @@ -370,29 +401,25 @@ def render_supply_chain_map( column_config={ "套用": st.column_config.CheckboxColumn("是否套用", default=True), "地區": st.column_config.TextColumn("熱點名稱", disabled=True), - "預估風險 (%)": st.column_config.NumberColumn("影響 %", min_value=0, max_value=100, step=1), + "目前 (%)": st.column_config.NumberColumn("目前 %", disabled=True), + "預估風險 (%)": st.column_config.NumberColumn("AI 建議 %", min_value=0, max_value=100, step=1), "預估延遲 (天)": st.column_config.NumberColumn("延遲天數", min_value=0, max_value=365, step=1) }, hide_index=True, use_container_width=True, key="ai_risk_editor" ) - + sel_risks = edited_risk_df[edited_risk_df["套用"] == True] if st.button(f"📥 套用打勾的 {len(sel_risks)} 個地區風險至地圖", key="apply_ai_risk_btn", type="primary", disabled=len(sel_risks)==0): - from backend.supply_chain_risk import apply_heatmap_updates final_updates = [{"display_name": r["地區"], "risk_pct": r["預估風險 (%)"]} for _, r in sel_risks.iterrows()] - + # 🧪 關鍵同步:將使用者手動修改的天數寫回 suggested_events current_suggested = st.session_state.get("suggested_events", []) for _, edited_row in sel_risks.iterrows(): - reg_name = edited_row["地區"] - new_days = edited_row["預估延遲 (天)"] - for sev in current_suggested: - s_reg, s_cnt = (sev.get('region') or "").strip(), (sev.get('country') or "").strip() - if (s_reg and s_reg in reg_name) or (s_cnt and s_cnt in reg_name) or (reg_name in s_reg) or (reg_name in s_cnt): - sev["impact_days"] = int(new_days) - break + sev = _find_suggestion(edited_row["地區"], current_suggested) + if sev: + sev["impact_days"] = int(edited_row["預估延遲 (天)"]) st.session_state["suggested_events"] = current_suggested cnt = apply_heatmap_updates( @@ -411,9 +438,6 @@ def render_supply_chain_map( st.rerun() else: st.info("AI 本次分析未偵測到與您供應商節點直接相關的變動建議。") - - # 移除原有的「審核 AI 偵測到之新事件」區塊(依需求隱藏) - pass else: st.caption("提示:點擊「即時全球情報」區塊的「更新即時新聞」後,系統會自動同步更新此熱圖與 AI 摘要。") diff --git a/tests/test_l2_risk_workspace.py b/tests/test_l2_risk_workspace.py new file mode 100644 index 0000000..fba7bee --- /dev/null +++ b/tests/test_l2_risk_workspace.py @@ -0,0 +1,368 @@ +""" +tests/test_l2_risk_workspace.py +L2 供應鏈風險頁完善: + 1. 熱圖預設值依事件嚴重度/筆數/時效差異化(不再全部 60%) + 2. 曝險金額改回傳完整資訊(未結採購單 + 供應商數),demo 可 opt-in 種採購單 + 3. 卡片判定納入新聞登錄事件(events_for_location / is_news_event) + 4. 同區域不同類型事件不再互相覆蓋;update_risk_event 就地修改 + 5. AI 摘要落地 risk_ai_summaries,L1 唯讀可讀、L2 重開頁面可載回 + 6. 證據閘門:AI 提到但資料裡沒有的地區略過、天數/類型超出證據則調整 +""" + +from __future__ import annotations + +import ast +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest + +from backend import database +from backend import l1_monitoring +from backend import supply_chain_risk as risk + + +ROOT = Path(__file__).resolve().parents[1] +NOW = datetime(2026, 9, 13, 12, 0, 0) + + +@pytest.fixture +def l2_db(tmp_path, monkeypatch): + db_path = tmp_path / "l2-workspace.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + monkeypatch.delenv("ERP_ENABLE_DEMO_SEED", raising=False) + database.init_db() + with sqlite3.connect(db_path) as conn: + conn.execute("DELETE FROM suppliers") + conn.execute("DELETE FROM purchase_orders") + conn.execute("DELETE FROM supply_chain_events") + conn.executemany( + "INSERT INTO suppliers (supplier_id, name, country, region, latitude, longitude, is_official) " + "VALUES (?,?,?,?,?,?,?)", + [ + ("S-TW1", "台北供應商", "台灣", "亞洲", 25.0, 121.5, 1), + ("S-TW2", "新竹供應商", "台灣", "亞洲", 24.8, 121.0, 0), + ("S-DE1", "柏林供應商", "德國", "歐洲", 52.5, 13.4, 1), + ("S-US1", "加州供應商", "美國", "北美洲", 37.7, -122.4, 1), + ], + ) + conn.commit() + return db_path + + +def _events(db_path): + with sqlite3.connect(db_path) as conn: + return conn.execute( + "SELECT event_type, region, country, impact_days, news_id FROM supply_chain_events ORDER BY id" + ).fetchall() + + +def _heatmap_by_name(): + return {row["display_name"]: row for row in risk.get_risk_heatmap_data()} + + +# ── 1. 熱圖預設值差異化 ────────────────────────────────────────────── + + +def test_score_region_events_weights_by_severity_count_and_age(): + def ev(days, created="2026-09-12", country="台灣", region="亞洲"): + return {"country": country, "region": region, "impact_days": days, "created_at": created} + + assert risk.score_region_events("台灣", "亞洲", [], now=NOW)["points"] == 0 + assert risk.score_region_events("台灣", "亞洲", [ev(7)], now=NOW)["points"] == 30 + assert risk.score_region_events("台灣", "亞洲", [ev(30)], now=NOW)["points"] == 50 + # 兩筆事件:最嚴重者 + 每多一筆 +5 + assert risk.score_region_events("台灣", "亞洲", [ev(7), ev(14)], now=NOW)["points"] == 45 + # 逾 30 天的事件加權減半 + stale = risk.score_region_events("台灣", "亞洲", [ev(30, created="2026-07-01")], now=NOW) + assert stale["points"] == 25 and "已逾" in stale["reason"] + # 其他地區的事件不算 + assert risk.score_region_events("德國", "歐洲", [ev(30)], now=NOW)["count"] == 0 + + +def test_heatmap_defaults_no_longer_flat_60(l2_db): + risk.add_risk_event("罷工", "亞洲", "台灣", 7, "港口罷工", actor="planner") + risk.add_risk_event("氣候", "亞洲", "台灣", 14, "颱風", actor="planner") + risk.add_risk_event("戰爭", "歐洲", "德國", 30, "衝突", actor="planner") + + rows = _heatmap_by_name() + assert rows["台灣 亞洲"]["risk_pct"] == 65 # 20 + 40(14 天)+ 5(第二筆) + assert rows["德國 歐洲"]["risk_pct"] == 70 # 20 + 50(30 天) + assert rows["美國 北美洲"]["risk_pct"] == 20 # 沒事件 + assert rows["台灣 亞洲"]["event_count"] == 2 + assert "2 則事件" in rows["台灣 亞洲"]["risk_reason"] + assert rows["美國 北美洲"]["risk_reason"] == "近期無登錄事件" + + +def test_heatmap_override_keeps_reason_of_override(l2_db): + risk.upsert_risk_heatmap("台灣|亞洲", "台灣 亞洲", 25.0, 121.5, 88, "AI", actor="planner") + row = _heatmap_by_name()["台灣 亞洲"] + assert row["risk_pct"] == 88 and row["risk_reason"].startswith("AI/人工設定") + + +# ── 2. 曝險資訊 ─────────────────────────────────────────────────────── + + +def test_region_exposure_explains_zero_amount(l2_db): + exposure = risk.get_region_exposure("台灣|亞洲") + assert exposure == { + "supplier_count": 2, "official_supplier_count": 1, + "open_po_count": 0, "open_po_amount": 0.0, + } + with sqlite3.connect(l2_db) as conn: + conn.executemany( + "INSERT INTO purchase_orders (po_id, supplier_id, status, total_amount) VALUES (?,?,?,?)", + [("PO-1", "S-TW1", "已下單", 1500.0), ("PO-2", "S-TW2", "已完成", 999.0), ("PO-3", "S-DE1", None, 40.0)], + ) + conn.commit() + exposure = risk.get_region_exposure("台灣|亞洲") + assert exposure["open_po_count"] == 1 and exposure["open_po_amount"] == 1500.0 # 已完成不算 + + +def test_demo_purchase_orders_seed_is_opt_in_and_idempotent(l2_db, monkeypatch): + with sqlite3.connect(l2_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] == 0 + monkeypatch.setenv("ERP_ENABLE_DEMO_SEED", "1") + database.init_db() + with sqlite3.connect(l2_db) as conn: + count = conn.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] + official = conn.execute("SELECT COUNT(*) FROM suppliers WHERE is_official=1").fetchone()[0] + assert count == official > 0 + database.init_db() # 第二次不重複種 + with sqlite3.connect(l2_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0] == count + assert risk.get_region_exposure("台灣|亞洲")["open_po_amount"] > 0 + + +# ── 3. 卡片判定納入新聞登錄事件 ─────────────────────────────────────── + + +def test_events_for_location_separates_news_and_formal(l2_db): + risk.add_risk_event("其他", "", "台灣", 7, "新聞 A", news_id=11, actor="planner") + risk.add_risk_event("氣候", "", "台灣", 21, "新聞 B", news_id=12, actor="planner") + risk.add_risk_event("罷工", "亞洲", "台灣", 10, "人工", actor="planner") + + matched = risk.events_for_location("台灣", "亞洲") + assert [e["impact_days"] for e in matched] == [21, 10, 7] # 依天數排序 + assert [risk.is_news_event(e) for e in matched] == [True, False, True] + assert risk.events_for_location("美國", "北美洲") == [] + + +# ── 4. 事件不再互相覆蓋 ─────────────────────────────────────────────── + + +def test_add_risk_event_keeps_different_types_separate(l2_db): + first = risk.add_risk_event("罷工", "亞洲", "台灣", 7, "罷工", actor="planner") + second = risk.add_risk_event("氣候", "亞洲", "台灣", 14, "颱風", actor="planner") + assert first != second + assert len(_events(l2_db)) == 2 + # 同類型再登錄 → 更新同一筆(天數/說明),不新增 + again = risk.add_risk_event("罷工", "亞洲", "台灣", 9, "罷工延長", actor="planner") + assert again == first + assert [(t, d) for t, _, _, d, _ in _events(l2_db)] == [("罷工", 9), ("氣候", 14)] + + +def test_update_risk_event_changes_only_given_fields(l2_db): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 7, "原始", actor="planner") + assert risk.update_risk_event(event_id, impact_days=21, actor="planner") is True + assert _events(l2_db)[0][:4] == ("罷工", "亞洲", "台灣", 21) + assert risk.update_risk_event(event_id, event_type="戰爭", description="升級", actor="planner") is True + assert _events(l2_db)[0][0] == "戰爭" + assert risk.update_risk_event(999999, impact_days=1, actor="planner") is False + + +@pytest.mark.parametrize("actor", [None, "", "viewer", "nobody"]) +def test_update_risk_event_fails_closed(l2_db, actor): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 7, "原始", actor="planner") + with pytest.raises(PermissionError): + risk.update_risk_event(event_id, impact_days=99, actor=actor) + assert _events(l2_db)[0][3] == 7 + + +# ── 5. 摘要落地 ─────────────────────────────────────────────────────── + + +def _fake_llm(monkeypatch, payload: str): + import backend.llm_client as lc + monkeypatch.setattr(lc, "complete_text", lambda *a, **kw: payload) + + +NEWS = [ + {"country": "台灣", "region": "亞洲", "category": "氣候", "estimated_delay": 5, + "title": "Typhoon", "summary": "port closed", "published_at": "2026-09-12"}, +] +PAYLOAD = ( + '{"摘要": "### 現況\\n台灣颱風影響港口。美國戰爭風險上升。", ' + '"更新": [{"地區": "台灣 亞洲", "風險": 70}, {"地區": "美國 北美洲", "風險": 75}], ' + '"事件": [{"類型": "氣候", "地區": "亞洲", "國家": "台灣", "延遲天數": 30, "描述": "颱風"}, ' + '{"類型": "戰爭", "地區": "北美洲", "國家": "美國", "延遲天數": 30, "描述": "戰爭"}]}' +) + + +def test_analyze_persists_when_actor_given_and_l1_can_read(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + assert risk.get_latest_ai_risk_summary() is None + + result = risk.analyze_heatmap_risk(NEWS, reference_date="2026-09-13", actor="planner") + assert result["error"] is False and result["summary_id"] is not None + assert result["news_count"] == 1 + + latest = risk.get_latest_ai_risk_summary() + assert latest["summary_id"] == result["summary_id"] + assert "台灣颱風" in latest["summary"] + assert latest["updates"] == result["updates"] + assert latest["events"] == result["events"] + assert latest["audit"] == result["audit"] + assert latest["actor"] == "planner" + + # L1 唯讀入口:viewer 可讀,同一筆 + assert l1_monitoring.get_latest_risk_summary(actor="viewer")["summary_id"] == result["summary_id"] + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_l1_latest_summary_fails_closed(l2_db, actor): + with pytest.raises(PermissionError): + l1_monitoring.get_latest_risk_summary(actor=actor) + + +def test_analyze_without_actor_does_not_persist_and_viewer_cannot_persist(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + result = risk.analyze_heatmap_risk(NEWS, reference_date="2026-09-13") + assert result["summary_id"] is None and risk.get_latest_ai_risk_summary() is None + with pytest.raises(PermissionError): + risk.save_ai_risk_summary(result, actor="viewer") + assert risk.get_latest_ai_risk_summary() is None + + +def test_legacy_tuple_interface_still_works(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + summary, updates, events = risk.get_heatmap_ai_summary(news_context="x", news_items=NEWS) + assert "台灣颱風" in summary and isinstance(updates, list) and isinstance(events, list) + + +def test_scheduler_refresh_persists_summary(l2_db, monkeypatch): + """排程/L2「更新即時新聞」路徑:摘要落地、建議事件不再被丟掉。""" + from backend import supply_chain_news as news + + _fake_llm(monkeypatch, PAYLOAD) + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **kw: [ + {"country": "台灣", "region": "亞洲", "title": "Typhoon", "summary": "port closed", + "url": "https://example.test/n", "source": "t", "published_at": "2026-09-12 00:00", + "relevance_tag": "supply_chain"}, + ]) + monkeypatch.setattr(risk, "batch_infer_affected_region_from_news", lambda **kw: [ + {"is_relevant": True, "estimated_delay": 5, "event_type": "氣候", + "country": "台灣", "region": "亞洲", "chinese_summary": "颱風"}, + ]) + news.refresh_news_for_countries(["台灣"], actor="planner") + latest = risk.get_latest_ai_risk_summary() + assert latest is not None and latest["actor"] == "planner" + assert latest["events"] and latest["events"][0]["country"] == "台灣" + + +# ── 6. 證據閘門 ─────────────────────────────────────────────────────── + + +def test_gate_by_evidence_drops_unsupported_and_caps_days(l2_db, monkeypatch): + _fake_llm(monkeypatch, PAYLOAD) + # 已登錄事件:台灣 7 天罷工;新聞:台灣 5 天氣候。美國完全沒有依據。 + risk.add_risk_event("罷工", "亞洲", "台灣", 7, "罷工", actor="planner") + result = risk.analyze_heatmap_risk(NEWS, reference_date="2026-09-13") + + assert [u["display_name"] for u in result["updates"]] == ["台灣 亞洲"] + assert len(result["events"]) == 1 + tw = result["events"][0] + assert tw["country"] == "台灣" and tw["event_type"] == "氣候" + assert tw["impact_days"] == 14 # 證據最長 7 天 × 2 + + audit = {(a["kind"], a["name"], a["action"]) for a in result["audit"]} + assert ("更新", "美國 北美洲", "略過") in audit + assert ("事件", "美國 北美洲", "略過") in audit + assert ("事件", "台灣 亞洲", "調整") in audit + assert "台灣" in result["evidence_locations"] and "美國" not in result["evidence_locations"] + + +def test_gate_by_evidence_unit_rules(): + evidence = risk.build_risk_evidence( + [{"country": "越南", "region": "", "category": "政策", "estimated_delay": 3}], + [{"country": "台灣", "region": "北區", "event_type": "罷工", "impact_days": 10}], + ) + updates = [{"display_name": "台灣 北區", "risk_pct": 60}, {"display_name": "巴西", "risk_pct": 50}] + events = [ + {"event_type": "戰爭", "country": "越南", "region": "", "impact_days": 6, "description": ""}, + {"event_type": "罷工", "country": "台灣", "region": "北區", "impact_days": 12, "description": ""}, + ] + kept_u, kept_e, audit = risk.gate_by_evidence(updates, events, evidence) + assert [u["display_name"] for u in kept_u] == ["台灣 北區"] + # 越南:類型「戰爭」沒依據 → 其他;6 天 ≤ max(7, 3×2) 不動 + assert kept_e[0]["event_type"] == "其他" and kept_e[0]["impact_days"] == 6 + # 台灣:12 ≤ 10×2 不動,類型有依據 + assert kept_e[1]["event_type"] == "罷工" and kept_e[1]["impact_days"] == 12 + assert any(a["name"] == "巴西" and a["action"] == "略過" for a in audit) + assert any(a["name"] == "越南" and a["action"] == "調整" for a in audit) + + +def test_gate_flags_but_keeps_type_when_evidence_is_unclassified(): + """證據只有「其他」類新聞時不改寫類型(分類品質未知),改成提醒人工確認。""" + evidence = risk.build_risk_evidence( + [{"country": "美國", "region": "北美洲", "category": "其他", "estimated_delay": 7}], [], + ) + events = [{"event_type": "戰爭", "country": "美國", "region": "美國 北美洲", "impact_days": 30, "description": ""}] + _, kept, audit = risk.gate_by_evidence([], events, evidence) + assert kept[0]["event_type"] == "戰爭" and kept[0]["impact_days"] == 14 # 7×2 上限 + names = {(a["name"], a["action"]) for a in audit} + assert ("美國 北美洲", "提醒") in names and ("美國 北美洲", "調整") in names # 名稱不重複國家 + + +def test_gate_by_evidence_passthrough_without_evidence(): + updates = [{"display_name": "火星", "risk_pct": 99}] + events = [{"event_type": "戰爭", "country": "火星", "region": "", "impact_days": 90}] + assert risk.gate_by_evidence(updates, events, risk.build_risk_evidence([], [])) == (updates, events, []) + + +def test_prompt_tells_model_to_stay_within_evidence(): + from backend import prompts + assert "不要自行推測" in prompts.HEATMAP_AI_SUMMARY_PROMPT_V2 + + +# ── 前端契約(AST) ─────────────────────────────────────────────────── + + +def _calls(tree, name): + return [n for n in ast.walk(tree) if isinstance(n, ast.Call) + and ((isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name))] + + +def test_supply_map_uses_structured_summary_and_forwards_actor(): + tree = ast.parse((ROOT / "frontend/components/supply_map.py").read_text(encoding="utf-8")) + analyze = _calls(tree, "analyze_heatmap_risk") + assert analyze, "L2 應改用 analyze_heatmap_risk 取得 audit 與落地" + assert all(any(k.arg == "actor" for k in c.keywords) for c in analyze) + update = _calls(tree, "update_risk_event") + assert update and all(any(k.arg == "actor" for k in c.keywords) for c in update) + assert _calls(tree, "get_latest_ai_risk_summary"), "重開頁面要載回最近一次摘要" + assert not _calls(tree, "get_total_impact_amount"), "卡片改用 get_region_exposure" + for c in _calls(tree, "add_risk_event"): + assert any(k.arg == "actor" for k in c.keywords) + + +def test_risk_overview_shows_persisted_summary_read_only(): + src = (ROOT / "frontend/components/risk_overview.py").read_text(encoding="utf-8") + tree = ast.parse(src) + calls = _calls(tree, "get_latest_risk_summary") + assert calls and all(any(k.arg == "actor" for k in c.keywords) for c in calls) + assert "analyze_heatmap_risk" not in src # L1 不觸發模型 + + +def test_country_event_does_not_light_up_whole_region(l2_db): + risk.add_risk_event("罷工", "亞洲", "台灣", 7, "台灣罷工", actor="planner") + assert risk.events_for_location("台灣", "亞洲") + assert risk.events_for_location("越南", "亞洲") == [] # 同大區域、不同國家 + # 純大區域事件(沒有國家)才會擴散到該區所有據點 + risk.add_risk_event("戰爭", "亞洲", "", 30, "區域衝突", actor="planner") + assert len(risk.events_for_location("越南", "亞洲")) == 1 + assert len(risk.events_for_location("台灣", "亞洲")) == 2 From d4fc8f51698988c097824f491f0ac649e9887697 Mon Sep 17 00:00:00 2001 From: ewiwi Date: Mon, 14 Sep 2026 00:34:20 +0800 Subject: [PATCH 4/7] fix(l2): stamp AI summary generated_at after the model replies The timestamp was taken before the call, so a summary that took two minutes to generate was recorded as older than the usage log row. Co-Authored-By: Claude Opus 5 --- backend/supply_chain_risk.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index 214b84a..b354e4f 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -816,7 +816,9 @@ def analyze_heatmap_risk( updates = _gate_heatmap_updates(payload.get("更新"), valid_list, name_expansions) suggested_events = _coerce_heatmap_events(payload.get("事件")) updates, suggested_events, audit = gate_by_evidence(updates, suggested_events, evidence) - result.update({"summary": summary, "updates": updates, "events": suggested_events, "audit": audit}) + result.update({"summary": summary, "updates": updates, "events": suggested_events, "audit": audit, + # 模型可能想 1~2 分鐘,「產生時間」以回覆完成為準 + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}) except Exception as e: import traceback traceback.print_exc() From b9c755f404773be23afba6e276f2fa8d4e37bbc6 Mon Sep 17 00:00:00 2001 From: ewiwi Date: Mon, 14 Sep 2026 01:27:35 +0800 Subject: [PATCH 5/7] feat(l2): reconnect impacted-PO marking so Step 5 proposals have real input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 (替代採購提案) only lists purchase orders that carry an estimated_delay_days or alternative_suggestion, but no screen wrote those fields any more: the "受災採購清單 → AI 替代建議 → 寫回採購單" layer was gone before the public snapshot, leaving get_impacted_pos / get_ai_alternative_suggestions / update_po_impact as unused imports. In the normal flow Step 5 was therefore always empty; only the day-1 seed script could populate it. Step 3 now opens with "🧾 0. 受影響採購單標記" for the selected event: - lists open POs whose supplier sits in the event's region (get_impacted_pos, now also returning supplier_id, amount, status and raw delay/suggestion) - "🤖 AI 評估延遲與替代來源" fills per-PO delay and a concrete re-sourcing suggestion; the table is editable and works without AI too (default delay = event impact days) - "📌 標記 N 張" writes the reviewed values via update_po_impact, after which the PO appears in Step 5 with the reason pre-filled update_po_impact moves from ERP_POLICY_WRITE to RISK_WORKSPACE_WRITE: the two columns are assessment evidence for L3, not a change to the PO itself (supplier, amount, status untouched), so L2 annotates and L3 still decides in Step 5 — the same propose/approve split as the CSV exchange. Co-Authored-By: Claude Opus 5 --- backend/supply_chain_risk.py | 17 +++- frontend/components/risk_dashboard.py | 109 +++++++++++++++++++++++ tests/test_l2_risk_workspace.py | 67 ++++++++++++++ tests/test_supply_chain_authorization.py | 3 +- 4 files changed, 192 insertions(+), 4 deletions(-) diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index b354e4f..2ef115f 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -1100,7 +1100,7 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): params.extend(params_sub) q = """ SELECT p.po_id, p.supplier_id, s.name as supplier_name, s.country, s.region, - p.estimated_delay_days, p.alternative_suggestion + p.estimated_delay_days, p.alternative_suggestion, p.total_amount, p.status FROM purchase_orders p JOIN suppliers s ON p.supplier_id = s.supplier_id WHERE """ + " AND ".join(where) @@ -1139,10 +1139,17 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): alt = str(alt_raw).strip() if (_pd.notna(alt_raw) and alt_raw) else "—" out.append({ "po_id": row["po_id"], + "supplier_id": row["supplier_id"], "supplier_name": row["supplier_name"], + "country": _clean_text(row.get("country")), + "region": _clean_text(row.get("region")), "key_materials": key_materials, "estimated_delay": delay_str, + "estimated_delay_days": int(delay) if (delay is not None and delay == delay) else None, "alternative_suggestion": alt, + "alternative_suggestion_raw": alt if alt != "—" else "", + "total_amount": float(row.get("total_amount") or 0) if row.get("total_amount") == row.get("total_amount") else 0.0, + "status": _clean_text(row.get("status")), }) conn.close() return out @@ -1151,8 +1158,12 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): def update_po_impact( po_id, estimated_delay_days=None, alternative_suggestion=None, *, actor=None ): - """更新採購單的預計延遲天數與替代建議。""" - require_capability(actor, ERP_POLICY_WRITE) + """更新採購單的預計延遲天數與替代建議(風險評估註記)。 + + 這兩欄是給步驟 5/L3 決策看的證據,不動採購單本體(供應商、金額、狀態), + 所以歸 RISK_WORKSPACE_WRITE:L2 標記證據、L3 才在提案核准時真正改採購。 + """ + require_capability(actor, RISK_WORKSPACE_WRITE) conn = sqlite3.connect(DB_FILE) if estimated_delay_days is not None: conn.execute("UPDATE purchase_orders SET estimated_delay_days = ? WHERE po_id = ?", (estimated_delay_days, po_id)) diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index a7548b0..ebd6561 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,4 +1,5 @@ import streamlit as st +from frontend.ui_utils import show_error import re import pandas as pd from backend.access_control import ERP_POLICY_WRITE, has_capability @@ -249,6 +250,109 @@ def render_intelligence_gathering( st.success("手動事件已登錄!記得至地圖區更新 AI 摘要。") st.rerun() +def _render_impacted_po_marking(*, active_ev_id: int, region: str, country: str, impact_days: int, actor: str) -> None: + """受影響採購單標記:情報 → 事件 → 【這裡】→ 步驟 5 提案 → L3 核准。 + + 步驟 5 只列「已標上預估延遲或替代建議」的採購單;原本沒有任何畫面會寫這兩欄, + 所以整條鏈在這裡斷掉。這裡用事件地區找出未結採購單,AI 建議延遲/替代來源, + 使用者審核後寫回(RISK_WORKSPACE_WRITE,不動採購單本體)。 + """ + from backend.supply_chain_risk import ( + get_ai_alternative_suggestions, + get_impacted_pos, + update_po_impact, + ) + + with st.expander("🧾 0. 受影響採購單標記 → 送交步驟 5 提案 (Mark Impacted POs)", expanded=True): + try: + impacted = get_impacted_pos(region_key=region or None, country=country or None) + except Exception as exc: + show_error("受影響採購單讀取失敗", exc) + return + if not impacted: + st.info("此事件地區的供應商目前沒有未結採購單,步驟 5 不會有可提案項目。") + return + + marked = [x for x in impacted if x.get("estimated_delay_days") is not None or x.get("alternative_suggestion_raw")] + total_amount = sum(x.get("total_amount") or 0 for x in impacted) + st.caption( + f"事件地區命中 **{len(impacted)}** 張未結採購單(合計 ${total_amount:,.0f})," + f"其中 **{len(marked)}** 張已標記。標記後會出現在下方「步驟 5」供建立替代採購提案。" + ) + + hotspot = " ".join(part for part in (country, region) if part) or "受災地區" + ai_key = f"po_ai_suggest_{active_ev_id}" + col_ai, col_hint = st.columns([1, 2]) + with col_ai: + if st.button("🤖 AI 評估延遲與替代來源", key=f"po_ai_btn_{active_ev_id}", use_container_width=True): + with st.spinner("AI 正在依熱點、供應商與物料庫存評估每張採購單..."): + suggestions = get_ai_alternative_suggestions(impacted_list=impacted, hotspot_name=hotspot) + if suggestions: + st.session_state[ai_key] = {x["po_id"]: x for x in suggestions} + st.toast(f"AI 已為 {len(suggestions)} 張採購單提出建議", icon="🤖") + else: + st.session_state.pop(ai_key, None) + st.warning("AI 未回傳可用建議;你仍可手動填延遲天數與替代建議後標記。") + st.rerun() + with col_hint: + st.caption("沒按 AI 也能標:預設延遲=事件預估天數,替代建議可留空。表格可直接修改。") + + ai_suggestions = st.session_state.get(ai_key, {}) + table_rows = [] + for x in impacted: + sug = ai_suggestions.get(x["po_id"], {}) + default_days = sug.get("estimated_delay_days") or x.get("estimated_delay_days") or impact_days + default_alt = sug.get("alternative_suggestion") or x.get("alternative_suggestion_raw") or "" + table_rows.append({ + "標記": True, + "採購單": x["po_id"], + "供應商": x["supplier_name"], + "關鍵物料": x["key_materials"], + "金額": float(x.get("total_amount") or 0), + "目前": x["estimated_delay"], + "預估延遲 (天)": int(default_days), + "替代建議": default_alt, + }) + edited = st.data_editor( + pd.DataFrame(table_rows), + column_config={ + "標記": st.column_config.CheckboxColumn("標記", default=True), + "採購單": st.column_config.TextColumn("採購單", disabled=True), + "供應商": st.column_config.TextColumn("供應商", disabled=True), + "關鍵物料": st.column_config.TextColumn("關鍵物料(庫存)", disabled=True), + "金額": st.column_config.NumberColumn("金額", format="$%d", disabled=True), + "目前": st.column_config.TextColumn("目前延遲", disabled=True), + "預估延遲 (天)": st.column_config.NumberColumn("預估延遲 (天)", min_value=0, max_value=365, step=1), + "替代建議": st.column_config.TextColumn("替代建議(從哪裡調貨)", width="large"), + }, + hide_index=True, + use_container_width=True, + key=f"po_mark_editor_{active_ev_id}", + ) + selected = edited[edited["標記"] == True] + if st.button( + f"📌 標記 {len(selected)} 張為受影響採購單(寫入延遲與建議)", + key=f"po_mark_btn_{active_ev_id}", type="primary", disabled=len(selected) == 0, + ): + try: + for _, row in selected.iterrows(): + update_po_impact( + row["採購單"], + estimated_delay_days=int(row["預估延遲 (天)"]), + alternative_suggestion=(str(row["替代建議"]).strip() or None), + actor=actor, + ) + except PermissionError: + st.error("此帳號沒有標記受影響採購單的權限。") + return + except Exception as exc: + show_error("標記受影響採購單失敗", exc) + return + st.session_state.pop(ai_key, None) + st.toast(f"✅ 已標記 {len(selected)} 張採購單,步驟 5 可建立提案", icon="🧾") + st.rerun() + + def render_response_execution( api_key: str = "", gnews_api_key: str = "", @@ -362,6 +466,11 @@ def get_ai_safety_multiplier(etype): return mapping.get(etype, 1.0) # 執行與分析細節 (用摺疊式選單以省空間) + _render_impacted_po_marking( + active_ev_id=int(active_ev["id"]), region=region, country=country, + impact_days=impact_days, actor=actor, + ) + with st.expander("🚚 1. 斷鏈庫存預警與應變 (Increase Safety Stock)", expanded=True): if stock_alerts: etype = active_ev.get('event_type', '其他') diff --git a/tests/test_l2_risk_workspace.py b/tests/test_l2_risk_workspace.py index fba7bee..e5a591f 100644 --- a/tests/test_l2_risk_workspace.py +++ b/tests/test_l2_risk_workspace.py @@ -366,3 +366,70 @@ def test_country_event_does_not_light_up_whole_region(l2_db): risk.add_risk_event("戰爭", "亞洲", "", 30, "區域衝突", actor="planner") assert len(risk.events_for_location("越南", "亞洲")) == 1 assert len(risk.events_for_location("台灣", "亞洲")) == 2 + + +# ── 7. 受影響採購單標記:情報 → 事件 → 標記 → 步驟 5 ────────────────── + + +def _seed_open_po(db_path, po_id="PO-TW-1", supplier="S-TW1", amount=1500.0, product="P-L2"): + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT OR IGNORE INTO inventory (product_id, name, stock, daily_sales, reorder_point, baseline_reorder_point) " + "VALUES (?, 'L2 物料', 40, 4, 10, 10)", (product,), + ) + conn.execute( + "INSERT INTO purchase_orders (po_id, supplier_id, status, total_amount) VALUES (?,?,?,?)", + (po_id, supplier, "已下單", amount), + ) + conn.execute( + "INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)", + (po_id, product, 10, amount / 10), + ) + conn.commit() + + +def test_impacted_pos_carry_amount_and_raw_fields(l2_db): + _seed_open_po(l2_db) + rows = risk.get_impacted_pos(region_key="亞洲", country="台灣") + assert [r["po_id"] for r in rows] == ["PO-TW-1"] + row = rows[0] + assert row["total_amount"] == 1500.0 and row["supplier_id"] == "S-TW1" + assert row["estimated_delay_days"] is None and row["alternative_suggestion_raw"] == "" + assert "庫存約剩 10 天" in row["key_materials"] + assert risk.get_impacted_pos(region_key="歐洲", country="德國") == [] + + +def test_planner_marks_po_and_step5_lists_it(l2_db): + """整條鏈:登錄事件 → 找到受災採購單 → planner 標記 → 步驟 5 查得到。""" + from backend.purchase_proposals import list_impacted_purchase_options + + _seed_open_po(l2_db) + risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + assert list_impacted_purchase_options(actor="planner") == [] # 標記前步驟 5 是空的 + + risk.update_po_impact("PO-TW-1", estimated_delay_days=14, + alternative_suggestion="改由越南倉出貨", actor="planner") + + options = list_impacted_purchase_options(actor="planner") + assert [o["po_id"] for o in options] == ["PO-TW-1"] + assert options[0]["estimated_delay_days"] == 14 + assert options[0]["alternative_suggestion"] == "改由越南倉出貨" + # 標記後 get_impacted_pos 也帶出既有值,讓 UI 顯示「目前 +14 天」 + marked = risk.get_impacted_pos(region_key="亞洲", country="台灣")[0] + assert marked["estimated_delay"] == "+14 天" and marked["estimated_delay_days"] == 14 + + +@pytest.mark.parametrize("actor", [None, "", "viewer", "approver", "nobody"]) +def test_update_po_impact_fails_closed_for_non_workspace_roles(l2_db, actor): + _seed_open_po(l2_db) + with pytest.raises(PermissionError): + risk.update_po_impact("PO-TW-1", estimated_delay_days=9, actor=actor) + with sqlite3.connect(l2_db) as conn: + assert conn.execute("SELECT estimated_delay_days FROM purchase_orders WHERE po_id='PO-TW-1'").fetchone()[0] is None + + +def test_step3_marks_impacted_pos_with_actor(): + tree = ast.parse((ROOT / "frontend/components/risk_dashboard.py").read_text(encoding="utf-8")) + marks = _calls(tree, "update_po_impact") + assert marks and all(any(k.arg == "actor" for k in c.keywords) for c in marks) + assert _calls(tree, "get_impacted_pos") and _calls(tree, "get_ai_alternative_suggestions") diff --git a/tests/test_supply_chain_authorization.py b/tests/test_supply_chain_authorization.py index 9cda089..f5009b8 100644 --- a/tests/test_supply_chain_authorization.py +++ b/tests/test_supply_chain_authorization.py @@ -138,10 +138,11 @@ def _mutation(name: str, actor: str | None): "delete_factor", "clear_factors", "load_presets", + # 採購單延遲/替代建議是給 L3 看的評估註記,不動採購單本體 → L2 workspace + "update_po_impact", ) _ERP_POLICY_MUTATIONS = ( - "update_po_impact", "increase_stock", "restore_stock", "update_rop", From 39383dcfd5584a95d007267744ebcc7f308fbb8b Mon Sep 17 00:00:00 2001 From: ewiwi Date: Mon, 14 Sep 2026 01:45:11 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(l3):=20close=20the=20proposal=20loop?= =?UTF-8?q?=20=E2=80=94=20event=20evidence,=20decision=20feedback,=20idemp?= =?UTF-8?q?otent=20reversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking the approver path end to end showed the governed action worked but was blind and open-ended: proposals never carried the risk event they were made for (source_event_id was always NULL), the L3 page could not show why a proposal existed, and after a decision nothing changed on L1/L2 — the original PO line stayed in Step 5 as if nothing had happened. - Step 5 binds the proposal to a risk event: the event selected in Step 3 if any, otherwise the most severe formal event for the PO's supplier region. The form shows which event it will cite. - list_impacted_purchase_options returns per-line proposal status (pending / approved / rejected / unsubmitted, approver, decided_at, proposed_po_id, rejection reason), derived from pending_approvals via the proposal operation id. Step 5 labels each line and explains an approved or rejected line before allowing another proposal. - get_purchase_proposal_context (PROPOSAL_EVIDENCE_READ) gives the L3 page the bound event (type, region, delay, description, source news) and the affected PO's L2 annotation (status, amount, marked delay, suggestion); the approval card renders both above the immutable proposal evidence. - proposal_status_summary_by_event returns only counts per event for L1. - 沖銷 (compensating reversal) is now idempotent: get_reversal_record looks up a successful retry_approval log for the approval id; the button is replaced by "已沖銷於 …" and the handler re-checks before executing. (HANDOFF §3 #13) - The heatmap summary prompt no longer leaks the literal "nan" for events without a region (glm actually reported "地區欄位為 nan"). Co-Authored-By: Claude Opus 5 --- backend/agent_logger.py | 27 +++ backend/purchase_proposals.py | 112 ++++++++++++ backend/supply_chain_risk.py | 5 +- .../components/purchase_proposal_workbench.py | 68 ++++++++ frontend/page_agent_dashboard.py | 51 +++++- tests/test_l2_risk_workspace.py | 16 ++ tests/test_l3_proposal_closure.py | 159 ++++++++++++++++++ 7 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 tests/test_l3_proposal_closure.py diff --git a/backend/agent_logger.py b/backend/agent_logger.py index 9cb1cd3..0d9f02e 100644 --- a/backend/agent_logger.py +++ b/backend/agent_logger.py @@ -6,6 +6,8 @@ import hmac import json from datetime import datetime +import sqlite3 +from backend import database as _database from backend.database import run_query, transaction, tx_run from backend.log_checksum import _get_prev_checksum, compute_checksum @@ -413,3 +415,28 @@ def reject_action(approval_id: str, reason: str, approver: str) -> dict: from backend.tool_gateway import gateway res = gateway.reject_action(approval_id, reason, approver=approver) return res.to_dict() + + +def get_reversal_record(approval_id: str, *, conn=None) -> dict | None: + """某審批單是否已成功沖銷過;回 {"timestamp", "result", "caller"} 或 None。 + + 沖銷是補償交易,重按會再扣一次庫存/再取消一次訂單,所以前端要先查這裡。 + """ + if not approval_id: + return None + needle = json.dumps({"approval_id": approval_id}, ensure_ascii=False)[1:-1] # "approval_id": "…" + query = """ + SELECT timestamp, result, caller FROM agent_action_logs + WHERE tool_name = 'retry_approval' AND success = 1 AND parameters LIKE ? + ORDER BY id DESC LIMIT 1 + """ + owned = conn is None + conn = conn or sqlite3.connect(_database.DB_FILE) # 動態讀,測試可改路徑 + try: + row = conn.execute(query, (f"%{needle}%",)).fetchone() + finally: + if owned: + conn.close() + if not row: + return None + return {"timestamp": row[0], "result": row[1], "caller": row[2]} diff --git a/backend/purchase_proposals.py b/backend/purchase_proposals.py index e040a71..a848388 100644 --- a/backend/purchase_proposals.py +++ b/backend/purchase_proposals.py @@ -811,6 +811,7 @@ def list_impacted_purchase_options(*, actor: str) -> list[dict]: ORDER BY COALESCE(p.estimated_delay_days, 0) DESC, p.po_id, i.id """ ).fetchall() + status_by_line = _proposal_status_by_po_line(conn) result = [] for row in rows: item = dict(row) @@ -820,10 +821,121 @@ def list_impacted_purchase_options(*, actor: str) -> list[dict]: item["product_id"], item["source_po_item_id"], ) + item["proposal"] = status_by_line.get((item["po_id"], int(item["source_po_item_id"]))) result.append(item) return result +_PROPOSAL_STATUS_LABELS = { + "pending": "待 L3 核准", + "approved": "已核准", + "rejected": "已拒絕", + "unsubmitted": "草稿未送審", +} + + +def _proposal_status_by_po_line(conn) -> dict[tuple[str, int], dict]: + """每條受影響採購明細最新一筆提案的狀態(pending / approved / rejected / unsubmitted)。 + + 提案本身不存審批狀態(設計如此);狀態由 operation_id 對回 pending_approvals。 + 同一明細多次提案時取最新建立者。 + """ + rows = conn.execute( + """ + SELECT pr.proposal_id, pr.affected_po_id, pr.source_po_item_id, pr.proposed_po_id, + pr.alternative_supplier_id, pr.source_event_id, pr.created_at, + pa.status, pa.approver, pa.updated_at, pa.reason + FROM purchase_proposals pr + LEFT JOIN pending_approvals pa + ON pa.operation_id = ? || pr.proposal_id || ? + ORDER BY pr.created_at DESC, pr.rowid DESC + """, + (_OPERATION_PREFIX, f":{EXECUTION_CONTRACT_VERSION}"), + ).fetchall() + out: dict[tuple[str, int], dict] = {} + for row in rows: + (proposal_id, po_id, item_id, proposed_po_id, alt_supplier, event_id, + created_at, status, approver, decided_at, reason) = tuple(row) + key = (str(po_id), int(item_id)) + if key in out: + continue + status = str(status or "unsubmitted") + out[key] = { + "proposal_id": proposal_id, + "status": status, + "label": _PROPOSAL_STATUS_LABELS.get(status, status), + "proposed_po_id": proposed_po_id, + "alternative_supplier_id": alt_supplier, + "source_event_id": event_id, + "created_at": created_at, + "approver": approver, + "decided_at": decided_at if status in {"approved", "rejected"} else None, + "reason": reason or "", + } + return out + + +def proposal_status_summary_by_event(event_ids, *, conn=None) -> dict[int, dict]: + """各風險事件底下替代採購提案的狀態計數(L1 告警用;只回計數,不含提案內容)。""" + ids = sorted({int(i) for i in (event_ids or []) if i is not None}) + if not ids: + return {} + placeholders = ",".join("?" for _ in ids) + query = f""" + SELECT pr.source_event_id, COALESCE(pa.status, 'unsubmitted'), COUNT(*) + FROM purchase_proposals pr + LEFT JOIN pending_approvals pa ON pa.operation_id = ? || pr.proposal_id || ? + WHERE pr.source_event_id IN ({placeholders}) + GROUP BY pr.source_event_id, COALESCE(pa.status, 'unsubmitted') + """ + params = (_OPERATION_PREFIX, f":{EXECUTION_CONTRACT_VERSION}", *ids) + owned = conn is None + conn = conn or sqlite3.connect(database.DB_FILE) + try: + rows = conn.execute(query, params).fetchall() + finally: + if owned: + conn.close() + out: dict[int, dict] = {} + for event_id, status, count in rows: + entry = out.setdefault(int(event_id), {"pending": 0, "approved": 0, "rejected": 0, "unsubmitted": 0}) + entry[str(status)] = entry.get(str(status), 0) + int(count) + return out + + +def get_purchase_proposal_context(proposal: PurchaseProposal, *, actor: str) -> dict: + """審批頁的 L2 證據脈絡:提案所依據的風險事件 + 受影響採購單上的延遲/替代建議註記。 + + 只讀;需 PROPOSAL_EVIDENCE_READ。找不到事件時 event 為 None(舊提案沒綁事件)。 + """ + require_capability(actor, PROPOSAL_EVIDENCE_READ) + with sqlite3.connect(database.DB_FILE) as conn: + conn.row_factory = sqlite3.Row + event = None + if proposal.source_event_id is not None: + row = conn.execute( + """ + SELECT e.id, e.event_type, e.region, e.country, e.impact_days, e.description, + e.created_at, e.news_id, n.title AS news_title, n.url AS news_url + FROM supply_chain_events e + LEFT JOIN supply_chain_news n ON n.id = e.news_id + WHERE e.id = ? + """, + (int(proposal.source_event_id),), + ).fetchone() + event = dict(row) if row else None + po = conn.execute( + """ + SELECT p.po_id, p.status, p.total_amount, p.estimated_delay_days, p.alternative_suggestion, + s.name AS supplier_name, s.country, s.region + FROM purchase_orders p LEFT JOIN suppliers s ON s.supplier_id = p.supplier_id + WHERE p.po_id = ? + """, + (proposal.affected_po_id,), + ).fetchone() + return {"event": event, "affected_po": dict(po) if po else None} + + def list_alternative_suppliers( *, affected_po_id: str, diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index 2ef115f..6e0f627 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -741,8 +741,11 @@ def analyze_heatmap_risk( events_text = "目前尚無已登錄事件。" if events_df is not None and not events_df.empty: # 只列出最近的 15 筆事件作為背景 + # region 為 NaN 時 pandas 值為 truthy,原本會把字面 "nan" 餵給模型(模型真的回了「地區欄位為 nan」) events_text = "\n".join([ - f"- 【{row['event_type']}】區域:{row['region'] or row['country']} (預計延遲:{row['impact_days']}天)" + f"- 【{_clean_text(row['event_type']) or '其他'}】區域:" + f"{' '.join(p for p in (_clean_text(row['country']), _clean_text(row['region'])) if p) or '未填'}" + f" (預計延遲:{row['impact_days']}天)" for _, row in events_df.head(15).iterrows() ]) diff --git a/frontend/components/purchase_proposal_workbench.py b/frontend/components/purchase_proposal_workbench.py index b9b9e00..7b2c2c0 100644 --- a/frontend/components/purchase_proposal_workbench.py +++ b/frontend/components/purchase_proposal_workbench.py @@ -83,6 +83,35 @@ def _render_submission_state() -> bool: return True +_BADGES = {"pending": "⏳ ", "approved": "✅ ", "rejected": "❌ "} + + +def _proposal_badge(item: dict) -> str: + return _BADGES.get((item.get("proposal") or {}).get("status"), "") + + +def _resolve_source_event(selected: dict) -> dict | None: + """步驟 3 選中的事件優先;否則依受影響採購單的供應商地區找最嚴重的正式事件。""" + from backend.supply_chain_risk import events_for_location, get_risk_events_list, is_news_event + + active_id = st.session_state.get("active_risk_event_id") + try: + events = get_risk_events_list(limit=200) + except Exception: + return None + if events is None or events.empty: + return None + if active_id is not None: + hit = events[events["id"] == active_id] + if not hit.empty: + return hit.iloc[0].to_dict() + matched = [ + ev for ev in events_for_location(selected.get("country") or "", selected.get("region") or "", events) + if not is_news_event(ev) + ] + return matched[0] if matched else None + + def render_purchase_proposal_workbench(*, actor: str) -> None: """Render affected PO evidence, candidate suppliers, and proposal submit.""" st.subheader("🧾 替代採購決策提案") @@ -101,11 +130,23 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: st.info("目前沒有含延遲或替代建議的受影響採購單。") return + # 閉環:已核准/待審的明細標出來,避免同一條明細重複提案 + counts = {"pending": 0, "approved": 0, "rejected": 0} + for item in impacted: + status = (item.get("proposal") or {}).get("status") + if status in counts: + counts[status] += 1 + if any(counts.values()): + st.caption( + f"提案狀態:待核准 {counts['pending']} ・ 已核准 {counts['approved']} ・ 已拒絕 {counts['rejected']}" + ) + option_keys = list(range(len(impacted))) selected_index = st.selectbox( "選擇受影響採購品項", option_keys, format_func=lambda index: ( + f"{_proposal_badge(impacted[index])}" f"{impacted[index]['po_id']}|" f"{impacted[index]['product_id']} {impacted[index].get('product_name') or ''}|" f"明細 #{impacted[index]['source_po_item_id']} × {impacted[index]['qty']}|" @@ -115,6 +156,24 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: key="purchase_proposal_affected_line", ) selected = impacted[selected_index] + existing = selected.get("proposal") + if existing and existing.get("status") == "approved": + st.success( + f"此明細的替代採購提案 `{existing['proposal_id']}` 已由 `{existing.get('approver') or 'L3'}` " + f"於 {existing.get('decided_at') or '—'} 核准,替代採購單 `{existing.get('proposed_po_id')}` 已建立。" + "如需再次提案請先確認原因。" + ) + elif existing and existing.get("status") == "pending": + st.info(f"此明細已有提案 `{existing['proposal_id']}` 待 L3 核准;再送一筆會成為新的提案。") + elif existing and existing.get("status") == "rejected": + st.warning( + f"上一筆提案 `{existing['proposal_id']}` 已被拒絕" + + (f":{existing.get('reason')}" if existing.get("reason") else "") + + "。可修正後重新提案。" + ) + + # 提案綁定風險事件:優先用步驟 3 正在分析的事件,否則依供應商地區找最嚴重的正式事件 + source_event = _resolve_source_event(selected) st.dataframe( pd.DataFrame( [ @@ -173,6 +232,14 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: value=int(selected.get("estimated_delay_days") or 0), step=1, ) + if source_event: + st.caption( + f"依據事件 #{source_event['id']}:{source_event.get('event_type')}|" + f"{source_event.get('country') or ''} {source_event.get('region') or ''}|" + f"預估延遲 {source_event.get('impact_days') or 0} 天(L3 審批頁會看到)" + ) + else: + st.caption("找不到對應的正式風險事件;提案仍可送出,但 L3 看不到事件依據。") st.caption(f"提案識別碼:`{proposal_id}`") if st.form_submit_button( "送交 L3 人工核准", type="primary", use_container_width=True @@ -190,6 +257,7 @@ def render_purchase_proposal_workbench(*, actor: str) -> None: ], reason=reason, estimated_delay_days=int(delay_days), + source_event_id=int(source_event["id"]) if source_event else None, actor=actor, ) result = submit_purchase_proposal(proposal, actor=actor) diff --git a/frontend/page_agent_dashboard.py b/frontend/page_agent_dashboard.py index b9d1e21..1227d45 100644 --- a/frontend/page_agent_dashboard.py +++ b/frontend/page_agent_dashboard.py @@ -12,6 +12,7 @@ from backend.access_control import load_principal from backend.agent_registry import AGENTS, get_tools_for_agent, get_agent_for_tool from backend.agent_logger import ( + get_reversal_record, get_pending_list, get_action_logs, approve_action, @@ -21,6 +22,7 @@ ) from backend.database import run_query from backend.purchase_proposals import ( + get_purchase_proposal_context, ApprovalDecision, decide_purchase_proposal, get_purchase_operation_timeline, @@ -182,8 +184,40 @@ def format_parameters_to_chinese(tool_name: str, args) -> str: return ", ".join(parts) -def _render_domain_proposal_evidence(proposal) -> None: +def _render_proposal_context(proposal, principal) -> None: + """L2 的事件依據與採購單註記:讓 L3 不用回 L2 頁翻就能判斷。""" + try: + context = get_purchase_proposal_context(proposal, actor=principal.username) + except (PermissionError, ValueError) as exc: + st.caption(f"(無法讀取事件依據:{exc})") + return + event = context.get("event") + if event: + location = " ".join(part for part in (event.get("country"), event.get("region")) if part) or "未填地區" + st.markdown( + f"**風險事件依據**:#{event['id']} `{event.get('event_type') or '未分類'}`|{location}|" + f"預估延遲 {event.get('impact_days') or 0} 天|登錄於 {event.get('created_at') or '—'}" + ) + if event.get("description"): + st.caption(f"事件說明:{event['description'][:160]}") + if event.get("news_url"): + st.caption(f"來源新聞:[{event.get('news_title') or '開啟'}]({event['news_url']})") + else: + st.caption("此提案未綁定風險事件(舊提案或 L2 未選事件)。") + po = context.get("affected_po") + if po: + st.caption( + f"受影響採購單註記:{po.get('supplier_name') or po.get('po_id')}({po.get('country') or ''} {po.get('region') or ''})|" + f"狀態 {po.get('status') or '—'}|金額 ${float(po.get('total_amount') or 0):,.0f}|" + f"L2 標記延遲 {po.get('estimated_delay_days') if po.get('estimated_delay_days') is not None else '—'} 天" + + (f"|建議:{po['alternative_suggestion'][:80]}" if po.get("alternative_suggestion") else "") + ) + + +def _render_domain_proposal_evidence(proposal, principal=None) -> None: """Show the immutable business evidence separately from approval state.""" + if principal is not None: + _render_proposal_context(proposal, principal) st.markdown(f"**受影響採購單**:`{proposal.affected_po_id}`") st.markdown( f"**供應來源變更**:`{proposal.original_supplier_id}` → " @@ -267,7 +301,7 @@ def _render_purchase_approval_dashboard(principal, pending_list, approval_histor st.error(f"提案證據驗證失敗,已停止決策:{evidence_error}") continue if domain_proposal is not None: - _render_domain_proposal_evidence(domain_proposal) + _render_domain_proposal_evidence(domain_proposal, principal) _render_operation_timeline(item["operation_id"], principal) if item.get("requester_username") == principal.username: @@ -364,7 +398,7 @@ def _render_purchase_approval_dashboard(principal, pending_list, approval_histor if evidence_error: st.error(f"提案證據驗證失敗:{evidence_error}") elif domain_proposal is not None: - _render_domain_proposal_evidence(domain_proposal) + _render_domain_proposal_evidence(domain_proposal, principal) _render_operation_timeline(item["operation_id"], principal) if item["reason"]: st.markdown(f"**拒絕原因**:{item['reason']}") @@ -575,13 +609,22 @@ def render(username: str = ""): history_action = _history_action_kind( item["status"], item["tool"], current_role ) - if history_action == "rollback": + reversed_record = ( + get_reversal_record(item["id"]) if history_action == "rollback" else None + ) + if history_action == "rollback" and reversed_record: + # 沖銷是補償交易,重按會再扣一次庫存/再取消一次訂單:已沖銷就不給按 + st.caption(f"🔁 已沖銷於 {reversed_record['timestamp']}") + elif history_action == "rollback": # 沖銷(補償交易):走 Gateway 執行、寫入 action log 供稽核。 # 不再把單號重置回 pending —— 沖銷本身已核准人一次確認, # 不需要再進一次審批單讓同一位管理員自己審自己。 if st.button("🔄 沖銷", key=f"retry_{item['id']}", use_container_width=True): from backend.tool_gateway import gateway ok, msg = False, "" + if get_reversal_record(item["id"]): + st.warning("此單已沖銷過,不重複執行。") + st.rerun() if item["tool"] == "update_inventory": args = item["raw_args"] pid = args.get("product_id") diff --git a/tests/test_l2_risk_workspace.py b/tests/test_l2_risk_workspace.py index e5a591f..80a7269 100644 --- a/tests/test_l2_risk_workspace.py +++ b/tests/test_l2_risk_workspace.py @@ -433,3 +433,19 @@ def test_step3_marks_impacted_pos_with_actor(): marks = _calls(tree, "update_po_impact") assert marks and all(any(k.arg == "actor" for k in c.keywords) for c in marks) assert _calls(tree, "get_impacted_pos") and _calls(tree, "get_ai_alternative_suggestions") + + +def test_summary_prompt_never_leaks_nan_regions(l2_db, monkeypatch): + """事件 region 為 NULL 時,餵給模型的事件清單不能出現字面 nan。""" + import backend.llm_client as lc + seen = {} + + def fake(prompt, **kw): + seen["prompt"] = prompt if isinstance(prompt, str) else str(prompt) + return '{"摘要": "ok", "更新": [], "事件": []}' + + monkeypatch.setattr(lc, "complete_text", fake) + risk.add_risk_event("其他", "", "台灣", 7, "無地區", actor="planner") + risk.analyze_heatmap_risk([], reference_date="2026-09-13") + assert "nan" not in seen["prompt"].lower().replace("financial", "") + assert "區域:台灣" in seen["prompt"] diff --git a/tests/test_l3_proposal_closure.py b/tests/test_l3_proposal_closure.py new file mode 100644 index 0000000..b010095 --- /dev/null +++ b/tests/test_l3_proposal_closure.py @@ -0,0 +1,159 @@ +""" +tests/test_l3_proposal_closure.py +L3 受治理行動閉環: + - 步驟 5 清單附帶每條明細的提案狀態(pending / approved / rejected) + - 提案綁定風險事件,審批頁可讀事件依據與採購單註記(PROPOSAL_EVIDENCE_READ) + - L1 可取得各事件的提案狀態計數(不含提案內容) + - 沖銷紀錄可查、同一審批單只算一次 +""" + +from __future__ import annotations + +import ast +from pathlib import Path +import sqlite3 + +import pytest + +from backend import agent_logger +from backend import database +from backend import purchase_proposals as pp +from backend import supply_chain_risk as risk + + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def flow_db(tmp_path, monkeypatch): + db_path = tmp_path / "l3-flow.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + monkeypatch.setenv("ERP_ENABLE_DEMO_SEED", "1") # 正式供應商各一張採購單 + database.init_db() + return db_path + + +def _first_impacted(actor="planner"): + opts = pp.list_impacted_purchase_options(actor=actor) + assert opts, "demo seed 應該讓至少一條明細被標記" + return opts[0] + + +def _propose(sel, proposal_id, event_id=None, actor="planner"): + alts = pp.list_alternative_suppliers( + affected_po_id=sel["po_id"], product_id=sel["product_id"], + source_po_item_id=sel["source_po_item_id"], actor=actor, + ) + assert alts + proposal = pp.prepare_alternative_purchase_proposal( + proposal_id=proposal_id, affected_po_id=sel["po_id"], product_id=sel["product_id"], + source_po_item_id=sel["source_po_item_id"], alternative_supplier_id=alts[0]["supplier_id"], + alternative_supplier_product_id=alts[0]["supplier_product_id"], reason="改由備援供貨", + estimated_delay_days=14, source_event_id=event_id, actor=actor, + ) + result = pp.submit_purchase_proposal(proposal, actor=actor) + assert result.status == "pending" + return proposal + + +def _mark_first_po(flow_db): + with sqlite3.connect(flow_db) as conn: + po_id, supplier = conn.execute( + "SELECT p.po_id, p.supplier_id FROM purchase_orders p ORDER BY p.po_id LIMIT 1" + ).fetchone() + country, region = conn.execute( + "SELECT country, region FROM suppliers WHERE supplier_id=?", (supplier,) + ).fetchone() + risk.update_po_impact(po_id, estimated_delay_days=14, alternative_suggestion="改由備援", actor="planner") + return po_id, country, region + + +def test_step5_reports_proposal_status_through_the_whole_flow(flow_db): + po_id, country, region = _mark_first_po(flow_db) + event_id = risk.add_risk_event("戰爭", region, country, 30, "港口攻擊", actor="planner") + + sel = _first_impacted() + assert sel["po_id"] == po_id and sel["proposal"] is None + + proposal = _propose(sel, "closure-001", event_id) + sel = _first_impacted() + assert sel["proposal"]["status"] == "pending" and sel["proposal"]["label"] == "待 L3 核准" + assert sel["proposal"]["source_event_id"] == event_id + + pp.decide_purchase_proposal(pp.ApprovalDecision(proposal_id="closure-001", outcome="approve"), actor="approver") + sel = _first_impacted() + assert sel["proposal"]["status"] == "approved" + assert sel["proposal"]["approver"] == "approver" and sel["proposal"]["decided_at"] + assert sel["proposal"]["proposed_po_id"] == proposal.proposed_po_id + with sqlite3.connect(flow_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM purchase_orders WHERE po_id=?", (proposal.proposed_po_id,)).fetchone()[0] == 1 + + # L1 只拿計數 + summary = pp.proposal_status_summary_by_event([event_id, 999999]) + assert summary == {event_id: {"pending": 0, "approved": 1, "rejected": 0, "unsubmitted": 0}} + + +def test_rejected_proposal_keeps_reason_and_latest_wins(flow_db): + po_id, country, region = _mark_first_po(flow_db) + sel = _first_impacted() + _propose(sel, "closure-r1") + pp.decide_purchase_proposal( + pp.ApprovalDecision(proposal_id="closure-r1", outcome="reject", reason="價格過高"), actor="approver" + ) + sel = _first_impacted() + assert sel["proposal"]["status"] == "rejected" and sel["proposal"]["reason"] == "價格過高" + # 重新提案 → 最新一筆為準 + _propose(sel, "closure-r2") + assert _first_impacted()["proposal"]["proposal_id"] == "closure-r2" + + +def test_proposal_context_exposes_event_and_po_annotation(flow_db): + po_id, country, region = _mark_first_po(flow_db) + event_id = risk.add_risk_event("罷工", region, country, 21, "碼頭罷工", actor="planner") + proposal = _propose(_first_impacted(), "closure-ctx", event_id) + + context = pp.get_purchase_proposal_context(proposal, actor="approver") + assert context["event"]["id"] == event_id and context["event"]["event_type"] == "罷工" + assert context["affected_po"]["po_id"] == po_id + assert context["affected_po"]["estimated_delay_days"] == 14 + assert context["affected_po"]["alternative_suggestion"] == "改由備援" + + unbound = _propose(_first_impacted(), "closure-ctx-2") + assert pp.get_purchase_proposal_context(unbound, actor="approver")["event"] is None + + +@pytest.mark.parametrize("actor", [None, "", "viewer", "planner", "nobody"]) +def test_proposal_context_fails_closed(flow_db, actor): + _mark_first_po(flow_db) + proposal = _propose(_first_impacted(), "closure-deny") + with pytest.raises(PermissionError): + pp.get_purchase_proposal_context(proposal, actor=actor) + + +def test_reversal_record_is_found_only_after_success(flow_db): + assert agent_logger.get_reversal_record("PENDING-X") is None + agent_logger.write_action_log("retry_approval", {"approval_id": "PENDING-X"}, "admin", "沖銷失敗", False) + assert agent_logger.get_reversal_record("PENDING-X") is None + agent_logger.write_action_log("retry_approval", {"approval_id": "PENDING-X"}, "admin", "已沖銷", True) + record = agent_logger.get_reversal_record("PENDING-X") + assert record and record["result"] == "已沖銷" and record["caller"] == "admin" + assert agent_logger.get_reversal_record("PENDING-Y") is None # 不會誤配其他單 + + +def _calls(tree, name): + return [n for n in ast.walk(tree) if isinstance(n, ast.Call) + and ((isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name))] + + +def test_workbench_binds_source_event_and_dashboard_guards_reversal(): + wb = ast.parse((ROOT / "frontend/components/purchase_proposal_workbench.py").read_text(encoding="utf-8")) + prepares = _calls(wb, "prepare_alternative_purchase_proposal") + assert prepares and all(any(k.arg == "source_event_id" for k in c.keywords) for c in prepares) + + dash_src = (ROOT / "frontend/page_agent_dashboard.py").read_text(encoding="utf-8") + dash = ast.parse(dash_src) + assert _calls(dash, "get_reversal_record"), "沖銷前必須查是否已沖銷" + contexts = _calls(dash, "get_purchase_proposal_context") + assert contexts and all(any(k.arg == "actor" for k in c.keywords) for c in contexts) From 0bc642f9468a50567eb4f765534236cee155e8af Mon Sep 17 00:00:00 2001 From: ewiwi Date: Mon, 14 Sep 2026 01:52:48 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(l1):=20alert=20acknowledgement,=20L1?= =?UTF-8?q?=E2=86=92L2=20handoff,=20live=20PO=20mapping,=20proposal=20stat?= =?UTF-8?q?us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L1 was a stateless read: every refresh rebuilt the same alert list, nothing recorded who had looked at what, the only way to map purchase orders to events was uploading a CSV even though the ERP now holds open POs, and an L1 viewer had no way to push a suspicious candidate to L2. - New capability RISK_ALERT_ACK (tier L1_MONITOR) on every role that has the L1 overview. It writes only to risk_alert_states (kind, ref_id, status, note, who, when) — monitoring state, not ERP data — so the "L1 never writes ERP" rule still holds. - Confirmed events: editable 處理狀態 (未讀/已讀/處理中) + 備註, saved with one button; the table also shows 替代提案 counts (核准/待審/拒絕) from L3. - Candidates: tick 通知 L2 (or set 已讀); the L2 intelligence page opens with "📨 L1 轉來 N 則待確認情報" listing them with who/when/note. Registering the news as an event closes the notice and removes the candidate automatically. - 告警與通知中心 defaults to 系統內未結採購單 (load_open_purchase_rows, RISK_OVERVIEW_READ) and maps them in memory exactly like the CSV path, which stays available as the second option. - get_latest_event_alerts merges ack state and proposal counts into each item; get_alert_states / list_l1_notifications_for_l2 are read-only and fail closed. Co-Authored-By: Claude Opus 5 --- backend/access_control.py | 8 +- backend/database.py | 9 ++ backend/l1_monitoring.py | 179 +++++++++++++++++++++++- frontend/components/risk_dashboard.py | 27 ++++ frontend/components/risk_overview.py | 146 +++++++++++++++++--- tests/test_l1_alert_states.py | 187 ++++++++++++++++++++++++++ tests/test_tier_authorization.py | 4 +- 7 files changed, 535 insertions(+), 25 deletions(-) create mode 100644 tests/test_l1_alert_states.py diff --git a/backend/access_control.py b/backend/access_control.py index e76536d..5f99378 100644 --- a/backend/access_control.py +++ b/backend/access_control.py @@ -14,6 +14,8 @@ RISK_OVERVIEW_READ = "risk.overview.read" +# L1 告警的已讀/處理中/通知 L2:監控狀態,不是 ERP 資料,L1 唯讀原則不受影響 +RISK_ALERT_ACK = "risk.alert.ack" RISK_ANALYSIS_READ = "risk.analysis.read" RISK_WHAT_IF_RUN = "risk.what_if.run" RISK_WORKSPACE_WRITE = "risk.workspace.write" @@ -32,6 +34,7 @@ _CAPABILITY_ENTITLEMENT = { RISK_OVERVIEW_READ: L1_MONITOR, + RISK_ALERT_ACK: L1_MONITOR, RISK_ANALYSIS_READ: L2_DECISION, RISK_WHAT_IF_RUN: L2_DECISION, RISK_WORKSPACE_WRITE: L2_DECISION, @@ -49,10 +52,11 @@ _ROLE_CAPABILITIES = { - "risk_viewer": frozenset({RISK_OVERVIEW_READ}), + "risk_viewer": frozenset({RISK_OVERVIEW_READ, RISK_ALERT_ACK}), "supply_planner": frozenset( { RISK_OVERVIEW_READ, + RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN, RISK_WORKSPACE_WRITE, @@ -62,6 +66,7 @@ "procurement_approver": frozenset( { RISK_OVERVIEW_READ, + RISK_ALERT_ACK, PROPOSAL_EVIDENCE_READ, APPROVAL_QUEUE_READ, APPROVAL_DECIDE, @@ -74,6 +79,7 @@ "warehouse": frozenset( { RISK_OVERVIEW_READ, + RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN, RISK_WORKSPACE_WRITE, diff --git a/backend/database.py b/backend/database.py index fa475b4..af28a87 100644 --- a/backend/database.py +++ b/backend/database.py @@ -208,6 +208,15 @@ def init_db(): ai_summary TEXT, updated_at TEXT )''') + c.execute('''CREATE TABLE IF NOT EXISTS risk_alert_states ( + alert_key TEXT PRIMARY KEY, + kind TEXT NOT NULL, + ref_id INTEGER NOT NULL, + status TEXT NOT NULL, + note TEXT, + updated_by TEXT, + updated_at TEXT NOT NULL + )''') c.execute('''CREATE TABLE IF NOT EXISTS risk_ai_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, diff --git a/backend/l1_monitoring.py b/backend/l1_monitoring.py index d2cac2e..e2a27cc 100644 --- a/backend/l1_monitoring.py +++ b/backend/l1_monitoring.py @@ -6,7 +6,7 @@ import sqlite3 from backend import database -from backend.access_control import RISK_OVERVIEW_READ, require_capability +from backend.access_control import RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_OVERVIEW_READ, require_capability # 告警嚴重度依預估延遲天數分級;L1 只讀不寫,分級規則放在後端以便 LINE / Web 共用。 @@ -275,6 +275,182 @@ def _load_candidate_alerts(conn: sqlite3.Connection, *, since: str, limit: int) return candidates +# ── 告警狀態(已讀/處理中/已通知 L2) ──────────────────────────────── +# 監控狀態獨立一張表,不碰事件與新聞本體;L1 每次重整仍直接讀 DB,但狀態會留下來。 + +ALERT_KIND_CONFIRMED = "confirmed" +ALERT_KIND_CANDIDATE = "candidate" +ALERT_STATUS_UNREAD = "未讀" +ALERT_STATUS_READ = "已讀" +ALERT_STATUS_IN_PROGRESS = "處理中" +ALERT_STATUS_NOTIFIED_L2 = "已通知L2" +CONFIRMED_STATUS_OPTIONS = (ALERT_STATUS_UNREAD, ALERT_STATUS_READ, ALERT_STATUS_IN_PROGRESS) +CANDIDATE_STATUS_OPTIONS = (ALERT_STATUS_UNREAD, ALERT_STATUS_READ, ALERT_STATUS_NOTIFIED_L2) +_STATUS_OPTIONS = { + ALERT_KIND_CONFIRMED: CONFIRMED_STATUS_OPTIONS, + ALERT_KIND_CANDIDATE: CANDIDATE_STATUS_OPTIONS, +} + + +def _ensure_alert_state_table(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS risk_alert_states ( + alert_key TEXT PRIMARY KEY, kind TEXT NOT NULL, ref_id INTEGER NOT NULL, + status TEXT NOT NULL, note TEXT, updated_by TEXT, updated_at TEXT NOT NULL)""" + ) + + +def _alert_key(kind: str, ref_id) -> str: + return f"{kind}:{int(ref_id)}" + + +def set_alert_status(kind: str, ref_id, status: str, *, actor: str | None, note: str = "", + conn: sqlite3.Connection | None = None, now: datetime | None = None) -> dict: + """L1 標記告警狀態。authorization 先於任何寫入;狀態值必須是該類別允許的選項。""" + require_capability(actor, RISK_ALERT_ACK, conn=conn) + if kind not in _STATUS_OPTIONS: + raise ValueError(f"不支援的告警類別:{kind}") + status = _text(status) + if status not in _STATUS_OPTIONS[kind]: + raise ValueError(f"{kind} 告警不支援狀態「{status}」") + record = { + "alert_key": _alert_key(kind, ref_id), + "kind": kind, + "ref_id": int(ref_id), + "status": status, + "note": _text(note)[:500], + "updated_by": actor, + "updated_at": (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"), + } + + def _write(active_conn: sqlite3.Connection) -> None: + _ensure_alert_state_table(active_conn) + active_conn.execute( + """INSERT INTO risk_alert_states (alert_key, kind, ref_id, status, note, updated_by, updated_at) + VALUES (:alert_key, :kind, :ref_id, :status, :note, :updated_by, :updated_at) + ON CONFLICT(alert_key) DO UPDATE SET status=excluded.status, note=excluded.note, + updated_by=excluded.updated_by, updated_at=excluded.updated_at""", + record, + ) + active_conn.commit() + + if conn is not None: + _write(conn) + else: + with sqlite3.connect(database.DB_FILE) as owned_conn: + _write(owned_conn) + return record + + +def get_alert_states(kind: str, ref_ids, *, conn: sqlite3.Connection | None = None) -> dict[int, dict]: + ids = sorted({int(i) for i in (ref_ids or []) if i is not None}) + if not ids: + return {} + + def _load(active_conn: sqlite3.Connection) -> dict[int, dict]: + _ensure_alert_state_table(active_conn) + placeholders = ",".join("?" for _ in ids) + rows = active_conn.execute( + f"""SELECT ref_id, status, note, updated_by, updated_at FROM risk_alert_states + WHERE kind = ? AND ref_id IN ({placeholders})""", + (kind, *ids), + ).fetchall() + return { + int(ref_id): {"status": status, "note": _text(note), "updated_by": _text(by), "updated_at": _text(at)} + for ref_id, status, note, by, at in rows + } + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def list_l1_notifications_for_l2(*, actor: str | None, conn: sqlite3.Connection | None = None) -> list[dict]: + """L1 標成「已通知L2」、而 L2 還沒登錄成事件的情報。L2 頁面頂端提醒用。""" + require_capability(actor, RISK_ANALYSIS_READ, conn=conn) + + def _load(active_conn: sqlite3.Connection) -> list[dict]: + _ensure_alert_state_table(active_conn) + rows = active_conn.execute( + """ + SELECT s.ref_id, s.note, s.updated_by, s.updated_at, + n.title, n.country, n.region, n.category, n.estimated_delay, n.url + FROM risk_alert_states s + JOIN supply_chain_news n ON n.id = s.ref_id + WHERE s.kind = ? AND s.status = ? + AND NOT EXISTS (SELECT 1 FROM supply_chain_events e WHERE e.news_id = n.id) + ORDER BY s.updated_at DESC + """, + (ALERT_KIND_CANDIDATE, ALERT_STATUS_NOTIFIED_L2), + ).fetchall() + return [ + { + "news_id": int(ref_id), "note": _text(note), "notified_by": _text(by), "notified_at": _text(at), + "title": _text(title), "country": _text(country), "region": _text(region), + "event_type": _text(category) or "其他", "impact_days": _impact_days({"impact_days": delay}), + "url": _text(url), + } + for ref_id, note, by, at, title, country, region, category, delay, url in rows + ] + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def load_open_purchase_rows(*, actor: str | None, conn: sqlite3.Connection | None = None) -> list[dict]: + """系統內未結採購單(一列一品項),格式與 CSV 範本相同,供 L1 對映事件。唯讀。""" + require_capability(actor, RISK_OVERVIEW_READ, conn=conn) + + def _load(active_conn: sqlite3.Connection) -> list[dict]: + rows = active_conn.execute( + """ + SELECT p.po_id, p.supplier_id, i.product_id, i.qty, p.status, p.order_date, p.total_amount + FROM purchase_orders p + LEFT JOIN purchase_order_items i ON i.po_id = p.po_id + WHERE (p.status IS NULL OR p.status NOT IN ('已完成', '已取消')) + ORDER BY p.po_id, i.id + """ + ).fetchall() + return [ + { + "external_id": po_id, "po_id": po_id, "supplier_id": _text(supplier_id), + "product_id": _text(product_id), "qty": int(qty or 0), "status": _text(status), + "order_date": _text(order_date), "total_amount": float(total_amount or 0), + } + for po_id, supplier_id, product_id, qty, status, order_date, total_amount in rows + ] + + if conn is not None: + return _load(conn) + with sqlite3.connect(database.DB_FILE) as owned_conn: + return _load(owned_conn) + + +def _attach_ack_and_proposals(conn: sqlite3.Connection, confirmed: list[dict], candidates: list[dict]) -> None: + """把 L1 標記狀態與 L3 提案計數併進告警列(唯讀)。""" + from backend.purchase_proposals import proposal_status_summary_by_event + + confirmed_states = get_alert_states(ALERT_KIND_CONFIRMED, [item["id"] for item in confirmed], conn=conn) + proposal_counts = proposal_status_summary_by_event([item["id"] for item in confirmed], conn=conn) + for item in confirmed: + state = confirmed_states.get(int(item["id"]), {}) + item["ack_status"] = state.get("status") or ALERT_STATUS_UNREAD + item["ack_note"] = state.get("note", "") + item["ack_by"] = state.get("updated_by", "") + item["ack_at"] = state.get("updated_at", "") + item["proposals"] = proposal_counts.get(int(item["id"]), {"pending": 0, "approved": 0, "rejected": 0, "unsubmitted": 0}) + candidate_states = get_alert_states(ALERT_KIND_CANDIDATE, [item["news_id"] for item in candidates], conn=conn) + for item in candidates: + state = candidate_states.get(int(item["news_id"]), {}) + item["ack_status"] = state.get("status") or ALERT_STATUS_UNREAD + item["ack_note"] = state.get("note", "") + item["ack_by"] = state.get("updated_by", "") + item["ack_at"] = state.get("updated_at", "") + + def get_latest_event_alerts( *, actor: str | None, @@ -296,6 +472,7 @@ def get_latest_event_alerts( def _load(active_conn: sqlite3.Connection) -> dict: confirmed = _load_confirmed_alerts(active_conn, since=since, limit=limit) candidates = _load_candidate_alerts(active_conn, since=since, limit=limit) + _attach_ack_and_proposals(active_conn, confirmed, candidates) severities = [item["severity"] for item in confirmed + candidates] highest = "無" for level in ("高", "中", "低"): diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index ebd6561..3eb7032 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -30,6 +30,32 @@ def can_write_erp_policy(actor: str) -> bool: return has_capability(actor, ERP_POLICY_WRITE) +def _render_l1_handoff_notices(*, actor: str) -> None: + """L1 勾「通知 L2」的待確認情報;登錄成事件後自動消失(唯讀提示)。""" + from backend.l1_monitoring import list_l1_notifications_for_l2 + + try: + notices = list_l1_notifications_for_l2(actor=actor) + except PermissionError: + return + except Exception as exc: + show_error("L1 通知讀取失敗", exc) + return + if not notices: + return + with st.container(border=True): + st.markdown(f"**📨 L1 轉來 {len(notices)} 則待確認情報**(在下方「當前全球情報分析」選取後一鍵登錄即可結案)") + for n in notices[:8]: + location = " ".join(part for part in (n["country"], n["region"]) if part) or "未填地區" + line = (f"- 【{n['event_type']}|預估 {n['impact_days']} 天】{n['title'] or '(無標題)'} — {location}" + f"|{n['notified_by'] or 'L1'} 於 {n['notified_at'] or '—'} 通知") + if n.get("note"): + line += f"|備註:{n['note']}" + st.markdown(line) + if len(notices) > 8: + st.caption(f"…另有 {len(notices) - 8} 則") + + def render_intelligence_gathering( api_key: str = "", gnews_api_key: str = "", @@ -43,6 +69,7 @@ def render_intelligence_gathering( """ st.subheader("🔍 即時全球情報與事件登錄") st.caption("透過 GNews/RSS 抓取全球供應鏈相關新聞,並利用 AI 自動偵測受影響國家、地區與事件類型(戰爭、氣候、罷工等)。") + _render_l1_handoff_notices(actor=actor) # 更新即時新聞:依供應商國家從 GNews/RSS 抓取並寫入 DB _suppliers = get_suppliers_for_map() diff --git a/frontend/components/risk_overview.py b/frontend/components/risk_overview.py index 922feb6..ee9357c 100644 --- a/frontend/components/risk_overview.py +++ b/frontend/components/risk_overview.py @@ -9,9 +9,16 @@ parse_purchase_order_csv, ) from backend.l1_monitoring import ( + ALERT_KIND_CANDIDATE, + ALERT_KIND_CONFIRMED, + ALERT_STATUS_NOTIFIED_L2, + CANDIDATE_STATUS_OPTIONS, + CONFIRMED_STATUS_OPTIONS, get_latest_event_alerts, get_latest_risk_summary, + load_open_purchase_rows, map_purchase_rows_to_events, + set_alert_status, ) from backend.supply_chain_risk import ( get_risk_events_list, @@ -97,35 +104,66 @@ def _render_latest_event_alerts(*, actor: str) -> None: if not feed["confirmed"]: st.info("此區間內尚無已登錄的供應鏈風險事件。") else: + unread = sum(1 for item in feed["confirmed"] if item["ack_status"] == "未讀") + st.caption(f"未讀 {unread} 筆 ・ 狀態改完按「儲存狀態」,重新整理不會歸零。「替代提案」為 L3 對此事件提案的核准進度。") confirmed_rows = [ { + "處理狀態": item["ack_status"], "嚴重度": _SEVERITY_ICONS.get(item["severity"], item["severity"]), "事件": item["event_type"], "國家/地區": _location_label(item), "預估延遲": f"{item['impact_days']} 天", + "替代提案": _proposal_label(item.get("proposals") or {}), "登錄時間": item["created_at"] or "未記錄", "來源": item["source"], "來源新聞": item["news_title"] or "—", "原文連結": item["news_url"] or "", "事件說明": item["description"] or "未提供", + "備註": item.get("ack_note") or "", + "_id": item["id"], } for item in feed["confirmed"] ] - st.dataframe( + edited = st.data_editor( pd.DataFrame(confirmed_rows), width="stretch", hide_index=True, + key=f"l1_confirmed_editor_{since_days}", + disabled=[c for c in confirmed_rows[0] if c not in ("處理狀態", "備註")], column_config={ + "處理狀態": st.column_config.SelectboxColumn("處理狀態", options=list(CONFIRMED_STATUS_OPTIONS), required=True), + "備註": st.column_config.TextColumn("備註", width="medium"), "原文連結": st.column_config.LinkColumn("原文連結", display_text="開啟"), + "_id": None, }, ) + changed = [ + (int(row["_id"]), row["處理狀態"], row["備註"]) + for (_, row), original in zip(edited.iterrows(), confirmed_rows) + if row["處理狀態"] != original["處理狀態"] or (row["備註"] or "") != (original["備註"] or "") + ] + if st.button(f"💾 儲存狀態({len(changed)} 筆異動)", key="l1_save_confirmed", disabled=not changed): + try: + for event_id, status, note in changed: + set_alert_status(ALERT_KIND_CONFIRMED, event_id, status, actor=actor, note=note or "") + except PermissionError: + st.error("此帳號沒有標記告警狀態的權限。") + except ValueError as exc: + st.error(str(exc)) + else: + st.toast(f"已更新 {len(changed)} 筆告警狀態", icon="💾") + st.rerun() st.markdown("**AI 偵測待確認**") if not feed["candidates"]: st.success("此區間內沒有尚未登錄的高風險情報。") else: + notified = sum(1 for item in feed["candidates"] if item["ack_status"] == ALERT_STATUS_NOTIFIED_L2) + st.caption(f"已通知 L2 {notified} 筆。勾選後按「通知 L2」,L2「情報與決策」頁頂端會列出這些情報;L2 登錄成事件後自動從這裡消失。") candidate_rows = [ { + "通知 L2": item["ack_status"] == ALERT_STATUS_NOTIFIED_L2, + "處理狀態": item["ack_status"], "嚴重度": _SEVERITY_ICONS.get(item["severity"], item["severity"]), "類型": item["event_type"], "國家/地區": _location_label(item), @@ -134,17 +172,45 @@ def _render_latest_event_alerts(*, actor: str) -> None: "新聞標題": item["title"] or "(無標題)", "原文連結": item["url"] or "", "狀態": item["status"], + "備註": item.get("ack_note") or "", + "_news_id": item["news_id"], } for item in feed["candidates"] ] - st.dataframe( + edited = st.data_editor( pd.DataFrame(candidate_rows), width="stretch", hide_index=True, + key=f"l1_candidate_editor_{since_days}", + disabled=[c for c in candidate_rows[0] if c not in ("通知 L2", "處理狀態", "備註")], column_config={ + "通知 L2": st.column_config.CheckboxColumn("通知 L2"), + "處理狀態": st.column_config.SelectboxColumn("處理狀態", options=list(CANDIDATE_STATUS_OPTIONS), required=True), + "備註": st.column_config.TextColumn("備註", width="medium"), "原文連結": st.column_config.LinkColumn("原文連結", display_text="開啟"), + "_news_id": None, }, ) + changed = [] + for (_, row), original in zip(edited.iterrows(), candidate_rows): + status = ALERT_STATUS_NOTIFIED_L2 if bool(row["通知 L2"]) else row["處理狀態"] + if status == ALERT_STATUS_NOTIFIED_L2 and not bool(row["通知 L2"]): + status = "已讀" # 取消勾選 → 退回已讀 + if status != original["處理狀態"] or (row["備註"] or "") != (original["備註"] or ""): + changed.append((int(row["_news_id"]), status, row["備註"])) + notify_count = sum(1 for _, status, _ in changed if status == ALERT_STATUS_NOTIFIED_L2) + label = f"📨 通知 L2({notify_count} 則)" if notify_count else f"💾 儲存狀態({len(changed)} 筆異動)" + if st.button(label, key="l1_save_candidates", disabled=not changed): + try: + for news_id, status, note in changed: + set_alert_status(ALERT_KIND_CANDIDATE, news_id, status, actor=actor, note=note or "") + except PermissionError: + st.error("此帳號沒有標記告警狀態的權限。") + except ValueError as exc: + st.error(str(exc)) + else: + st.toast(f"已更新 {len(changed)} 筆情報狀態", icon="📨") + st.rerun() st.caption("待確認情報需由具 L2 權限的人員在「情報與決策」頁登錄後,才會成為正式事件並進入對映。") @@ -189,31 +255,67 @@ def _render_latest_ai_summary(*, actor: str) -> None: st.caption(f"{item.get('kind')}「{item.get('name')}」{item.get('action')}:{item.get('reason')}") -def _render_read_only_mapping(events: list[dict]) -> None: +def _proposal_label(counts: dict) -> str: + parts = [] + if counts.get("approved"): + parts.append(f"✅ 核准 {counts['approved']}") + if counts.get("pending"): + parts.append(f"⏳ 待審 {counts['pending']}") + if counts.get("rejected"): + parts.append(f"❌ 拒絕 {counts['rejected']}") + return "、".join(parts) or "—" + + +def _render_read_only_mapping(events: list[dict], *, actor: str) -> None: st.markdown("#### 🔔 L1 告警與通知中心") st.caption( - "上傳資料只會在記憶體中進行格式驗證、事件對映與通知預覽," - "不會寫入 ERP 或提案暫存區。Excel 資料請先另存為 UTF-8 CSV。" - ) - st.download_button( - "下載唯讀對映 CSV 範本", - data=build_purchase_order_template_csv(), - file_name="l1_purchase_order_monitoring_template.csv", - mime="text/csv", - key="l1_monitor_download_template", + "對映只在記憶體中進行:把採購單依供應商地區比對已確認事件,產生通知預覽," + "不會寫入 ERP 或提案暫存區。" ) - uploaded = st.file_uploader( - "上傳採購資料 CSV", - type=["csv"], - key="l1_monitor_csv_upload", - help="檔案必須為 UTF-8;上傳與對映均不會修改 ERP。", + source = st.radio( + "採購資料來源", + ("系統內未結採購單", "上傳 CSV"), + horizontal=True, + key="l1_monitor_source", ) - if uploaded is None: - st.info("可下載範本後匯入採購資料,以預覽事件對映與通知結果。") - return + purchase_rows: list[dict] = [] + if source == "系統內未結採購單": + try: + purchase_rows = load_open_purchase_rows(actor=actor) + except PermissionError: + st.error("此帳號沒有讀取採購單的權限。") + return + except sqlite3.Error as exc: + show_error("採購單讀取失敗", exc) + return + if not purchase_rows: + st.info("系統內目前沒有未結採購單;可改用上傳 CSV 預覽對映。") + return + st.caption(f"讀取 {len(purchase_rows)} 條未結採購明細(即時,不需上傳)。") + else: + st.download_button( + "下載唯讀對映 CSV 範本", + data=build_purchase_order_template_csv(), + file_name="l1_purchase_order_monitoring_template.csv", + mime="text/csv", + key="l1_monitor_download_template", + ) + uploaded = st.file_uploader( + "上傳採購資料 CSV", + type=["csv"], + key="l1_monitor_csv_upload", + help="檔案必須為 UTF-8;上傳與對映均不會修改 ERP。", + ) + if uploaded is None: + st.info("可下載範本後匯入採購資料,以預覽事件對映與通知結果。") + return + try: + purchase_rows = parse_purchase_order_csv(uploaded.getvalue()) + except ValueError as exc: + st.error(f"CSV 驗證失敗:{exc}") + return try: - purchase_rows = parse_purchase_order_csv(uploaded.getvalue()) supplier_context = _load_supplier_context( {row["supplier_id"] for row in purchase_rows} ) @@ -317,4 +419,4 @@ def render_risk_overview(*, actor: str): events = [] st.markdown("
", unsafe_allow_html=True) - _render_read_only_mapping(events) + _render_read_only_mapping(events, actor=actor) diff --git a/tests/test_l1_alert_states.py b/tests/test_l1_alert_states.py new file mode 100644 index 0000000..50c6a6d --- /dev/null +++ b/tests/test_l1_alert_states.py @@ -0,0 +1,187 @@ +""" +tests/test_l1_alert_states.py +L1 風險總覽完善: + - 告警「已讀/處理中/已通知L2」狀態落地(RISK_ALERT_ACK,fail-closed) + - 告警 feed 併入狀態與 L3 提案計數 + - L1 通知 L2:待確認情報在 L2 頁列出,登錄成事件後自動消失 + - 系統內未結採購單可直接對映事件(不必上傳 CSV) +""" + +from __future__ import annotations + +import ast +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest + +from backend import database +from backend import l1_monitoring as l1 +from backend import supply_chain_risk as risk +from backend.access_control import RISK_ALERT_ACK, capabilities_for_role + + +ROOT = Path(__file__).resolve().parents[1] +NOW = datetime(2026, 9, 14, 9, 0, 0) + + +@pytest.fixture +def l1_db(tmp_path, monkeypatch): + db_path = tmp_path / "l1-states.db" + monkeypatch.setattr(database, "DB_FILE", str(db_path)) + monkeypatch.setattr(risk, "DB_FILE", str(db_path)) + monkeypatch.delenv("ERP_ENABLE_DEMO_SEED", raising=False) + database.init_db() + with sqlite3.connect(db_path) as conn: + conn.execute("DELETE FROM suppliers") + conn.execute("DELETE FROM purchase_orders") + conn.execute("DELETE FROM supply_chain_events") + conn.execute("DELETE FROM supply_chain_news") + conn.execute( + "INSERT INTO suppliers (supplier_id, name, country, region, latitude, longitude, is_official) " + "VALUES ('S-TW', '台北供應商', '台灣', '亞洲', 25.0, 121.5, 1)" + ) + conn.execute( + "INSERT INTO suppliers (supplier_id, name, country, region, latitude, longitude, is_official) " + "VALUES ('S-DE', '柏林供應商', '德國', '歐洲', 52.5, 13.4, 1)" + ) + conn.executemany( + "INSERT INTO purchase_orders (po_id, supplier_id, status, total_amount) VALUES (?,?,?,?)", + [("PO-TW", "S-TW", "已下單", 100.0), ("PO-DE", "S-DE", "運送中", 200.0), ("PO-DONE", "S-TW", "已完成", 5.0)], + ) + conn.executemany( + "INSERT INTO purchase_order_items (po_id, product_id, qty, unit_price) VALUES (?,?,?,?)", + [("PO-TW", "P1", 1, 100.0), ("PO-DE", "P2", 2, 100.0), ("PO-DONE", "P1", 1, 5.0)], + ) + conn.execute( + """INSERT INTO supply_chain_news (id, country, region, title, summary, url, source, published_at, + relevance_tag, fetched_at, category, is_relevant, estimated_delay) + VALUES (7, '台灣', '北區', 'Typhoon closes port', 's', 'https://n/7', 't', '2026-09-13 06:00', + 'supply_chain', '2026-09-13 07:00', '氣候', 1, 21)""" + ) + conn.commit() + return db_path + + +def test_alert_ack_capability_is_l1_only(): + assert RISK_ALERT_ACK in capabilities_for_role("risk_viewer") + assert RISK_ALERT_ACK in capabilities_for_role("supply_planner") + assert RISK_ALERT_ACK in capabilities_for_role("procurement_approver") + assert RISK_ALERT_ACK not in capabilities_for_role("hr") + + +def test_confirmed_alert_status_persists_and_shows_in_feed(l1_db): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + assert feed["confirmed"][0]["ack_status"] == "未讀" + assert feed["confirmed"][0]["proposals"] == {"pending": 0, "approved": 0, "rejected": 0, "unsubmitted": 0} + + record = l1.set_alert_status("confirmed", event_id, "處理中", actor="viewer", note="已通知採購", now=NOW) + assert record["alert_key"] == f"confirmed:{event_id}" + + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + item = feed["confirmed"][0] + assert item["ack_status"] == "處理中" and item["ack_note"] == "已通知採購" and item["ack_by"] == "viewer" + + # 同一鍵再標記 → 覆寫,不新增 + l1.set_alert_status("confirmed", event_id, "已讀", actor="viewer", now=NOW) + assert l1.get_alert_states("confirmed", [event_id])[event_id]["status"] == "已讀" + with sqlite3.connect(l1_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_alert_states").fetchone()[0] == 1 + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_set_alert_status_fails_closed(l1_db, actor): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + with pytest.raises(PermissionError): + l1.set_alert_status("confirmed", event_id, "已讀", actor=actor) + with sqlite3.connect(l1_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_alert_states").fetchone()[0] == 0 + + +def test_status_values_are_validated_per_kind(l1_db): + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + with pytest.raises(ValueError): + l1.set_alert_status("confirmed", event_id, "已通知L2", actor="viewer") # 已確認事件不能「通知 L2」 + with pytest.raises(ValueError): + l1.set_alert_status("candidate", 7, "處理中", actor="viewer") # 候選只有 未讀/已讀/已通知L2 + with pytest.raises(ValueError): + l1.set_alert_status("unknown", 7, "已讀", actor="viewer") + + +def test_l1_notify_l2_round_trip(l1_db): + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + assert [c["news_id"] for c in feed["candidates"]] == [7] + assert feed["candidates"][0]["ack_status"] == "未讀" + assert l1.list_l1_notifications_for_l2(actor="planner") == [] + + l1.set_alert_status("candidate", 7, "已通知L2", actor="viewer", note="請優先確認", now=NOW) + notices = l1.list_l1_notifications_for_l2(actor="planner") + assert len(notices) == 1 + assert notices[0]["news_id"] == 7 and notices[0]["title"] == "Typhoon closes port" + assert notices[0]["notified_by"] == "viewer" and notices[0]["note"] == "請優先確認" + assert notices[0]["event_type"] == "氣候" and notices[0]["impact_days"] == 21 + assert l1.get_latest_event_alerts(actor="viewer", now=NOW)["candidates"][0]["ack_status"] == "已通知L2" + + # L2 登錄成事件 → 通知自動結案、候選消失 + risk.add_risk_event("氣候", "北區", "台灣", 21, "颱風", news_id=7, actor="planner") + assert l1.list_l1_notifications_for_l2(actor="planner") == [] + assert l1.get_latest_event_alerts(actor="viewer", now=NOW)["candidates"] == [] + + +@pytest.mark.parametrize("actor", [None, "viewer", "approver", "nobody"]) +def test_l2_notification_list_requires_analysis_read(l1_db, actor): + with pytest.raises(PermissionError): + l1.list_l1_notifications_for_l2(actor=actor) + + +def test_open_purchase_rows_map_to_events_without_csv(l1_db): + risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + rows = l1.load_open_purchase_rows(actor="viewer") + assert [r["po_id"] for r in rows] == ["PO-DE", "PO-TW"] # 已完成不算 + assert rows[1] == { + "external_id": "PO-TW", "po_id": "PO-TW", "supplier_id": "S-TW", "product_id": "P1", + "qty": 1, "status": "已下單", "order_date": "", "total_amount": 100.0, + } + with sqlite3.connect(l1_db) as conn: + conn.row_factory = sqlite3.Row + suppliers = {r["supplier_id"]: dict(r) for r in conn.execute( + "SELECT supplier_id, country, region, risk_level FROM suppliers")} + events = risk.get_risk_events_list(limit=30).to_dict("records") + mapped = {m["po_id"]: m for m in l1.map_purchase_rows_to_events(rows, supplier_context=suppliers, events=events)} + assert mapped["PO-TW"]["match_status"] == "需關注" and mapped["PO-TW"]["impact_days"] == 14 + assert mapped["PO-DE"]["match_status"] == "正常" + + +@pytest.mark.parametrize("actor", [None, "", "hr1", "nobody"]) +def test_open_purchase_rows_fail_closed(l1_db, actor): + with pytest.raises(PermissionError): + l1.load_open_purchase_rows(actor=actor) + + +def test_confirmed_alert_shows_l3_proposal_counts(l1_db, monkeypatch): + from backend import purchase_proposals as pp + + event_id = risk.add_risk_event("罷工", "亞洲", "台灣", 14, "港口罷工", actor="planner") + monkeypatch.setattr(pp, "proposal_status_summary_by_event", + lambda ids, conn=None: {event_id: {"pending": 1, "approved": 2, "rejected": 0, "unsubmitted": 0}}) + feed = l1.get_latest_event_alerts(actor="viewer", now=NOW) + assert feed["confirmed"][0]["proposals"]["approved"] == 2 + + +def _calls(tree, name): + return [n for n in ast.walk(tree) if isinstance(n, ast.Call) + and ((isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name))] + + +def test_frontend_forwards_actor_for_l1_writes_and_l2_notices(): + overview = ast.parse((ROOT / "frontend/components/risk_overview.py").read_text(encoding="utf-8")) + for name in ("set_alert_status", "load_open_purchase_rows", "get_latest_event_alerts"): + calls = _calls(overview, name) + assert calls, name + assert all(any(k.arg == "actor" for k in c.keywords) for c in calls), name + dashboard = ast.parse((ROOT / "frontend/components/risk_dashboard.py").read_text(encoding="utf-8")) + calls = _calls(dashboard, "list_l1_notifications_for_l2") + assert calls and all(any(k.arg == "actor" for k in c.keywords) for c in calls) diff --git a/tests/test_tier_authorization.py b/tests/test_tier_authorization.py index 97bd378..c878c0d 100644 --- a/tests/test_tier_authorization.py +++ b/tests/test_tier_authorization.py @@ -16,6 +16,7 @@ ERP_EXCHANGE_RECONCILE, PROPOSAL_EVIDENCE_READ, RISK_ANALYSIS_READ, + RISK_ALERT_ACK, RISK_OVERVIEW_READ, RISK_WHAT_IF_RUN, capabilities_for_role, @@ -116,7 +117,8 @@ def test_demo_roles_have_context_visibility_without_inheriting_actions(): planner = capabilities_for_role("supply_planner") approver = capabilities_for_role("procurement_approver") - assert viewer == {RISK_OVERVIEW_READ} + # L1 告警確認是監控狀態、不是 ERP 寫入,viewer 仍無任何 L2/L3 能力 + assert viewer == {RISK_OVERVIEW_READ, RISK_ALERT_ACK} assert {RISK_OVERVIEW_READ, RISK_ANALYSIS_READ, RISK_WHAT_IF_RUN} <= planner assert ERP_EXCHANGE_PROPOSE in planner