From d0baf2895aa5a4ac120c2e12d4c67f06e5750c6c Mon Sep 17 00:00:00 2001 From: ewiwi Date: Sat, 12 Sep 2026 21:50:57 +0800 Subject: [PATCH 01/19] 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 5c9aa3604f0672d1c861a31b92bfe25a531f8106 Mon Sep 17 00:00:00 2001 From: weck06 Date: Sun, 13 Sep 2026 18:50:31 +0800 Subject: [PATCH 02/19] Fix first-batch risk pipeline and add isolated review environment --- .env.example | 7 +- .gitignore | 5 + app.py | 6 +- backend/agent_orchestrator.py | 5 + backend/database.py | 3 + backend/isolated_runtime.py | 78 ++++ backend/job_lock.py | 36 ++ backend/llm_client.py | 5 + backend/news_store.py | 98 +++++ backend/prompts.py | 6 +- backend/region_matching.py | 86 ++++ backend/risk_validation.py | 77 ++++ backend/scheduler.py | 155 ++++++- backend/supply_chain_news.py | 312 +++++++------- backend/supply_chain_risk.py | 510 +++++++++-------------- docs/batch1-isolated-review.md | 85 ++++ frontend/components/risk_dashboard.py | 76 ++-- frontend/components/supply_map.py | 132 ++---- scripts/run_isolated.py | 55 +++ scripts/start-isolated.ps1 | 11 + tests/conftest.py | 6 + tests/test_batch1_risk_pipeline.py | 260 ++++++++++++ tests/test_batch1_scheduler.py | 97 +++++ tests/test_batch1_ui.py | 53 +++ tests/test_prompt_p1p2.py | 18 +- tests/test_supply_chain_authorization.py | 18 +- 26 files changed, 1536 insertions(+), 664 deletions(-) create mode 100644 backend/isolated_runtime.py create mode 100644 backend/job_lock.py create mode 100644 backend/news_store.py create mode 100644 backend/region_matching.py create mode 100644 backend/risk_validation.py create mode 100644 docs/batch1-isolated-review.md create mode 100644 scripts/run_isolated.py create mode 100644 scripts/start-isolated.ps1 create mode 100644 tests/test_batch1_risk_pipeline.py create mode 100644 tests/test_batch1_scheduler.py create mode 100644 tests/test_batch1_ui.py diff --git a/.env.example b/.env.example index de37a5d..99da3d3 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,13 @@ ERP_DEMO_MODE=false # then provision user_organizations and organization_entitlements before startup. # ERP_ORGANIZATION_ID=your-organization-id -# Optional service identity for the 24-hour supply-chain news refresh. +# Supply-chain scheduler: background execution is opt-in. # The account must have risk.workspace.write; for the local demo use planner. +ERP_SCHEDULER_ENABLED=0 +ERP_SCHEDULER_INTERVAL_SECONDS=86400 +ERP_SCHEDULER_INITIAL_DELAY_SECONDS=10 +ERP_SCHEDULER_MAX_ATTEMPTS=3 +ERP_SCHEDULER_RETRY_SECONDS=30 ERP_SCHEDULER_ACTOR= # Optional: seed Agent Dashboard with synthetic demo records. Keep disabled for real data. diff --git a/.gitignore b/.gitignore index 643aace..7b69966 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,8 @@ line bot/ngrok.exe # Local AI-assistant and planning state must never enter the public repository. .claude/ .planning/ + +# Isolated batch-one runtime state +.isolated/ +*.db.news.lock +*.db.scheduler.lock diff --git a/app.py b/app.py index 391ba2d..b885aa1 100644 --- a/app.py +++ b/app.py @@ -5,12 +5,14 @@ 所有商業邏輯均位於 backend/,所有 UI 頁面均位於 frontend/ """ +import os import streamlit as st # ── 載入 .env(模型設定 LLM_MODEL / 各供應商金鑰,issue #25)──────────── try: from dotenv import load_dotenv - load_dotenv() + if os.getenv("ERP_ISOLATED_TEST") != "1": + load_dotenv() except Exception: pass @@ -30,6 +32,8 @@ # ── 頁面設定 ──────────────────────────────────────────────────────── st.set_page_config(page_title="進銷存安全系統", page_icon="🛡️", layout="wide") +if os.getenv("ERP_ISOLATED_TEST") == "1": + st.warning("隔離測試環境|固定新聞與模擬 AI|不發送通知|背景排程關閉") # ── 全域 CSS ──────────────────────────────────────────────────────── st.markdown(""" diff --git a/backend/agent_orchestrator.py b/backend/agent_orchestrator.py index c4f3a9d..cc20955 100644 --- a/backend/agent_orchestrator.py +++ b/backend/agent_orchestrator.py @@ -201,6 +201,11 @@ def _llm(messages, model=None, tools=None, temperature=0.2, json_mode=False, 用量記帳:每次成功呼叫記一列 llm_usage_logs(tokens + 成本), usage_tag 標記用途(route / agent: / aggregate / smalltalk)供歸因。 """ + if os.getenv("ERP_ISOLATED_TEST") == "1": + from types import SimpleNamespace + from .isolated_runtime import fixture_completion + msg = SimpleNamespace(content=fixture_completion(messages, usage_tag), tool_calls=None) + return SimpleNamespace(choices=[SimpleNamespace(message=msg)]) kw = {"messages": messages, "temperature": temperature} if tools: kw["tools"] = tools diff --git a/backend/database.py b/backend/database.py index 4af17ec..f39dbe5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -170,6 +170,9 @@ def init_db(): ai_summary TEXT, updated_at TEXT )''') + from .news_store import migrate as migrate_news + migrate_news(conn) + 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))''') diff --git a/backend/isolated_runtime.py b/backend/isolated_runtime.py new file mode 100644 index 0000000..665687d --- /dev/null +++ b/backend/isolated_runtime.py @@ -0,0 +1,78 @@ +"""Deterministic local fixtures and a network guard for the isolated launcher.""" +import ipaddress +import os +import json +import re +import socket + + +def block_external_network(): + if getattr(socket, "_erp_isolated", False): + return + original_connect = socket.socket.connect + original_connect_ex = socket.socket.connect_ex + original_getaddrinfo = socket.getaddrinfo + + def allowed(host): + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + def check(address): + if isinstance(address, tuple) and not allowed(address[0]): + raise OSError("Isolated ERP: external network and notifications are disabled") + + def connect(sock, address): + check(address) + return original_connect(sock, address) + + def connect_ex(sock, address): + check(address) + return original_connect_ex(sock, address) + + def getaddrinfo(host, *args, **kwargs): + if host is not None and not allowed(host): + raise OSError("Isolated ERP: external DNS is disabled") + return original_getaddrinfo(host, *args, **kwargs) + + socket.socket.connect = connect + socket.socket.connect_ex = connect_ex + socket.getaddrinfo = getaddrinfo + socket._erp_isolated = True + + +def fixture_news(country): + rows = [ + ("zero", "港口恢復營運 [ZERO]", "確認目前無延遲。"), + ("delay", "港口罷工 [DELAY]", "固定測試事件:延遲五天。"), + ("unknown", "交期尚未確認 [UNKNOWN]", "目前沒有可靠的延遲天數。"), + ("invalid", "模型輸出格式錯誤案例 [INVALID]", "保留此原文供人工檢查。"), + ] + if os.getenv("ERP_ISOLATED_SCENARIO", "mixed") == "success": + rows = [row for row in rows if row[0] != "invalid"] + return [dict(country=country, region=None, title=f"{country} {title}", summary=summary, + url=f"https://fixture.invalid/{country}/{key}", source="固定測試資料", + published_at="2026-09-13 08:00", relevance_tag="supply_chain") + for key, title, summary in rows] + + +def fixture_completion(prompt, tag): + if tag == "analysis:news_batch": + results = [] + for idx, body in re.findall(r"【新聞編號 (\d+)】\n(.*?)(?=【新聞編號|$)", str(prompt), re.S): + country = next((c for c in ("台灣", "日本", "美國", "南韓", "中國", "越南", "墨西哥", "德國", "新加坡") if body.startswith(c)), "台灣") + delay = 0 if "[ZERO]" in body else None if "[UNKNOWN]" in body else "invalid" if "[INVALID]" in body else 5 + results.append({"news_id": int(idx), "相關性": "YES", "國家": country, "地區": "不明", + "事件類型": "交通", "預計延遲": delay, "繁體中文簡要": "固定模擬分析;非即時新聞。"}) + return json.dumps({"results": results}, ensure_ascii=False) + if tag == "analysis:heatmap": + return json.dumps({"摘要": "固定測試摘要:台灣北區確認為 0%/0 天,日本為 65%/5 天。", + "更新": [{"地區": "台灣 北區", "風險": 0}, {"地區": "日本", "風險": 65}], + "事件": [{"類型": "交通", "地區": "北區", "國家": "台灣", "延遲天數": 0, "描述": "固定零值測試"}, + {"類型": "罷工", "地區": "日本", "國家": "日本", "延遲天數": 5, "描述": "固定延遲測試"}]}, ensure_ascii=False) + if tag == "analysis:po_alternative": + return '{"results": []}' + return "隔離測試環境:這是固定模擬回應,未呼叫外部模型或發送通知。" diff --git a/backend/job_lock.py b/backend/job_lock.py new file mode 100644 index 0000000..f835886 --- /dev/null +++ b/backend/job_lock.py @@ -0,0 +1,36 @@ +"""Process-wide advisory file locks, released by the OS even on process death.""" +import os +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def exclusive_job_lock(db_path, name): + path = Path(str(db_path) + f".{name}.lock") + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+b") as handle: + handle.seek(0, 2) + if handle.tell() == 0: + handle.write(b"0") + handle.flush() + handle.seek(0) + acquired = False + try: + if os.name == "nt": + import msvcrt + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + except (OSError, BlockingIOError): + pass + try: + yield acquired + finally: + if acquired: + handle.seek(0) + if os.name == "nt": + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) diff --git a/backend/llm_client.py b/backend/llm_client.py index 31261ad..d061d5f 100644 --- a/backend/llm_client.py +++ b/backend/llm_client.py @@ -19,6 +19,8 @@ def llm_available() -> bool: """是否已設定任何可用的模型供應商(.env 驅動)。""" + if os.getenv("ERP_ISOLATED_TEST") == "1": + return True return bool(os.getenv("LLM_MODEL") or os.getenv("OPENAI_API_KEY") or os.getenv("GEMINI_API_KEY")) @@ -30,6 +32,9 @@ def complete_text(prompt, system: str | None = None, temperature: float = 0.2, 單次文字補全。prompt 可為字串或 messages list。 回傳純文字(失敗拋例外,由呼叫端決定 fallback 行為)。 """ + if os.getenv("ERP_ISOLATED_TEST") == "1": + from .isolated_runtime import fixture_completion + return fixture_completion(prompt, tag) from backend.agent_orchestrator import _llm, _content from backend.prompts import PROMPT_DEFENSE_BASELINE diff --git a/backend/news_store.py b/backend/news_store.py new file mode 100644 index 0000000..594f103 --- /dev/null +++ b/backend/news_store.py @@ -0,0 +1,98 @@ +"""Raw news retention, durable analysis state and pre-analysis deduplication.""" +import hashlib +import re +import sqlite3 +import unicodedata +from datetime import datetime, timezone +from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def identity_keys(item): + url = str(item.get("url") or "").strip() + if url: + parts = urlsplit(url) + query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) + if not k.lower().startswith("utm_") and k.lower() not in {"fbclid", "gclid"}) + url = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip("/"), urlencode(query), "")) + title = re.sub(r"\s+", " ", unicodedata.normalize("NFKC", str(item.get("title") or ""))).strip().casefold() + # Different syndication URLs with the same headline/source/publication day are one story. + content = "|".join((title, str(item.get("source") or "").strip().casefold(), str(item.get("published_at") or "")[:10])) if title else "" + digest = lambda value: hashlib.sha256(value.encode("utf-8")).hexdigest() if value else None + return digest(url), digest(content) + + +def migrate(conn): + columns = {r[1] for r in conn.execute("PRAGMA table_info(supply_chain_news)")} + additions = {"analysis_status": "TEXT NOT NULL DEFAULT 'legacy_unverified'", + "analysis_error": "TEXT", "analysis_summary": "TEXT", "analysis_country": "TEXT", + "analysis_region": "TEXT", "analyzed_at": "TEXT", "url_key": "TEXT", "content_key": "TEXT"} + for name, declaration in additions.items(): + if name not in columns: + conn.execute(f"ALTER TABLE supply_chain_news ADD COLUMN {name} {declaration}") + if "estimated_delay" not in {r[1] for r in conn.execute("PRAGMA table_info(risk_heatmap)")}: + conn.execute("ALTER TABLE risk_heatmap ADD COLUMN estimated_delay INTEGER") + # Keep historical duplicates and IDs (events may reference them); index only the first owner. + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS news_url_unique ON supply_chain_news(url_key) WHERE url_key IS NOT NULL") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS news_content_unique ON supply_chain_news(content_key) WHERE content_key IS NOT NULL") + if "url_key" not in columns: + rows = conn.execute("SELECT id,title,url,source,published_at FROM supply_chain_news ORDER BY id").fetchall() + for row in rows: + uk, ck = identity_keys(dict(zip(("id", "title", "url", "source", "published_at"), row))) + for field, value in (("url_key", uk), ("content_key", ck)): + if value and not conn.execute(f"SELECT 1 FROM supply_chain_news WHERE {field}=?", (value,)).fetchone(): + conn.execute(f"UPDATE supply_chain_news SET {field}=? WHERE id=?", (value, row[0])) + conn.execute("""CREATE TABLE IF NOT EXISTS scheduled_jobs ( + job_key TEXT PRIMARY KEY, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, + started_at TEXT, finished_at TEXT, error TEXT, result_json TEXT)""") + + +def find_existing(conn, item): + uk, ck = identity_keys(item) + return conn.execute("SELECT id,analysis_status FROM supply_chain_news WHERE url_key=? OR content_key=? ORDER BY id LIMIT 1", (uk, ck)).fetchone() + + +def store_raw(conn, item): + existing = find_existing(conn, item) + if existing: + return existing[0], False + uk, ck = identity_keys(item) + if not (uk or ck): + raise ValueError("News needs a title or URL") + values = [item.get(k) for k in ("country", "region", "title", "summary", "url", "source", "published_at", "relevance_tag")] + try: + cur = conn.execute("""INSERT INTO supply_chain_news + (country,region,title,summary,url,source,published_at,relevance_tag,fetched_at, + analysis_status,is_relevant,estimated_delay,url_key,content_key) + VALUES (?,?,?,?,?,?,?,?,?,'pending',NULL,NULL,?,?)""", (*values, now(), uk, ck)) + return cur.lastrowid, True + except sqlite3.IntegrityError: + existing = find_existing(conn, item) + if existing: + return existing[0], False + raise + + +def store_analysis(conn, news_id, result): + from .risk_validation import number, text, EVENT_TYPES + success = result.get("analysis_status") == "succeeded" + delay = number(result.get("estimated_delay"), maximum=365, integer=True, nullable=True) if success else None + if success: + if type(result.get("is_relevant")) is not bool: + raise ValueError("Invalid analysis relevance") + for key in ("country", "region", "chinese_summary"): + text(result.get(key)) + if result.get("event_type") not in EVENT_TYPES: + raise ValueError("Invalid event type") + if not result["is_relevant"] and delay not in (None, 0): + raise ValueError("Irrelevant news cannot have delay") + conn.execute("""UPDATE supply_chain_news SET analysis_status=?,analysis_error=?, + analysis_summary=?,analysis_country=?,analysis_region=?,category=?,is_relevant=?, + estimated_delay=?,analyzed_at=? WHERE id=? AND analysis_status!='succeeded'""", + ("succeeded" if success else "failed", None if success else result.get("analysis_error", "invalid_output"), + result.get("chinese_summary") if success else None, result.get("country") if success else None, + result.get("region") if success else None, result.get("event_type") if success else None, + int(result["is_relevant"]) if success else None, delay, now(), news_id)) diff --git a/backend/prompts.py b/backend/prompts.py index 0afa71a..258e114 100644 --- a/backend/prompts.py +++ b/backend/prompts.py @@ -44,7 +44,7 @@ {{"地區": "<合法區域名稱>", "風險": <0-100 整數>}} ], "事件": [ - {{"類型": "<戰爭|氣候|罷工|政策|交通|其他>", "地區": "<合法區域名稱>", "國家": "<國家名>", "延遲天數": <整數>, "描述": "<一句話>"}} + {{"類型": "<戰爭|氣候|罷工|政策|交通|其他>", "地區": "<合法區域名稱>", "國家": "<國家名>", "延遲天數": <0-365 整數或 null,未知用 null,確認無延遲用 0>, "描述": "<一句話>"}} ] }}""" @@ -64,7 +64,7 @@ 【輸出格式】只輸出以下 JSON 物件(頂層必須是物件、不要其他文字),results 共 {impact_count} 筆: {{ "results": [ - {{"po_id": "<採購單號>", "延遲天數": <整數>, "建議": "<含具體國家/地區名的替代建議>"}} + {{"po_id": "<採購單號>", "延遲天數": <0-365 整數或 null,未知用 null,確認無延遲用 0>, "建議": "<含具體國家/地區名的替代建議>"}} ] }}""" @@ -110,7 +110,7 @@ "地區": "地區/城市名(務必繁體中文)", "事件類型": "戰爭, 氣候, 罷工, 政策, 交通, 其他", "繁體中文簡要": "這則新聞的 150 字內繁體中文簡要分析", - "預計延遲": <數字,不相關則填 0> + "預計延遲": <0-365 整數或 null;0 為確認無延遲,null 為未知,不可用慣例猜測缺漏資料> }} ] }} diff --git a/backend/region_matching.py b/backend/region_matching.py new file mode 100644 index 0000000..d230c49 --- /dev/null +++ b/backend/region_matching.py @@ -0,0 +1,86 @@ +"""One exact geographic contract for Python and SQLite consumers. + +Comma-separated selectors are OR; country + subregion is AND. Spaces inside +country names are preserved. Empty selectors never match. No SQL wildcards. +""" +import re + +REGION_COUNTRY_MAP = { + "亞洲": ["台灣", "日本", "中國", "南韓", "北韓", "越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "印度", "香港", "澳門", "緬甸", "柬埔寨", "寮國"], + "東亞": ["台灣", "日本", "中國", "南韓", "北韓", "香港", "澳門"], + "東南亞": ["越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "緬甸", "柬埔寨", "寮國"], + "歐洲": ["德國", "法國", "英國", "義大利", "西班牙", "荷蘭", "波蘭", "比利時", "奧地利", "瑞士"], + "北美": ["美國", "加拿大", "墨西哥"], + "中東": ["伊朗", "沙烏地阿拉伯", "阿拉伯聯合大公國", "以色列", "卡達", "伊拉克", "科威特", "約旦", "黎巴嫩", "敘利亞", "土耳其"], + "非洲": ["埃及", "南非", "摩洛哥", "奈及利亞"], +} +ALIASES = { + "臺灣": "台灣", "taiwan": "台灣", "tw": "台灣", + "japan": "日本", "jp": "日本", "united states": "美國", "usa": "美國", "us": "美國", + "韓國": "南韓", "south korea": "南韓", "korea": "南韓", "kr": "南韓", + "阿聯酋": "阿拉伯聯合大公國", "阿聯": "阿拉伯聯合大公國", "uae": "阿拉伯聯合大公國", + "china": "中國", "vietnam": "越南", "germany": "德國", "united kingdom": "英國", + "singapore": "新加坡", "canada": "加拿大", "mexico": "墨西哥", +} + + +def normalize(value): + value = re.sub(r"\s+", " ", str(value or "")).strip().casefold() + return ALIASES.get(value, value) + + +def _parts(value): + return [v.strip() for v in re.split(r"[,,、;;]", str(value or "")) if v.strip()] + + +def split_location(value): + value = str(value or "").strip() + if "|" in value: + c, r = value.split("|", 1) + return normalize(c), normalize(r) + known = set(ALIASES) | set(ALIASES.values()) | set(REGION_COUNTRY_MAP) + known.update(c for countries in REGION_COUNTRY_MAP.values() for c in countries) + for c in sorted(known, key=len, reverse=True): + if value.casefold().startswith(c.casefold() + " "): + return normalize(c), normalize(value[len(c):]) + return "", normalize(value) + + +def _country_match(selector, country): + return selector == country or country in REGION_COUNTRY_MAP.get(selector, []) + + +def matches_location(country, region, selector_region=None, selector_country=None): + country, region = normalize(country), normalize(region) + countries = [normalize(c) for c in _parts(selector_country)] + regions = _parts(selector_region) + if not countries and not regions: + return False + if countries and not any(_country_match(c, country) for c in countries): + return False + if not regions: + return True + for value in regions: + exact = normalize(value) + if exact in {country, region, f"{country} {region}", f"{country}|{region}"}: + return True + c, r = split_location(value) + if c: + if _country_match(c, country) and (r == c or r == region): + return True + elif _country_match(r, country) or r == region: + return True + return False + + +def expanded_region_where(region, country, prefix=""): + if prefix not in ("", "s.", "p.", "c."): + raise ValueError("Unsupported SQL alias") + return [f"erp_region_matches({prefix}country, {prefix}region, ?, ?) = 1"], [region, country] + + +def connect_db(path): + import sqlite3 + conn = sqlite3.connect(path) + conn.create_function("erp_region_matches", 4, matches_location, deterministic=True) + return conn diff --git a/backend/risk_validation.py b/backend/risk_validation.py new file mode 100644 index 0000000..4fac1b6 --- /dev/null +++ b/backend/risk_validation.py @@ -0,0 +1,77 @@ +"""Fail-closed validators: zero is a measurement, None is unknown.""" +import json +import math +import numbers +import re + +EVENT_TYPES = {"戰爭", "氣候", "罷工", "政策", "交通", "其他", "地震", "天候", "政治", "疫情"} + + +def number(value, *, maximum, integer=False, nullable=False): + if value is None and nullable: + return None + if isinstance(value, bool) or not isinstance(value, numbers.Real): + raise ValueError("Expected a JSON number") + value = float(value) + if not math.isfinite(value) or not 0 <= value <= maximum: + raise ValueError("Number out of range") + if integer and not value.is_integer(): + raise ValueError("Expected an integer") + return int(value) if integer else value + + +def text(value): + if not isinstance(value, str): + raise ValueError("Expected text") + return value.strip() + + +def json_payload(raw): + raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip()) + def unique_object(pairs): + result = {} + for k, v in pairs: + if k in result: + raise ValueError("Duplicate JSON key") + result[k] = v + return result + return json.loads(raw, object_pairs_hook=unique_object, + parse_constant=lambda x: (_ for _ in ()).throw(ValueError("Non-finite JSON"))) + + +def failed_analysis(code="invalid_output"): + return dict(analysis_status="failed", analysis_error=code, is_relevant=None, + country="", region="", event_type=None, estimated_delay=None, chinese_summary=None) + + +def parse_news_batch(raw, count): + payload = json_payload(raw) + rows = payload.get("results") if isinstance(payload, dict) else payload + if not isinstance(rows, list): + raise ValueError("Expected results array") + results = [failed_analysis("missing_result") for _ in range(count)] + seen = set() + for row in rows: + if not isinstance(row, dict): + raise ValueError("Expected result object") + idx = row.get("news_id") + if type(idx) is not int or not 0 <= idx < count or idx in seen: + raise ValueError("Invalid or duplicate news_id") + seen.add(idx) + try: + if row["相關性"] not in ("YES", "NO"): + raise ValueError("Invalid relevance") + etype = text(row["事件類型"]) + if etype not in EVENT_TYPES: + raise ValueError("Invalid event type") + country, region = text(row["國家"]), text(row["地區"]) + delay = number(row["預計延遲"], maximum=365, integer=True, nullable=True) + if row["相關性"] == "NO" and delay not in (None, 0): + raise ValueError("Irrelevant news cannot have delay") + results[idx] = dict(analysis_status="succeeded", analysis_error=None, + is_relevant=row["相關性"] == "YES", country="" if country == "不明" else country, + region="" if region == "不明" else region, event_type=etype, + estimated_delay=delay, chinese_summary=text(row["繁體中文簡要"])) + except (KeyError, ValueError, TypeError): + results[idx] = failed_analysis() + return results diff --git a/backend/scheduler.py b/backend/scheduler.py index bb61a7d..1efefe3 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -4,6 +4,11 @@ """ import os +import json +import sqlite3 +import logging +from dataclasses import dataclass +from datetime import datetime, timezone import threading import time from backend.access_control import RISK_WORKSPACE_WRITE, require_capability @@ -35,29 +40,135 @@ def refresh_supply_chain_news_once(*, actor: str) -> dict: actor=actor, ) +@dataclass(frozen=True) +class SchedulerConfig: + actor: str + enabled: bool = False + interval_seconds: int = 86400 + initial_delay_seconds: int = 10 + max_attempts: int = 3 + retry_seconds: int = 30 + + def __post_init__(self): + if self.interval_seconds < 1 or self.initial_delay_seconds < 0 or self.max_attempts < 1 or self.retry_seconds < 0: + raise ValueError("Invalid scheduler timing or retry configuration") + + @classmethod + def from_env(cls): + return cls(actor=os.getenv("ERP_SCHEDULER_ACTOR", "").strip(), + enabled=os.getenv("ERP_SCHEDULER_ENABLED", "0") == "1", + interval_seconds=int(os.getenv("ERP_SCHEDULER_INTERVAL_SECONDS", "86400")), + initial_delay_seconds=int(os.getenv("ERP_SCHEDULER_INITIAL_DELAY_SECONDS", "10")), + max_attempts=int(os.getenv("ERP_SCHEDULER_MAX_ATTEMPTS", "3")), + retry_seconds=int(os.getenv("ERP_SCHEDULER_RETRY_SECONDS", "30"))) + + +def run_scheduled_refresh(config=None, *, job_key=None, wait=None): + """Explicit one-shot entry, with cross-process locking and durable idempotency. + + Same key + success => skip. Failures retry up to max_attempts per invocation; + an explicit rerun of a failed key is allowed. OS lock permits crash recovery. + """ + from .database import DB_FILE + from .job_lock import exclusive_job_lock + config = config or SchedulerConfig.from_env() + require_capability(config.actor, RISK_WORKSPACE_WRITE) + job_key = job_key or f"news:{int(time.time()) // config.interval_seconds}" + wait = wait or time.sleep + with exclusive_job_lock(DB_FILE, "scheduler") as acquired: + if not acquired: + return {"status": "busy", "job_key": job_key} + with sqlite3.connect(DB_FILE) as conn: + row = conn.execute("SELECT status FROM scheduled_jobs WHERE job_key=?", (job_key,)).fetchone() + if row and row[0] == "succeeded": + return {"status": "skipped", "job_key": job_key} + conn.execute("INSERT OR IGNORE INTO scheduled_jobs(job_key,status) VALUES (?,'pending')", (job_key,)) + for attempt in range(config.max_attempts): + with sqlite3.connect(DB_FILE) as conn: + conn.execute("UPDATE scheduled_jobs SET status='running',attempts=attempts+1,started_at=?,finished_at=NULL,error=NULL WHERE job_key=?", (_utcnow(), job_key)) + result = None + try: + result = refresh_supply_chain_news_once(actor=config.actor) + if result.get("status") in {"busy", "partial_failure", "pending_analysis"}: + raise RuntimeError(result["status"]) + except Exception as exc: + error = type(exc).__name__ # Do not persist credentials/provider payloads. + with sqlite3.connect(DB_FILE) as conn: + conn.execute("UPDATE scheduled_jobs SET status='failed',finished_at=?,error=?,result_json=? WHERE job_key=?", (_utcnow(), error, json.dumps(result, ensure_ascii=False), job_key)) + if isinstance(exc, PermissionError) or attempt + 1 >= config.max_attempts: + return {"status": "failed", "job_key": job_key, "error": error} + if wait(config.retry_seconds): + return {"status": "cancelled", "job_key": job_key} + else: + with sqlite3.connect(DB_FILE) as conn: + conn.execute("UPDATE scheduled_jobs SET status='succeeded',finished_at=?,error=NULL,result_json=? WHERE job_key=?", (_utcnow(), json.dumps(result, ensure_ascii=False), job_key)) + return {"status": "succeeded", "job_key": job_key, "result": result} + + +def _utcnow(): + return datetime.now(timezone.utc).isoformat() + + +_start_lock = threading.Lock() +_stop_event = threading.Event() +_job_thread = None + + def start_background_jobs(): - global _scheduler_started - if _scheduler_started: - return True - actor = os.getenv("ERP_SCHEDULER_ACTOR", "").strip() - if not actor: - print("Background scheduler disabled: ERP_SCHEDULER_ACTOR is not configured.") + global _scheduler_started, _job_thread + config = SchedulerConfig.from_env() + if not config.enabled or not config.actor or os.getenv("ERP_ISOLATED_TEST") == "1": return False - _scheduler_started = True + require_capability(config.actor, RISK_WORKSPACE_WRITE) + with _start_lock: + if _scheduler_started: + return True + _stop_event.clear() - def run_jobs(): - while True: + def run_jobs(): + global _scheduler_started try: - # 每天定時抓取一次新聞 (每 24 小時) - time.sleep(10) # 系統啟動後延遲 10 秒再抓 - refresh_supply_chain_news_once(actor=actor) - except Exception as e: - print(f"Background scheduler error: {e}") - - # 休息 24 小時 (可以視需求調整頻率) - time.sleep(24 * 60 * 60) - - # 設定為 Daemon Thread,讓主程式結束時能隨之關閉 - job_thread = threading.Thread(target=run_jobs, daemon=True) - job_thread.start() - return True + if _stop_event.wait(config.initial_delay_seconds): + return + while not _stop_event.is_set(): + try: + run_scheduled_refresh(config, wait=_stop_event.wait) + except Exception: + logging.exception("Scheduled news refresh failed") + if _stop_event.wait(config.interval_seconds): + return + finally: + _scheduler_started = False + + _job_thread = threading.Thread(target=run_jobs, name="erp-news-scheduler", daemon=True) + _scheduler_started = True + _job_thread.start() + return True + + +def stop_background_jobs(): + _stop_event.set() + if _job_thread: + _job_thread.join(timeout=5) + + +def main(): + import argparse + from .database import init_db + parser = argparse.ArgumentParser(description="Explicit supply-chain news refresh") + parser.add_argument("--once", action="store_true", required=True) + parser.add_argument("--job-key", help="Stable idempotency key; defaults to UTC interval bucket") + args = parser.parse_args() + if not os.getenv("ERP_DB_PATH"): + parser.error("Explicit ERP_DB_PATH is required") + if os.getenv("ERP_ISOLATED_TEST") == "1": + from .isolated_runtime import block_external_network + block_external_network() + init_db() + result = run_scheduled_refresh(job_key=args.job_key) + print(json.dumps(result, ensure_ascii=False)) + return 1 if result["status"] in {"failed", "busy", "cancelled"} else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/supply_chain_news.py b/backend/supply_chain_news.py index 9634c59..c98477e 100644 --- a/backend/supply_chain_news.py +++ b/backend/supply_chain_news.py @@ -45,6 +45,8 @@ def _get_db(): def _get_gnews_api_key() -> Optional[str]: """從環境變數或 Streamlit secrets 取得 GNews API Key(選填)。""" + if os.getenv("ERP_ISOLATED_TEST") == "1": + return None key = os.environ.get("GNEWS_API_KEY", "").strip() if key: return key @@ -69,10 +71,10 @@ def _fetch_via_gnews_api(country_name: str, api_key: str, max_results: int = 10, # 關鍵字:擴充相關範疇確保不漏抓 query = f"{name_en} (supply chain OR logistics OR shipping OR export OR tariff OR strike OR port OR pandemic OR war OR shortage OR conflict OR disruption OR natural disaster)" url = "https://gnews.io/api/v4/search" - + # 產出 GNews API 格式的時間 (YYYY-MM-DDTHH:mm:SSZ) from_date = (datetime.now() - timedelta(days=within_days)).strftime("%Y-%m-%dT00:00:00Z") - + params = { "q": query, "max": max_results, @@ -104,8 +106,8 @@ def _fetch_via_gnews_api(country_name: str, api_key: str, max_results: int = 10, "relevance_tag": "supply_chain", }) return out - except Exception: - return [] + except Exception as exc: + raise RuntimeError("News provider request failed") from exc def _fetch_via_rss(country_name: str, max_results: int = 15, within_days: int = 7) -> List[dict]: @@ -135,7 +137,7 @@ def _fetch_via_rss(country_name: str, max_results: int = 15, within_days: int = if summary: summary = re.sub(r"<[^>]+>", "", summary)[:500] pub_date_raw = item.find("pubDate").text if item.find("pubDate") is not None else "" - + # 標準化日期格式 (RFC 2822 -> ISO) pub_date_iso = "" try: @@ -156,8 +158,8 @@ def _fetch_via_rss(country_name: str, max_results: int = 15, within_days: int = "relevance_tag": "supply_chain", }) return out - except Exception: - return [] + except Exception as exc: + raise RuntimeError("News provider request failed") from exc def fetch_country_news(country_name: str, api_key: Optional[str] = None, max_results: int = 10, within_days: int = 7) -> List[dict]: @@ -165,182 +167,152 @@ def fetch_country_news(country_name: str, api_key: Optional[str] = None, max_res 取得指定國家可能影響銷售或出貨的即時新聞。 若有 GNews API Key 則優先使用 API,否則使用 Google News RSS。 """ + if os.getenv("ERP_ISOLATED_TEST") == "1": + from .isolated_runtime import fixture_news + return fixture_news(country_name)[:max_results] if api_key: - items = _fetch_via_gnews_api(country_name, api_key, max_results, within_days) - if items: - return items + try: + items = _fetch_via_gnews_api(country_name, api_key, max_results, within_days) + if items: + return items + except RuntimeError: + pass return _fetch_via_rss(country_name, max_results, within_days) def save_news_to_db(items: List[dict]) -> int: - """將新聞寫入 supply_chain_news 表。""" - if not items: - return 0 - db = _get_db() - conn = sqlite3.connect(db) - now = datetime.now().strftime("%Y-%m-%d %H:%M") - n = 0 - for it in items: - # 只存入相關的新聞 (Filter irrelevant already done in refresh_news or here) - if not it.get("is_relevant", True): - continue - try: - conn.execute( - """INSERT INTO supply_chain_news (country, region, title, summary, url, source, published_at, relevance_tag, fetched_at, category, is_relevant, estimated_delay) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - it.get("country") or "", - it.get("region"), - (it.get("title") or "")[:500], - (it.get("summary") or "")[:1000], - (it.get("url") or "")[:500], - (it.get("source") or "")[:100], - it.get("published_at"), - it.get("relevance_tag"), - now, - it.get("category"), - 1 if it.get("is_relevant", True) else 0, - it.get("estimated_delay") or 0, - ), - ) - n += 1 - except Exception: - continue - conn.commit() - conn.close() - return n + """Retain raw items once; analysis fields are stored separately.""" + from .news_store import store_raw, store_analysis + added = 0 + with sqlite3.connect(_get_db()) as conn: + for item in items: + news_id, created = store_raw(conn, item) + added += created + if "analysis_status" in item: + store_analysis(conn, news_id, item) + return added -def get_news_from_db( - country: Optional[str] = None, - limit: int = 50, - order_by_latest: bool = True, - within_days: Optional[int] = None, -) -> List[dict]: - """從資料庫讀取已快取的新聞。order_by_latest=True 依發布/取得時間取最近最新;within_days=30 僅取近 N 天內。""" - db = _get_db() - conn = sqlite3.connect(db) - conn.row_factory = sqlite3.Row - order = "ORDER BY COALESCE(published_at, fetched_at) DESC, id DESC LIMIT ?" - date_filter = "" - params_where = [] +def get_news_from_db(country=None, limit=50, order_by_latest=True, within_days=None, + *, analyzed_only=False) -> List[dict]: + """Raw content plus explicit analysis fields. Only successful relevant rows feed AI.""" + clauses, params = [], [] + if country: + clauses.append("country=?") + params.append(country) if within_days is not None and within_days > 0: - date_filter = " AND date(COALESCE(published_at, fetched_at)) >= date('now', ?) " - params_where.append(f"-{int(within_days)} days") - if country: - params = [country] + params_where + [limit] - rows = conn.execute( - f"""SELECT id, country, region, title, summary, url, source, published_at, relevance_tag, fetched_at, category, estimated_delay - FROM supply_chain_news WHERE country = ?{date_filter}{order}""", - params, - ).fetchall() - else: - params = params_where + [limit] - rows = conn.execute( - f"""SELECT id, country, region, title, summary, url, source, published_at, relevance_tag, fetched_at, category, estimated_delay - FROM supply_chain_news WHERE 1=1{date_filter}{order}""", - params, - ).fetchall() - conn.close() - return [dict(r) for r in rows] + clauses.append("date(COALESCE(NULLIF(published_at,''),fetched_at)) >= date('now',?)") + params.append(f"-{int(within_days)} days") + if analyzed_only: + clauses.append("analysis_status='succeeded' AND is_relevant=1") + order = "DESC" if order_by_latest else "ASC" + with sqlite3.connect(_get_db()) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute(f"SELECT * FROM supply_chain_news WHERE {' AND '.join(clauses) or '1=1'} ORDER BY COALESCE(NULLIF(published_at,''),fetched_at) {order}, id {order} LIMIT ?", (*params, limit)).fetchall() + return [dict(row) for row 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 歸類以極大化提升效能。 - """ +def refresh_news_for_countries(countries, gemini_api_key=None, gnews_api_key=None, + max_per_country=15, within_days=7, gemini_model="gemini-2.5-flash", *, actor=None): + from .job_lock import exclusive_job_lock require_capability(actor, RISK_WORKSPACE_WRITE) - import concurrent.futures + with exclusive_job_lock(_get_db(), "news") as acquired: + if not acquired: + return {"status": "busy", "saved_count": 0, "updated": 0} + return _refresh(countries, gnews_api_key, max_per_country, within_days, actor) + + +def _refresh(countries, gnews_api_key, max_per_country, within_days, actor): + from .news_store import store_raw, store_analysis from .supply_chain_risk import batch_infer_affected_region_from_news from .llm_client import llm_available - - # issue #27:AI 歸類/熱圖摘要改由 .env 模型設定驅動(gemini_api_key 參數棄用) + from .risk_validation import failed_analysis + from .region_matching import normalize + countries = list(dict.fromkeys(normalize(c) for c in countries if str(c or "").strip())) ai_enabled = llm_available() g_key = gnews_api_key or _get_gnews_api_key() - used_gnews = bool(g_key) - by_country = {} - total_saved = 0 - total_fetched = 0 - - # 1. 平行抓取各國原始新聞 (I/O Bound) - def fetch_job(c): - return c, fetch_country_news(c, api_key=g_key, max_results=max_per_country, within_days=within_days) - - all_raw_items = [] - with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(countries), 10)) as executor: - futures = [executor.submit(fetch_job, c) for c in countries] - for future in concurrent.futures.as_completed(futures): - country, items = future.result() - if items: - total_fetched += len(items) - all_raw_items.append((country, items)) - - # 2. 批量進行 AI 分析 + result = dict(status="succeeded", fetched_count=0, saved_count=0, updated=0, + duplicate_count=0, analyzed_count=0, failed_count=0, pending_count=0, + filtered_count=0, fetch_failed_count=0, by_country={}, used_api=bool(g_key)) + processed = set() + import concurrent.futures + fetched = {} + if countries: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(10, len(countries))) as pool: + jobs = {pool.submit(fetch_country_news, c, api_key=g_key, max_results=max_per_country, within_days=within_days): c for c in countries} + for future in concurrent.futures.as_completed(jobs): + c = jobs[future] + try: + fetched[c] = future.result() + except Exception: + result["fetch_failed_count"] += 1 + result["by_country"][c] = 0 + for country in countries: + items = fetched.get(country, []) + result["fetched_count"] += len(items) + pending = [] + result["by_country"][country] = 0 + require_capability(actor, RISK_WORKSPACE_WRITE) + with sqlite3.connect(_get_db()) as conn: + for item in items: + news_id, created = store_raw(conn, item) + result["saved_count"] += int(created) + result["by_country"][country] += int(created) + result["duplicate_count"] += int(not created) + status = conn.execute("SELECT analysis_status FROM supply_chain_news WHERE id=?", (news_id,)).fetchone()[0] + if news_id not in processed and status != "succeeded": + raw = conn.execute("SELECT title,summary FROM supply_chain_news WHERE id=?", (news_id,)).fetchone() + pending.append((news_id, f"{raw[0] or ''}\n{raw[1] or ''}")) + processed.add(news_id) + if not ai_enabled: + result["pending_count"] += len(pending) + continue + if pending: + inferred = batch_infer_affected_region_from_news(news_texts=[p[1] for p in pending]) + require_capability(actor, RISK_WORKSPACE_WRITE) + with sqlite3.connect(_get_db()) as conn: + for i, (news_id, _) in enumerate(pending): + analysis = inferred[i] if i < len(inferred) else failed_analysis("missing_result") + store_analysis(conn, news_id, analysis) + if analysis.get("analysis_status") == "succeeded": + result["analyzed_count"] += 1 + result["filtered_count"] += int(analysis["is_relevant"] is False) + else: + result["failed_count"] += 1 + # Include retained failures/pending rows even if the next provider fetch omits them. + with sqlite3.connect(_get_db()) as conn: + exclusions = ",".join("?" for _ in processed) or "NULL" + clause = f"AND id NOT IN ({exclusions})" if processed else "" + backlog = conn.execute(f"SELECT id,title,summary FROM supply_chain_news WHERE analysis_status IN ('pending','failed') {clause} ORDER BY COALESCE(analyzed_at,fetched_at),id LIMIT 100", sorted(processed)).fetchall() + if ai_enabled and backlog: + require_capability(actor, RISK_WORKSPACE_WRITE) + inferred = batch_infer_affected_region_from_news(news_texts=[f"{r[1] or ''}\n{r[2] or ''}" for r in backlog]) + require_capability(actor, RISK_WORKSPACE_WRITE) + with sqlite3.connect(_get_db()) as conn: + for i, row in enumerate(backlog): + analysis = inferred[i] if i < len(inferred) else failed_analysis("missing_result") + store_analysis(conn, row[0], analysis) + result["analyzed_count" if analysis.get("analysis_status") == "succeeded" else "failed_count"] += 1 + with sqlite3.connect(_get_db()) as conn: + result["remaining_analysis_count"] = conn.execute("SELECT COUNT(*) FROM supply_chain_news WHERE analysis_status IN ('pending','failed')").fetchone()[0] + result["updated"] = result["saved_count"] + if result["failed_count"] or result["fetch_failed_count"]: + result["status"] = "partial_failure" + elif result["remaining_analysis_count"]: + result["status"] = "pending_analysis" if ai_enabled: - for country, items in all_raw_items: - texts = [f"{it.get('title', '')}\n{it.get('summary', '')}" for it in items] - inferred_list = batch_infer_affected_region_from_news(news_texts=texts) - - relevant_items = [] - for it, inferred in zip(items, inferred_list): - # 如果不相關,或者 AI 推估延遲為 0 天,則視為無影響而不抓取 - if not inferred.get("is_relevant", True) or int(inferred.get("estimated_delay") or 0) <= 0: - continue - - it["is_relevant"] = True - it["category"] = inferred.get("event_type", "其他") - it["estimated_delay"] = int(inferred.get("estimated_delay") or 0) - - if inferred.get("country"): - it["country"] = inferred["country"] - if inferred.get("region"): - it["region"] = inferred["region"] - if inferred.get("chinese_summary"): - it["summary"] = inferred["chinese_summary"] - relevant_items.append(it) - - n = save_news_to_db(relevant_items) - by_country[country] = n - total_saved += n - else: - # 無 API Key 時僅存入 - for country, items in all_raw_items: - n = save_news_to_db(items) - by_country[country] = n - total_saved += n - - # 進行熱圖自動更新 (AI Heatmap Update) - if ai_enabled: - 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) - if updates: - apply_heatmap_updates(updates, summary_text, actor=actor) - except PermissionError: - raise - except Exception: - pass - - return { - "updated": total_saved, - "fetched_count": total_fetched, - "saved_count": total_saved, - "filtered_count": total_fetched - total_saved, - "by_country": by_country, - "used_api": used_gnews - } + from .supply_chain_risk import get_heatmap_ai_analysis, apply_heatmap_updates, build_heatmap_review_rows, get_risk_heatmap_data + eligible = get_news_from_db(limit=25, within_days=30, analyzed_only=True) + result["heatmap_status"] = "no_valid_news" + if eligible: + context = "\n".join(f"{n['title']} {n.get('analysis_summary') or ''} [delay={n.get('estimated_delay')}]" for n in eligible) + heatmap = get_heatmap_ai_analysis(news_context=context, reference_date=datetime.now().strftime("%Y-%m-%d")) + summary, updates, events = heatmap["summary"], heatmap["updates"], heatmap["events"] + if heatmap["analysis_status"] != "succeeded": + result["heatmap_status"] = "failed" + result["status"] = "partial_failure" + else: + review = build_heatmap_review_rows(updates, events, get_risk_heatmap_data()) + apply_heatmap_updates([dict(display_name=r["地區"],risk_pct=r["預估風險 (%)"],estimated_delay=r["預估延遲 (天)"]) for r in review], summary, actor=actor) + result["heatmap_status"] = "succeeded" + return result diff --git a/backend/supply_chain_risk.py b/backend/supply_chain_risk.py index bcbf9a3..da39a11 100644 --- a/backend/supply_chain_risk.py +++ b/backend/supply_chain_risk.py @@ -41,75 +41,11 @@ "新加坡": (1.3521, 103.8198), } -# 區域與國家映射表:當事件標記為「中東」時,自動影響該區域內的所有國家。 -_REGION_COUNTRY_MAP = { - "中東": ["伊朗", "沙烏地阿拉伯", "阿聯酋", "以色列", "卡達", "伊拉克", "科威特", "約旦", "黎巴嫩", "敘利亞"], - "東亞": ["台灣", "日本", "中國", "南韓", "北韓", "香港", "澳門"], - "東南亞": ["越南", "泰國", "新加坡", "菲律賓", "馬來西亞", "印尼", "緬甸"], - "北美": ["美國", "加拿大", "墨西哥"], - "非洲": ["埃及", "南非", "摩洛哥", "奈及利亞"] -} - -def _get_expanded_region_where(region, country_val, prefix=""): - """ - 擴展區域篩選邏輯:支援逗號分隔的多個國家/地區。 - 若輸入包含大區域名稱(如「中東」),則自動擴展為該區域下所有國家的 OR 條件。 - 【修正】當 country 與 region 同時指定且皆為單一值時,使用 AND 精確比對, - 避免台灣北區事件誤擴展至台灣中區/南區。 - """ - # 精確節點比對:若 country 與 region 皆提供且為單一值,直接回傳 AND 查詢 - c_single = (country_val or "").strip() if country_val and "," not in str(country_val) and "," not in str(country_val) else "" - r_single = (region or "").strip() if region and "," not in str(region) and "," not in str(region) else "" - # 若 region 以 country 為前綴(如 "台灣 北區"),去掉前綴只保留地區部分("北區") - if c_single and r_single and r_single.startswith(c_single): - r_single = r_single[len(c_single):].strip() - # 只有當 region 確實指向子地區(不為空、且不等於 country)才使用 AND 精確查詢 - if c_single and r_single and r_single != c_single and c_single not in _REGION_COUNTRY_MAP: - # 使用精確 AND 比對確保只選該特定節點 - return [f"({prefix}country LIKE ? AND {prefix}region LIKE ?)"], [f"%{c_single}%", f"%{r_single}%"] - - where_sub = [] - params_sub = [] - - # 解析輸入:支援「美國, 伊朗」或「北美, 中東」或「台灣 北區」 - input_names = [] - if region: - # 將全型逗號轉半型,且將空格也視為分隔符(若非大區域關鍵字) - raw_names = str(region).replace(",", ",").split(",") - for r in raw_names: - if r.strip(): - # 特殊處理:如果有空格且不是已定義的大區域,則拆分 - if " " in r.strip() and r.strip() not in _REGION_COUNTRY_MAP: - input_names.extend([p.strip() for p in r.strip().split() if p.strip()]) - else: - input_names.append(r.strip()) - - if country_val: - input_names.extend([n.strip() for n in str(country_val).replace(",", ",").split(",") if n.strip()]) - - if not input_names: - return [], [] - - # 展開大區域並收集所有目標關鍵字 - target_set = set() - for name in input_names: - target_set.add(name) - # 檢查是否為大區域 - if name in _REGION_COUNTRY_MAP: - for c in _REGION_COUNTRY_MAP[name]: - target_set.add(c) - - # 產生內容包含其中任一關鍵字的 OR 條件 (LIKE 查詢) - conditions = [] - for c in sorted(list(target_set)): - conditions.append(f"{prefix}country LIKE ?") - conditions.append(f"{prefix}region LIKE ?") - params_sub.extend([f"%{c}%", f"%{c}%"]) - - if conditions: - where_sub.append(f"({' OR '.join(conditions)})") - - return where_sub, params_sub +from .region_matching import ( + REGION_COUNTRY_MAP, matches_location, split_location, connect_db, normalize, + expanded_region_where as _get_expanded_region_where, +) +from .risk_validation import number, text, json_payload, failed_analysis, parse_news_batch, EVENT_TYPES def _fill_coords_from_country(df, country_col="country", lat_col="latitude", lon_col="longitude"): @@ -123,7 +59,7 @@ def _fill_coords_from_country(df, country_col="country", lat_col="latitude", lon df[lon_col] = pd.NA for idx, row in df.iterrows(): if pd.isna(row.get(lat_col)) or pd.isna(row.get(lon_col)): - country = (row.get(country_col) or "").strip() + country = normalize(row.get(country_col)) if country and country in _COUNTRY_DEFAULT_COORDS: lat, lon = _COUNTRY_DEFAULT_COORDS[country] df.at[idx, lat_col], df.at[idx, lon_col] = lat, lon @@ -132,7 +68,7 @@ def _fill_coords_from_country(df, country_col="country", lat_col="latitude", lon def get_suppliers_for_map(): """取得正式供應商清單(含經緯度、國家、地區、風險等級),供地圖與清單使用。經緯度僅後端使用。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read("SELECT supplier_id, name, country, region, latitude, longitude, risk_level FROM suppliers WHERE is_official=1", conn) conn.close() return _fill_coords_from_country(df) @@ -140,7 +76,7 @@ def get_suppliers_for_map(): def get_customers_for_map(): """取得客戶清單(含經緯度、國家、地區、風險等級),供地圖與清單使用。經緯度僅後端使用。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) try: df = __pd_read("SELECT customer_id, name, country, region, latitude, longitude, risk_level FROM customers", conn) except Exception: @@ -149,11 +85,16 @@ def get_customers_for_map(): return _fill_coords_from_country(df) +_VALID_EVENT_SOURCE = """(news_id IS NULL OR news_id IN ( + SELECT id FROM supply_chain_news WHERE analysis_status='succeeded' + AND is_relevant=1 AND estimated_delay IS NOT NULL))""" + + def get_recent_events_for_delay(limit=50): """取得近期供應鏈事件,供地圖判定出貨延遲狀況。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( - "SELECT event_type, region, country, impact_days FROM supply_chain_events ORDER BY id DESC LIMIT ?", + f"SELECT event_type, region, country, impact_days FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} ORDER BY id DESC LIMIT ?", conn, params=(limit,), ) @@ -165,7 +106,7 @@ def get_region_procurement_share(): """依地區彙總採購金額,計算各地區採購佔比(該地區供應商之採購額 / 全公司採購額)。 回傳 list of dict: region_key, display_name, procurement_ratio (0~1), total_amount, supplier_count。 用於初始熱圖:採購佔比愈高,集中度風險愈高,可對應風險低/中/高。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) total = __pd_read( "SELECT COALESCE(SUM(total_amount), 0) as tot FROM purchase_orders WHERE total_amount IS NOT NULL AND total_amount > 0", conn, @@ -218,15 +159,6 @@ def get_region_procurement_share(): # # 【廣域地區對應】當 AI 建議的更新地區為廣域名稱(如「亞洲」)時, # apply_heatmap_updates 需將其對應到該區所有國家之熱點一併更新。 -REGION_COUNTRY_MAP = { - "亞洲": ["台灣", "日本", "中國", "南韓", "北韓", "越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "印度", "香港", "澳門"], - "東亞": ["台灣", "日本", "中國", "南韓", "北韓", "香港", "澳門"], - "東南亞": ["越南", "泰國", "新加坡", "馬來西亞", "印尼", "菲律賓", "緬甸", "柬埔寨", "寮國"], - "歐洲": ["德國", "法國", "英國", "義大利", "西班牙", "荷蘭", "波蘭", "比利時", "奧地利", "瑞士"], - "北美": ["美國", "加拿大", "墨西哥"], - "中東": ["以色列", "沙烏地阿拉伯", "阿拉伯聯合大公國", "伊朗", "伊拉克", "土耳其", "約旦", "黎巴嫩"], -} - def get_risk_heatmap_data(): """ 取得熱圖資料:永遠以「供應商據點」為基礎產出完整熱點清單,再以 risk_heatmap 表覆寫風險%與摘要。 @@ -252,13 +184,12 @@ def get_risk_heatmap_data(): 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): + if matches_location(country, region, ev.get("region"), ev.get("country")) and (ev.get("impact_days") or 0) > 0: risk = min(100, risk + 40) break for k, v in region_scores.items(): - if k in region or k in country: + if matches_location(country, region, k): risk = max(risk, min(100, v)) - break if key in procurement_by_region: ratio = procurement_by_region[key]["procurement_ratio"] if ratio >= 0.35: @@ -278,11 +209,12 @@ def get_risk_heatmap_data(): "risk_pct": round(risk, 1), "ai_summary": None, "updated_at": None, + "estimated_delay": None, }) # 2. 讀取 DB 中手動/AI 覆寫的風險%與摘要,依 region_key 覆蓋到預設清單 - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( - "SELECT region_key, display_name, latitude, longitude, risk_pct, ai_summary, updated_at FROM risk_heatmap", + "SELECT region_key, display_name, latitude, longitude, risk_pct, ai_summary, updated_at, estimated_delay FROM risk_heatmap", conn, ) conn.close() @@ -295,6 +227,7 @@ def get_risk_heatmap_data(): "risk_pct": r.get("risk_pct"), "ai_summary": r.get("ai_summary"), "updated_at": r.get("updated_at"), + "estimated_delay": None if pd.isna(r.get("estimated_delay")) else r.get("estimated_delay"), "latitude": r.get("latitude"), "longitude": r.get("longitude"), } @@ -312,6 +245,7 @@ def get_risk_heatmap_data(): "risk_pct": o.get("risk_pct") if o.get("risk_pct") is not None else row["risk_pct"], "ai_summary": o.get("ai_summary"), "updated_at": o.get("updated_at"), + "estimated_delay": o.get("estimated_delay"), }) else: out.append(row) @@ -323,8 +257,9 @@ def upsert_risk_heatmap( ): """新增或更新一筆熱圖熱點。""" require_capability(actor, RISK_WORKSPACE_WRITE) + risk_pct = number(risk_pct, maximum=100) now = datetime.now().strftime("%Y-%m-%d %H:%M") - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute( """INSERT INTO risk_heatmap (region_key, display_name, latitude, longitude, risk_pct, ai_summary, updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(region_key) DO UPDATE SET @@ -339,7 +274,7 @@ def upsert_risk_heatmap( def reset_risk_heatmap_to_initial(*, actor=None): """清空 risk_heatmap 表,使熱圖還原為依供應商據點與風險事件計算的初始狀態。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute("DELETE FROM risk_heatmap") conn.commit() conn.close() @@ -350,57 +285,56 @@ def reset_risk_heatmap_to_initial(*, actor=None): def _gate_heatmap_updates(raw_updates, valid_list, name_expansions) -> list[dict]: - """ - issue #47 P1-3:合法區域檢核由 code 執行(取代 prompt 的嚴詞要求)。 - - 名稱在合法清單 → 直接收 - - 名稱是可展開的總稱(如「台灣」「中東」)→ 展開為完整節點 - - 其餘 → 丟棄 - 無合法清單(DB 無正式供應商)時退回寬鬆模式:全收。 - """ - gate_on = bool(valid_list) and not (len(valid_list) == 1 and valid_list[0].startswith("(")) + """Only validated numeric suggestions matching an actual node are actionable.""" + if not isinstance(raw_updates, list): + return [] out = [] - for u in raw_updates or []: - u = u or {} - name = str(u.get("地區") or u.get("display_name") or "").strip() - pct = u.get("風險", u.get("risk_pct")) + for u in raw_updates: try: - pct = float(str(pct).replace("%", "").strip()) - except (TypeError, ValueError): - continue - if not name: + name = text(u.get("地區", u.get("display_name"))) + pct = number(u.get("風險", u.get("risk_pct")), maximum=100) + except (AttributeError, TypeError, ValueError): continue - if not gate_on or name in valid_list: - out.append({"display_name": name, "risk_pct": pct}) - elif name in name_expansions: - for expanded in name_expansions[name]: - out.append({"display_name": expanded, "risk_pct": pct}) - # 不在清單也不可展開 → 丟棄(code-side gate) + for node in valid_list: + c, r = split_location(node) + if node == name or node in name_expansions.get(name, []) or matches_location(c or node, r, name): + out.append({"display_name": node, "risk_pct": pct}) return out def _coerce_heatmap_events(raw_events) -> list[dict]: - """AI 回傳事件 → 內部契約(型別修正 + 預設值)。維持舊行為:事件不做地區硬閘。""" + """Reject malformed events; null delay remains unknown and 0 stays zero.""" + if not isinstance(raw_events, list): + return [] out = [] - for e in raw_events or []: - e = e or {} + for e in raw_events: try: - days = int(e.get("延遲天數", e.get("impact_days", 14)) or 14) - except (TypeError, ValueError): - days = 14 - out.append({ - "event_type": str(e.get("類型") or e.get("event_type") or "其他").strip() or "其他", - "region": str(e.get("地區") or e.get("region") or "").strip(), - "country": str(e.get("國家") or e.get("country") or "").strip(), - "impact_days": days, - "description": str(e.get("描述") or e.get("description") or "").strip(), - }) + etype = text(e.get("類型", e.get("event_type"))) + if etype not in EVENT_TYPES: + raise ValueError("Invalid event type") + region = text(e.get("地區", e.get("region", ""))) + country = text(e.get("國家", e.get("country", ""))) + if not (region or country): + raise ValueError("Missing geography") + if "延遲天數" not in e and "impact_days" not in e: + raise ValueError("Missing delay") + days = number(e.get("延遲天數", e.get("impact_days")), maximum=365, integer=True, nullable=True) + out.append(dict(event_type=etype, region=region, country=country, + impact_days=days, description=text(e.get("描述", e.get("description", ""))))) + except (AttributeError, TypeError, ValueError): + continue 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 熱圖摘要,並整合現有的正式事件,確保「情報 -> 摘要 -> 應變」流程連貫。 - """ +def get_heatmap_ai_summary(api_key="", news_context="", reference_date=None, model=None): + """Compatibility tuple for existing callers; structured status is available below.""" + result = get_heatmap_ai_analysis(api_key, news_context, reference_date, model) + return result["summary"], result["updates"], result["events"] + + +def get_heatmap_ai_analysis(api_key="", news_context="", reference_date=None, model=None): + """Separate analysis status from display text and actionable suggestions.""" + 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: @@ -410,20 +344,23 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ for _, row in events_df.head(15).iterrows() ]) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) try: # 僅選取正式供應商 (is_official=1) 的據點,確保建議清單精確對齊 valid_regions_df = pd.read_sql_query("SELECT DISTINCT country, region FROM suppliers WHERE is_official=1 AND country IS NOT NULL", conn) valid_regions = [] + valid_locations = [] for _, r in valid_regions_df.iterrows(): - c = str(r['country']).strip() - rg = str(r['region']).strip() + c = str(r['country'] or '').strip() + rg = str(r['region'] or '').strip() + valid_locations.append((c, rg)) if rg and rg != c: valid_regions.append(f"{c} {rg}") else: valid_regions.append(c) valid_regions_text = "、".join(set(valid_regions)) or "(目前無正式供應商據點資料,請跳過風險建議清單)" except Exception: + valid_locations = [] valid_regions_text = "(系統讀取區域資料失敗,請跳過風險建議清單)" finally: conn.close() @@ -436,18 +373,10 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ ) # 合法區域清單與展開表(code-side gate 用;如「台灣」→「台灣 北區/中區/南區」) valid_list = [v.strip() for v in (valid_regions_text or "").split("、") if v.strip()] - name_expansions: dict = {} - for v in valid_list: - parts = v.split(" ") - c = parts[0] - name_expansions.setdefault(c, []) - if v not in name_expansions[c]: - name_expansions[c].append(v) - if len(parts) > 1: - r = parts[1] - name_expansions.setdefault(r, []) - if v not in name_expansions[r]: - name_expansions[r].append(v) + name_expansions = {} + for country, region in valid_locations: + name = f"{country} {region}" if region and region != country else country + name_expansions.setdefault(country, []).append(name) try: # issue #27/#47:統一 LLM 入口 + 結構化輸出(JSON)。 @@ -458,90 +387,74 @@ def get_heatmap_ai_summary(api_key: str = "", news_context: str = "", reference_ raw = (complete_text(prompt, temperature=0.3, json_mode=True, tag="analysis:heatmap") or "").strip() if not raw: - return "AI 摘要失敗:模型未回傳內容。", [], [] - payload = json.loads(re.sub(r"```json\s*|```\s*", "", raw)) - - summary = str(payload.get("摘要") or "").strip() or "(AI 未提供摘要內容)" + return dict(analysis_status="failed", analysis_error="empty_response", summary="AI 摘要失敗:模型未回傳內容。", updates=[], events=[]) + payload = json_payload(raw) + if not isinstance(payload, dict) or not isinstance(payload.get("摘要"), str) or not isinstance(payload.get("更新"), list) or not isinstance(payload.get("事件"), list): + raise ValueError("Invalid heatmap response schema") + + for update in payload["更新"]: + if not isinstance(update, dict) or not text(update.get("地區")): + raise ValueError("Invalid heatmap update") + number(update.get("風險"), maximum=100) + if len(_coerce_heatmap_events(payload["事件"])) != len(payload["事件"]): + raise ValueError("Invalid heatmap event") + summary = text(payload["摘要"]) + if not summary: + raise ValueError("Missing summary") updates = _gate_heatmap_updates(payload.get("更新"), valid_list, name_expansions) - suggested_events = _coerce_heatmap_events(payload.get("事件")) - return summary, updates, suggested_events - except Exception as e: - import traceback - traceback.print_exc() - return f"AI 摘要解析失敗:{e}", [], [] + suggested_events = [e for e in _coerce_heatmap_events(payload.get("事件")) + if any(matches_location(c, r, e["region"], e["country"]) for c, r in valid_locations)] + return dict(analysis_status="succeeded", analysis_error=None, summary=summary, updates=updates, events=suggested_events) + except Exception: + return dict(analysis_status="failed", analysis_error="invalid_output_or_provider_error", summary="AI 摘要解析失敗:請稍後重試。", updates=[], events=[]) -def apply_heatmap_updates(updates, ai_summary=None, *, actor=None): - """ - 將 AI 回傳的 UPDATE 清單套用到熱圖。 - - 若 update 的 display_name 為廣域地區(如「亞洲」),則將該地區內所有熱點都更新為對應 risk_pct。 - - 否則依「display_name 包含於熱點 display_name」匹配單一熱點後更新。 - """ - require_capability(actor, RISK_WORKSPACE_WRITE) - if not updates: - return 0 - heatmap_rows = get_risk_heatmap_data() - if not heatmap_rows: - return - summary_snippet = (ai_summary or "")[:500] +def resolve_heatmap_updates(updates, heatmap_rows): + """Resolve once for both UI preview and persistence; last matching update wins.""" + resolved = {} for u in updates: - name = (u.get("display_name") or "").strip() - risk_pct = u.get("risk_pct") - # 建立別名映射以提升匹配率 - synonyms = {"韓國": "南韓", "南韓": "韓國", "美國": "美洲", "德國": "德國"} - - matched_count = 0 - for u in updates: - name = (u.get("display_name") or "").strip() - risk_pct = u.get("risk_pct") - if not name or risk_pct is None: - continue - - target_names = [name] - if name in synonyms: - target_names.append(synonyms[name]) - - # 1. 廣域地區匹配 - is_region_match = False - for t_name in target_names: - if t_name in REGION_COUNTRY_MAP: - countries = REGION_COUNTRY_MAP[t_name] - for r in heatmap_rows: - country = (r.get("region_key") or "").split("|")[0].strip() - if country in countries: - upsert_risk_heatmap( - r["region_key"], r["display_name"], r["latitude"], r["longitude"], - float(risk_pct), summary_snippet, actor=actor, - ) - matched_count += 1 - is_region_match = True + pct = number(u.get("risk_pct"), maximum=100) + name = text(u.get("display_name")) + for row in heatmap_rows: + c, r = split_location(row["region_key"]) + if matches_location(c, r, name): + value = dict(row, risk_pct=pct) + if "estimated_delay" in u: + value["estimated_delay"] = number(u["estimated_delay"], maximum=365, integer=True, nullable=True) + resolved[row["region_key"]] = value + return list(resolved.values()) + + +def build_heatmap_review_rows(updates, events, heatmap_rows): + rows = [] + for row in resolve_heatmap_updates(updates, heatmap_rows): + c, r = split_location(row["region_key"]) + days = row.get("estimated_delay") + for event in events: + if matches_location(c, r, event.get("region"), event.get("country")): + days = event.get("impact_days") break - - if is_region_match: - continue - - # 2. 國家/地區精準或模糊匹配 - for r in heatmap_rows: - d_name = r.get("display_name") or "" - r_key = r.get("region_key") or "" - country_part = r_key.split("|")[0] if "|" in r_key else d_name - - matched = False - for t_name in target_names: - # 匹配邏輯:名稱包含、國家部包含、或熱點名稱包含 - if t_name in d_name or t_name in country_part or d_name in t_name: - matched = True - break - - if matched: - upsert_risk_heatmap( - r["region_key"], r["display_name"], r["latitude"], r["longitude"], - float(risk_pct), summary_snippet, actor=actor, - ) - matched_count += 1 - return matched_count + rows.append({"套用": True, "地區": row["display_name"], + "預估風險 (%)": row["risk_pct"], "預估延遲 (天)": days}) + return rows +def apply_heatmap_updates(updates, ai_summary=None, *, actor=None): + """Atomically persist the exact reviewed risk AND delay per node.""" + require_capability(actor, RISK_WORKSPACE_WRITE) + rows = resolve_heatmap_updates(updates or [], get_risk_heatmap_data()) + now = datetime.now().strftime("%Y-%m-%d %H:%M") + with connect_db(DB_FILE) as conn: + for row in rows: + conn.execute("""INSERT INTO risk_heatmap + (region_key,display_name,latitude,longitude,risk_pct,ai_summary,updated_at,estimated_delay) + VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(region_key) DO UPDATE SET + risk_pct=excluded.risk_pct,ai_summary=excluded.ai_summary, + updated_at=excluded.updated_at,estimated_delay=excluded.estimated_delay""", + (row["region_key"],row["display_name"],row["latitude"],row["longitude"], + row["risk_pct"],(ai_summary or "")[:500],now,row.get("estimated_delay"))) + return len(rows) + def translate_to_chinese_traditional(api_key: str = "", text: str = "", model_name: str = "") -> str: """將文字翻譯為繁體中文;失敗回傳原文。(issue #27:api_key/model_name 參數棄用,.env 驅動)""" @@ -586,7 +499,7 @@ def generate_communication_draft(api_key: str = "", context: str = "", target_ty def get_total_impact_amount(region_key): """計算特定地區受波及的採購總金額 (美元)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where = ["(p.status IS NULL OR p.status NOT IN ('已完成','已取消'))"] params = [] where_sub, params_sub = _get_expanded_region_where(region_key, None, prefix="s.") @@ -607,7 +520,7 @@ def get_total_impact_amount(region_key): def infer_affected_region_from_news(api_key: str, news_text: str, model: str | None = None) -> dict: """單篇新聞分析(保留原介面)。""" res = batch_infer_affected_region_from_news(api_key, [news_text], model=model) - return res[0] if res else {"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 0, "chinese_summary": ""} + return res[0] if res else failed_analysis("missing_result") def batch_infer_affected_region_from_news(api_key: str = "", news_texts: List[str] = None, model: str | None = None) -> List[dict]: @@ -641,54 +554,25 @@ def batch_infer_affected_region_from_news(api_key: str = "", news_texts: List[st # issue #27:統一 LLM 入口(json_mode + 低溫;供應商 fallback 在底層) from backend.llm_client import complete_text try: - raw_text = (complete_text(prompt, temperature=0.1, json_mode=True, - tag="analysis:news_batch") or "").strip() - except Exception as e: - print("批量新聞分析失敗,回傳預設 7 天延遲。錯誤:", e) - return [{"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 7, "chinese_summary": f"AI 分析失敗: {e}"}] * len(news_texts) - # 去除 markdown 程式碼區塊符號 - clean_json = re.sub(r"```json\s*", "", raw_text) - clean_json = re.sub(r"```\s*", "", clean_json) - - payload = json.loads(clean_json) - # issue #47 P0-2:頂層改為物件 {"results": [...]}(json_object 模式規格要求); - # 相容舊版頂層 array(模型偶爾仍會直接回 array) - data = payload.get("results", []) if isinstance(payload, dict) else payload - # 映射回原始順序 - results = [{"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 0, "chinese_summary": ""}] * len(news_texts) - for item in data: - idx = item.get("news_id") - if idx is not None and 0 <= idx < len(results): - results[idx] = { - "is_relevant": item.get("相關性") == "YES", - "country": item.get("國家") if item.get("國家") != "不明" else "", - "region": item.get("地區") if item.get("地區") != "不明" else "", - "event_type": item.get("事件類型") or "其他", - "chinese_summary": item.get("繁體中文簡要") or "", - "estimated_delay": item.get("預計延遲") or 0 - } - return results - except Exception as e: - import traceback - traceback.print_exc() - return [{"is_relevant": True, "country": "", "region": "", "event_type": "其他", "estimated_delay": 7, "chinese_summary": f"系統錯誤: {e}"}] * len(news_texts) + raw_text = complete_text(prompt, temperature=0.1, json_mode=True, tag="analysis:news_batch") or "" + except Exception: + return [failed_analysis("provider_error") for _ in news_texts] + return parse_news_batch(raw_text, len(news_texts)) + except Exception: + return [failed_analysis("invalid_output") for _ in news_texts] # ── 受災採購清單 (Impacted PO List) ──────────────────────────────────── def get_impacted_pos(region_key=None, country=None, supplier_id=None): """依熱點(地區/國家)或供應商 ID 篩選未結案採購單,回傳:採購單號、供應商、關鍵物料、預計延遲、替代建議。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = ["(p.status IS NULL OR p.status NOT IN ('已完成','已取消'))"], [] if supplier_id: where.append("p.supplier_id = ?") params.append(supplier_id) - if region_key: - where_sub, params_sub = _get_expanded_region_where(region_key, None, prefix="s.") - where.extend(where_sub) - params.extend(params_sub) - if country: - where_sub, params_sub = _get_expanded_region_where(None, country, prefix="s.") + if region_key or country: + where_sub, params_sub = _get_expanded_region_where(region_key, country, prefix="s.") where.extend(where_sub) params.extend(params_sub) q = """ @@ -702,7 +586,7 @@ def get_impacted_pos(region_key=None, country=None, supplier_id=None): if pos is None or pos.empty: return [] out = [] - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) for _, row in pos.iterrows(): items = __pd_read( "SELECT product_id FROM purchase_order_items WHERE po_id = ?", conn, params=(row["po_id"],) @@ -746,7 +630,7 @@ def update_po_impact( ): """更新採購單的預計延遲天數與替代建議。""" require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(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)) if alternative_suggestion is not None: @@ -771,7 +655,6 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= suppliers = get_suppliers_for_map() if suppliers is not None and not suppliers.empty: # 當前熱點可能為「墨西哥 中北部」或「台灣 北區」,用關鍵字排除 - hotspot_parts = [p.strip() for p in (hotspot_name or "").replace(" ", " ").split() if p.strip()] seen = set() parts = [] for _, s in suppliers.iterrows(): @@ -780,7 +663,7 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= if not country: continue # 若該據點屬於當前熱點(國家或地區名重合)則跳過 - if any(p in country or p in region for p in hotspot_parts): + if matches_location(country, region, hotspot_name): continue key = f"{country} {region}".strip() if key not in seen: @@ -805,20 +688,25 @@ def get_ai_alternative_suggestions(api_key="", impacted_list=None, hotspot_name= import json from backend.llm_client import complete_text raw = (complete_text(prompt, json_mode=True, tag="analysis:po_suggest") or "").strip() - payload = json.loads(re.sub(r"```json\s*|```\s*", "", raw)) - items = payload.get("results", []) if isinstance(payload, dict) else payload - - result = [] - for it in items or []: - it = it or {} - po_id = str(it.get("po_id") or "").strip() + payload = json_payload(raw) + items = payload.get("results") if isinstance(payload, dict) else payload + if not isinstance(items, list): + raise ValueError("Invalid PO response") + result, seen = [], set() + for it in items: + if not isinstance(it, dict): + raise ValueError("Invalid PO item") + po_id = text(it.get("po_id")) if po_id not in po_ids: continue + if po_id in seen: + raise ValueError("Duplicate PO result") + seen.add(po_id) try: - delay_days = int(it.get("延遲天數", 7) or 7) - except (TypeError, ValueError): - delay_days = 7 - suggestion = str(it.get("建議") or "").strip() + delay_days = number(it["延遲天數"], maximum=365, integer=True, nullable=True) + suggestion = text(it["建議"]) + except (KeyError, TypeError, ValueError): + continue if suggestion: result.append({"po_id": po_id, "estimated_delay_days": delay_days, "alternative_suggestion": suggestion}) @@ -838,7 +726,7 @@ def what_if_simulation( ): """依使用者情境問題,結合 ERP 供應商、未結案採購單、庫存安全天數,由 AI 回覆影響與建議。model 為 Gemini 模型 ID。""" require_capability(actor, RISK_WHAT_IF_RUN) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) suppliers = __pd_read("SELECT supplier_id, name, country, region FROM suppliers", conn) pos = __pd_read( """SELECT p.po_id, p.supplier_id, s.name, s.country, s.region, p.estimated_delay_days, p.alternative_suggestion @@ -874,9 +762,9 @@ def what_if_simulation( def get_risk_events_list(limit=20): """取得風險事件列表(id, event_type, region, country, impact_days, description, created_at)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(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 ?", + f"SELECT id, event_type, region, country, impact_days, description, created_at, news_id FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} ORDER BY id DESC LIMIT ?", conn, params=(limit,), ) @@ -889,14 +777,14 @@ def get_active_risk_events(limit=30): def get_supply_chain_summary_kpis(): """計算供應鏈風險總覽 KPI:30天內事件數、去重後的受影響供應商數與銷售訂單數。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) # 1. 30 天內事件數 since = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d %H:%M") - event_count = conn.execute("SELECT COUNT(*) FROM supply_chain_events WHERE created_at >= ?", (since,)).fetchone()[0] + event_count = conn.execute(f"SELECT COUNT(*) FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} AND created_at >= ?", (since,)).fetchone()[0] # 2. 受波及供應商與訂單 (去重) # 取得最近 50 件事件作為代表性 KPI - active_events = conn.execute("SELECT region, country, impact_days FROM supply_chain_events ORDER BY id DESC LIMIT 50").fetchall() + active_events = conn.execute(f"SELECT region, country, impact_days FROM supply_chain_events WHERE {_VALID_EVENT_SOURCE} ORDER BY id DESC LIMIT 50").fetchall() conn.close() affected_suppliers = set() @@ -919,13 +807,13 @@ def get_supply_chain_summary_kpis(): def get_historical_event_precedents(): """從資料庫統計各類事件的平均延遲天數,作為 AI 推估的依據。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) try: # 統計各類事件的平均值與次數 res = conn.execute( - """SELECT event_type, AVG(impact_days) as avg_days, COUNT(*) as cnt + f"""SELECT event_type, AVG(impact_days) as avg_days, COUNT(*) as cnt FROM supply_chain_events - WHERE impact_days > 0 + WHERE {_VALID_EVENT_SOURCE} AND impact_days > 0 GROUP BY event_type ORDER BY cnt DESC""" ).fetchall() @@ -941,9 +829,15 @@ def add_risk_event( ): """新增或更新風險事件(如果該區域已存在事件則覆蓋)。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + impact_days = number(impact_days, maximum=365, integer=True) + conn = connect_db(DB_FILE) c = conn.cursor() - + if news_id is not None: + source = c.execute("SELECT analysis_status,is_relevant,estimated_delay FROM supply_chain_news WHERE id=?", (news_id,)).fetchone() + if not source or source[0] != "succeeded" or source[1] != 1 or source[2] is None: + conn.close() + raise ValueError("新聞分析尚未成功或延遲未知,無法登錄風險") + # 核心優化:直接覆寫同區域的正式事件 (news_id 為空者) c.execute( """SELECT id FROM supply_chain_events @@ -982,7 +876,7 @@ def delete_risk_event(event_id, *, actor=None): def get_affected_suppliers_by_event(region: str, country: str = None): """依地區與國家篩選受影響的正式供應商(僅限 is_official=1)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = _get_expanded_region_where(region, country) if not where: conn.close() @@ -1002,7 +896,7 @@ def get_affected_sales_orders_by_event(region: str, country: str, impact_days: i Trace: Suppliers (Region) -> Purchase Orders (Pending) -> Products -> BOM (Finished Good) -> Sales Orders (Pending). Returns list of dicts with order details. """ - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = _get_expanded_region_where(region, country, prefix="s.") if not where: @@ -1078,16 +972,12 @@ def get_stockout_alerts_for_event(region: str, country: str, impact_days: int): 計算因風險事件導致的採購延遲,是否會造成庫存斷鏈(量 < 0)或跌破安全水位(量 < reorder_point)。 回傳列表:包含商品名稱、現有庫存、預估延期消耗量、預估剩餘庫存、警報等級。 """ - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where = [] params = [] - combined_loc = f"{region} {country}".strip() - if combined_loc: - where_sub, params_sub = _get_expanded_region_where(combined_loc, None, prefix="s.") - where.extend(where_sub) - params.extend(params_sub) - + where, params = _get_expanded_region_where(region, country, prefix="s.") + if not where: conn.close() return [] @@ -1168,7 +1058,7 @@ def increase_safety_stock_for_event( 基準水位會被保存在 baseline_reorder_point 中以供日後還原。 """ require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) where, params = _get_expanded_region_where(region, country, prefix="s.") if not where: conn.close() @@ -1220,7 +1110,7 @@ def increase_safety_stock_for_event( def restore_all_rop_to_baseline(*, actor=None): """將所有產品的安全水位還原至基準值 (baseline_reorder_point)。""" require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) # 僅針對有設定 baseline 的進行還原 conn.execute("UPDATE inventory SET reorder_point = baseline_reorder_point WHERE baseline_reorder_point IS NOT NULL") conn.commit() @@ -1229,7 +1119,7 @@ def restore_all_rop_to_baseline(*, actor=None): def update_reorder_point(product_id: str, new_reorder_point: int, *, actor=None): """手動更新指定物料的安全庫存水位。""" require_capability(actor, ERP_POLICY_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute( "UPDATE inventory SET reorder_point = ? WHERE product_id = ?", (int(new_reorder_point), product_id) @@ -1239,7 +1129,7 @@ def update_reorder_point(product_id: str, new_reorder_point: int, *, actor=None) def get_event_risk_scores(): """取得事件類型對應的風險分數(event_type -> score)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( "SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors WHERE risk_type = 'event_type'", conn) conn.close() @@ -1250,7 +1140,7 @@ def get_event_risk_scores(): def get_region_risk_scores(): """取得地區對應的風險分數(region key -> score)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read("SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors WHERE risk_type = 'region'", conn) conn.close() if df is None or df.empty: @@ -1262,7 +1152,7 @@ def get_region_risk_scores(): def get_risk_factors(): """取得所有風險係數(id, 類型, 代碼, 風險分數, 權重, 備註, 更新時間)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read( "SELECT id, risk_type as 類型, risk_key as 代碼, risk_score as 風險分數, weight as 權重, note as 備註, updated_at as 更新時間 FROM esg_risk_factors ORDER BY risk_type, risk_key", conn, @@ -1273,7 +1163,7 @@ def get_risk_factors(): def get_risk_factors_raw(): """取得原始欄位名的風險係數(供加權計算、預覽用)。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) df = __pd_read("SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors", conn) conn.close() return df @@ -1301,7 +1191,7 @@ def delete_risk_factor(factor_id, *, actor=None): def clear_all_risk_factors(*, actor=None): """清空全部風險係數(供重新實作或重置使用)。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) conn.execute("DELETE FROM esg_risk_factors") conn.commit() conn.close() @@ -1320,7 +1210,7 @@ def get_geographic_risk_display(): seen.add(name) score = 0 for rk, rs in region_scores.items(): - if rk in name or name in rk: + if matches_location(name, name, rk): score = max(score, min(100, rs)) break if score == 0 and name in default_fallback: @@ -1377,7 +1267,7 @@ def get_risk_ai_suggestions(api_key: str = "", news_context: str = "", region_su def load_preset_risk_factors(*, actor=None): """載入預設風險係數範本(地區、事件類型、供應商類別)。""" require_capability(actor, RISK_WORKSPACE_WRITE) - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) now = datetime.now().strftime("%Y-%m-%d %H:%M") presets = [ ("region", "東亞", 60, 1.0, "預設範本"), @@ -1420,7 +1310,7 @@ def get_procurement_by_region_with_risk(): supplier_count = int(v.get("supplier_count") or 0) risk_score = 0 for rk, rs in region_scores.items(): - if rk in display_name or rk in key: + if matches_location(*split_location(key), rk): risk_score = max(risk_score, min(100, rs)) out.append({ "display_name": display_name, @@ -1433,7 +1323,7 @@ def get_procurement_by_region_with_risk(): def get_aggregated_risk_preview(): """綜合風險預覽:據點 × 地區係數 × 供應商類別係數,回傳 list of dict。""" - conn = sqlite3.connect(DB_FILE) + conn = connect_db(DB_FILE) factors = __pd_read("SELECT risk_type, risk_key, risk_score, weight FROM esg_risk_factors", conn) sup = __pd_read( "SELECT supplier_id as id, name, country, region, risk_level FROM suppliers WHERE (country IS NOT NULL AND country != '') OR (region IS NOT NULL AND region != '')", @@ -1465,7 +1355,7 @@ def get_aggregated_risk_preview(): for _, p in partners.iterrows(): region_score = None for k, v in region_map.items(): - if k in str(p.get("region") or "") or k in str(p.get("country") or ""): + if matches_location(p.get("country"), p.get("region"), k): region_score = v break cat_score = cat_map.get(str(p.get("risk_level") or "").strip()) diff --git a/docs/batch1-isolated-review.md b/docs/batch1-isolated-review.md new file mode 100644 index 0000000..534809d --- /dev/null +++ b/docs/batch1-isolated-review.md @@ -0,0 +1,85 @@ +# 第一批改善 1~6:隔離實作與檢查 + +基準:`fcc2737`;分支:`codex/batch1-isolated`。 + +## 環境與範圍 + +- Worktree:`C:\新EPR系統\ERP-batch1-isolated`。 +- 主要工作區 `C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new` 的程式、`.env`、資料庫及 6 個未追蹤架構圖片均未修改。 +- 檢查資料庫:`C:\新EPR系統\ERP-batch1-isolated\.isolated\erp-batch1.db`。 +- 啟動器在任何後端匯入之前明確設定 `ERP_DB_PATH`,不繼承外部資料庫路徑;成功案例使用另一個 `erp-batch1-success.db`。 +- 重用現有 Python 3.12.3 虛擬環境的已安裝依賴,未修改依賴版本。亦可在 worktree 建立自己的 `.venv`,安裝 `requirements.txt` 與 `requirements-dev.txt`,啟動器會優先使用它。 +- 新聞與 LLM 使用固定模擬資料;關閉背景排程、不載入原工作區 `.env`,移除程序內供應商金鑰及代理設定,阻擋 Python 程序的對外 DNS/TCP 連線;只允許本機連線。未啟動 LINE Bot。 +- 未執行第二、三批功能;未合併、推送或部署。 + +## 修改對照 + +| 項目 | 修改與行為 | 主要位置 | +|---|---|---| +| 1. AI 失敗不成為風險 | 移除例外時「相關、延遲 7 天」的預設;原始標題、本文、國家、URL 保留,分析摘要與地區存入獨立欄位;失敗及未驗證新聞不進入熱圖分析或事件登錄 | `risk_validation.py`、`news_store.py`、`supply_chain_news.py` | +| 2. 排程入口 | 明確啟用、間隔與重試次數可設定;SQLite 保存工作識別碼、狀態、嘗試次數;跨程序 OS 檔案鎖避免同時執行;成功識別碼跳過,失敗識別碼可重跑;程序終止後鎖由 OS 釋放 | `scheduler.py`、`job_lock.py`、`.env.example` | +| 3. 新聞去重 | 分析前以 URL 正規化雜湊、標題+來源+發布日雜湊尋找既有新聞;URL 去除追蹤參數與片段;資料庫唯一索引再防止重複寫入;成功新聞不重做逐篇分析,失敗/待分析新聞可重試 | `news_store.py`、`supply_chain_news.py` | +| 4. 嚴格 AI 驗證 | 檢查 JSON 結構、重複鍵、新聞編號、重複/遺漏結果、相關性、事件類型、文字、整數天數與數值範圍;拒絕布林、數字字串、NaN、Infinity、負數及越界值;零為有效值,null 為未知,例外為失敗 | `risk_validation.py`、`supply_chain_risk.py`、`prompts.py` | +| 5. 地區規則 | Python 與 SQLite 共用同一函式;國家+子地區為交集,多選為聯集,廣域名稱展開固定國家表;支援臺灣/台灣、韓國/南韓、阿聯酋及常見英文別名;不使用子字串或 SQL 萬用字元 | `region_matching.py`、`supply_chain_risk.py`、`supply_map.py` | +| 6. 畫面與保存一致 | 熱圖新增持久化延遲欄位;審核表與寫入使用同一地區解析;單一交易保存所有選取節點的百分比與天數,失敗整批回滾;0%/0 天可保存,空白天數為未知;重新開頁讀取資料庫值 | `supply_chain_risk.py`、`supply_map.py`、`risk_dashboard.py` | + +新聞刷新保留既有的自動更新熱圖行為,但僅使用驗證成功且相關的新聞。熱圖分析本身失敗時不覆寫既有值,排程收到可重試的失敗狀態。熱圖建議的保存不等於登錄正式事件,畫面成功訊息已說明此區別。手動登錄未有 AI 天數的事件須在畫面確認天數,不再靜默補入 7 天。 + +分析狀態:`pending` 待分析、`succeeded` 成功(可包含未知天數)、`failed` 失敗、`legacy_unverified` 舊資料待確認。錯誤欄位保存錯誤代碼,不將供應商例外內容寫入原始新聞。 + +資料表升級可重複執行。既有新聞與事件 ID 保留,不刪除歷史重複新聞;只有第一筆取得唯一識別鍵。舊新聞標記為未驗證,其來源事件不供本次風險計算使用。過去已被覆寫的原始摘要無法自動還原,既有熱圖覆寫值也未進行來源推測或清除;正式資料盤點不在這次隔離執行內。 + +## 啟動檢查 + +PowerShell: + +```powershell +Set-Location 'C:\新EPR系統\ERP-batch1-isolated' +.\scripts\start-isolated.ps1 +``` + +開啟 。測試帳號/密碼為 `planner`/`planner`;管理員為 `admin`/`admin`,僅存在隔離 Demo 資料庫。 + +啟動器預設展示台灣北區、台灣南區、日本東京 3 個正式測試據點。進入供應鏈風險頁面: + +1. 「更新即時新聞」會產生確認零延遲、5 天延遲、未知天數、格式錯誤四類固定資料。 +2. 重按刷新:原始新聞筆數不增加;格式錯誤會重試並保持失敗狀態。 +3. 檢查失敗新聞原文仍在,事件登錄停用;未知與 0 天的顯示不同。 +4. 「產生/更新即時風險摘要」會得到台灣北區 0%/0 天、日本 65%/5 天建議。編輯並套用,重新開頁檢查保存結果。 +5. 台灣北區的事件與採購/缺貨分析不應包含台灣南區或日本北區。 + +預設不啟動任何背景排程。以下是**手動一次性**排程測試,固定識別碼重跑可檢查防重複執行: + +```powershell +$py = 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +& $py scripts/run_isolated.py --scenario success --scheduler-once review-success +& $py scripts/run_isolated.py --scenario success --scheduler-once review-success +# 第一次成功;第二次 skipped。資料庫為 .isolated/erp-batch1-success.db。 + +& $py scripts/run_isolated.py --scheduler-once review-failure +# 固定格式錯誤案例:重試後 failed,退出碼 1;失敗資料仍可檢查。 +``` + +通用入口為 `python -m backend.scheduler --once --job-key <識別碼>`,要求明確 `ERP_DB_PATH` 與有 `risk.workspace.write` 權限的 `ERP_SCHEDULER_ACTOR`。背景入口 `start_background_jobs()` 額外要求 `ERP_SCHEDULER_ENABLED=1`;本次未掛入 app 啟動,也未啟動它。設定預設為 86400 秒間隔、10 秒起始延遲、3 次嘗試、30 秒重試等待。`ERP_ISOLATED_TEST=1` 會強制停用背景入口。 + +鎖限定使用同一個本機 SQLite 路徑的程序。這次未實作跨機器或網路檔案系統上的分散式排程。 + +若此次協作啟動的 8511 程序仍在運行,可直接開啟網址,無須再啟動一份。其 PID 與輸出記錄在 `.isolated/server.pid`、`.isolated/server.stdout.log`、`.isolated/server.stderr.log`;重新啟動時可用 `-Port 8512` 指定另一個本機埠。 + +## 測試與審查 + +最終本機結果:**387 項通過,0 項失敗(27.33 秒)**,包含 60 個新增測試案例。JUnit 報告在 `.isolated/test-results.xml`。`git diff --check` 通過;本機 `http://127.0.0.1:8511/_stcore/health` 回傳 `ok`。 + +```powershell +Set-Location 'C:\新EPR系統\ERP-batch1-isolated' +$py = 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +& $py -m pytest -q --disable-warnings +git diff fcc2737 --stat +git diff fcc2737 -- backend frontend tests scripts docs/batch1-isolated-review.md +``` + +pytest 在載入後端前將 `ERP_DB_PATH` 指向獨立暫存資料庫,且阻擋對外連線。測試涵蓋:原始內容保留、失敗不生風險、零/未知、嚴格 AI 驗證、去重、失敗回補、舊資料遷移、Python/SQL 地區一致性、跨程序鎖及程序中止後復原、重試、識別碼冪等、權限撤銷、寫入失敗回滾,以及 Streamlit 真實按鈕流程與新 session 讀取。 + +本機測試平台為 Windows/Python 3.12.3。POSIX 檔案鎖分支未在本機執行。 + +尚未驗證:真實 GNews/RSS 服務、付費模型品質、真實 LINE 通知、正式資料庫遷移、大量資料效能及長時間背景運行。這些均未在本次隔離測試中執行。瀏覽器地圖底圖的外部素材可用性也不屬於本次後端連線驗證。 diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index 1731a53..04b469b 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,3 +1,4 @@ +from backend.region_matching import matches_location, split_location import streamlit as st import re import pandas as pd @@ -33,7 +34,7 @@ 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_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30, analyzed_only=True) news_context = "" if news_list: news_context = "\n".join([ @@ -120,8 +121,9 @@ def render_intelligence_gathering( filtered = res.get("filtered_count", 0) saved = res.get("saved_count", 0) - status.update(label=f"✅ 全球情報更新完成!(已掃描 {fetched} 則,AI 過濾掉 {filtered} 則無關情報)", state="complete") - st.toast(f"📍 AI 已自動過濾 {filtered} 則不相關新聞,保留 {saved} 則關鍵情報。", icon="🤖") + status.update(label=f"情報處理完成:掃描 {fetched}、新增 {saved}、重複 {res.get('duplicate_count', 0)}、分析失敗 {res.get('failed_count', 0)}、待分析 {res.get('pending_count', 0)}", + state="error" if res.get("failed_count") else "complete") + st.toast(f"已保存 {saved} 則原始新聞;分析判定無關 {filtered} 則。", icon="📍") st.rerun() # 讀取現有新聞 @@ -130,7 +132,6 @@ def render_intelligence_gathering( # 執行類別過濾與去重 filtered_news = [] seen = set() - ai_filtered_count = 0 for n in news_list_raw: cat = n.get("category") or "其他" if "全部" not in selected_cates and selected_cates and cat not in selected_cates: @@ -140,21 +141,12 @@ def render_intelligence_gathering( if key in seen or (not key[0] and not key[1]): continue - # 3. 延遲過濾 (只抓有實質影響的新聞,排除預估 0 天者) - if (n.get("estimated_delay") or 0) <= 0: - ai_filtered_count += 1 - seen.add(key) - continue - + # Include all states for inspection. Only successful, known analysis can register an event. seen.add(key) filtered_news.append(n) if not filtered_news: - st.info("✅ 目前尚無具有「實質延遲風險 (大於 0 天)」的情報。") - if ai_filtered_count > 0: - st.caption(f"🤖 AI 在背景已為您處理並過濾了 **{ai_filtered_count}** 筆無顯著影響(預估 0 天延遲)的一般新聞或重複新聞。") - else: - st.caption("請點擊上方按鈕更新或調整時間/類別篩選條件。") + st.info("目前沒有符合篩選條件的新聞,請更新新聞或調整篩選。") return st.markdown("---") @@ -176,7 +168,7 @@ def render_intelligence_gathering( news_options = [] for n in unregistered_news: cat = n.get('category') or '其他' - delay = n.get('estimated_delay') or 0 + delay = n.get('estimated_delay') if n.get('estimated_delay') is not None else '未知' title = n.get('title') or '(無標題)' news_options.append(f"【{cat} | 預估 {delay}天】{title}") @@ -189,17 +181,18 @@ def render_intelligence_gathering( raw_intro = "\n\n".join(p for p in [(chosen.get("title") or "").strip(), (chosen.get("summary") or "").strip()] if p).strip() or "(無簡介)" # --- 🚀 一鍵批量登錄功能 --- + registrable = [n for n in unregistered_news if n.get("analysis_status") == "succeeded" and n.get("is_relevant") == 1 and n.get("estimated_delay") is not None] col_bulk, _ = st.columns([1, 2]) with col_bulk: - if st.button("🚀 一鍵登錄全部情報", use_container_width=True, type="primary"): + if st.button(f"🚀 登錄已驗證情報 ({len(registrable)} 則)", use_container_width=True, type="primary", disabled=not registrable): with st.status("正在登錄情報...") as status: bulk_count = 0 - for n in unregistered_news: + for n in registrable: add_risk_event( event_type=n.get("category") or "其他", - region=n.get("region") or "", - country=n.get("country") or "", - impact_days=n.get("estimated_delay") or 7, + region=n.get("analysis_region") or "", + country=n.get("analysis_country") or "", + impact_days=n["estimated_delay"], description=f"【一鍵批量登錄】{n.get('title')}", news_id=n.get('id'), actor=actor, @@ -211,7 +204,10 @@ def render_intelligence_gathering( # 不再切分兩欄,直接全寬顯示簡介與單筆一鍵登錄按鈕 st.markdown("**📝 簡介分析**") - intro_text = chosen.get("summary") or "(無簡介)" + intro_text = chosen.get("analysis_summary") or "(尚無有效分析)" + st.caption(f"分析狀態:{chosen.get('analysis_status', 'legacy_unverified')};延遲:{chosen.get('estimated_delay') if chosen.get('estimated_delay') is not None else '未知'}") + with st.expander("原始新聞內容"): + st.write(chosen.get("summary") or "(無簡介)") st.info(intro_text) # 選配:點擊後才進行深度翻譯 @@ -229,12 +225,13 @@ def render_intelligence_gathering( with col_link: if chosen.get("url"): st.link_button("🔗 查看原文", chosen.get("url"), use_container_width=True) with col_reg: - def_country = chosen.get("country") or "" - def_region = chosen.get("region") or "" + def_country = chosen.get("analysis_country") or "" + def_region = chosen.get("analysis_region") or "" def_etype = chosen.get("category") or "其他" - def_delay = chosen.get("estimated_delay") or 0 + def_delay = chosen.get("estimated_delay") + can_register = chosen.get("analysis_status") == "succeeded" and chosen.get("is_relevant") == 1 and def_delay is not None - if st.button(f"🚀 一鍵登錄:{def_etype}風險 (預估延遲 {def_delay} 天)", type="primary", use_container_width=True): + if st.button(f"🚀 一鍵登錄:{def_etype}風險 (預估延遲 {def_delay} 天)", type="primary", use_container_width=True, disabled=not can_register): add_risk_event( def_etype, def_region, @@ -295,32 +292,13 @@ def render_response_execution( st.info("目前尚無正式應變事件。請至「步驟 2: 全域風險監控」點擊地圖區域之「加入應變計畫」以啟動分析。") return - # 【核心優化】過濾選單,僅顯示熱圖中具備中高風險 (>20%) 或 AI 有積極建議的地區 - heatmap_rows = get_risk_heatmap_data() - high_risk_names = [hr['display_name'] for hr in (heatmap_rows or []) if (hr.get('risk_pct') or 0) > 20] - - # 建立 country -> display_name 的查詢字典 - country_to_display = {} - for hr in (heatmap_rows or []): - c = (hr.get('display_name') or '').split(' ')[0] - if c and c not in country_to_display: - country_to_display[c] = hr['display_name'] - event_options = ["--- 請選擇要分析的事件 ---"] event_ids = [None] - - seen_display = set() for _, row in events.iterrows(): - country = (row.get('country') or '').strip() - display = country_to_display.get(country) or country or '未知' - - # 僅顯示高風險區域,或若該區域已經有進入應變狀態,則保留顯示 - if display in high_risk_names or display in seen_display: - if display not in seen_display: - event_options.append(f"【{row['event_type']}】{display}") - event_ids.append(row['id']) - seen_display.add(display) - + display = f"{row.get('country') or ''} {row.get('region') or ''}".strip() or "未知" + event_options.append(f"【{row['event_type']}】{display} (#{row['id']})") + event_ids.append(row["id"]) + # ── 聯動邏輯:檢查是否有外部 (如地圖/情報) 指令要選中特定事件 ── if "resp_active_event_sel" not in st.session_state: st.session_state["resp_active_event_sel"] = 0 diff --git a/frontend/components/supply_map.py b/frontend/components/supply_map.py index 0511191..4c9cbbf 100644 --- a/frontend/components/supply_map.py +++ b/frontend/components/supply_map.py @@ -1,3 +1,5 @@ +from backend.region_matching import matches_location, split_location +from backend.supply_chain_risk import build_heatmap_review_rows import streamlit as st import pandas as pd import plotly.express as px @@ -117,7 +119,7 @@ def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): 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: + if matches_location(*split_location(reg['region_key']), ev_r, ev_c): found_ev = ev break @@ -126,10 +128,15 @@ def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): 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()): + if sev.get('impact_days') is not None and matches_location(*split_location(reg['region_key']), s_r, s_c): match_suggest = sev break + if reg.get("estimated_delay") is not None and pd.notna(reg.get("estimated_delay")): + c, r = split_location(reg["region_key"]) + match_suggest = dict(country=c, region=r, impact_days=int(reg["estimated_delay"]), + event_type="其他", description=reg.get("ai_summary") or "已保存的熱圖建議") + # 判定按鈕狀態 btn_state = "add" # 待登錄 if found_ev is not None: @@ -174,9 +181,7 @@ def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): 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 "" + ev_c, ev_r = split_location(reg["region_key"]) from backend.supply_chain_risk import add_risk_event add_risk_event( @@ -190,9 +195,7 @@ def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): 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 "" + ev_c, ev_r = split_location(reg["region_key"]) from backend.supply_chain_risk import add_risk_event add_risk_event( etype, ev_r, ev_c, impact_days, desc, actor=actor @@ -201,12 +204,11 @@ def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): st.toast(f"📍 已啟動 {reg_display} 應變計畫", icon="🤖") st.rerun() else: + manual_days = st.number_input("手動確認延遲天數", min_value=0, max_value=365, value=0, key=f"manual_days_{key}_{i}") 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 "" + impact_days, etype, desc = manual_days, "其他", f"手動加入:偵測到 {reg_display} 高風險。" + ev_c, ev_r = split_location(reg["region_key"]) from backend.supply_chain_risk import add_risk_event add_risk_event( etype, ev_r, ev_c, impact_days, desc, actor=actor @@ -229,38 +231,19 @@ def render_risk_shortcuts(key: str, heatmap_rows=None, *, actor: str): impact_amt = get_total_impact_amount(selected_r.get('display_name')) st.markdown(f"
曝險金額: ${impact_amt:,.0f}
", unsafe_allow_html=True) with c3: + persisted_days = selected_r.get("estimated_delay") + confirmed_days = st.number_input("確認延遲天數", min_value=0, max_value=365, + value=int(persisted_days) if persisted_days is not None and pd.notna(persisted_days) else 0, + key=f"{key}_other_days_{selected_r['region_key']}") 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']}% 地理風險。" + c, r = split_location(selected_r["region_key"]) from backend.supply_chain_risk import add_risk_event - new_id = add_risk_event( - etype, - clean_loc, - clean_loc, - impact_days, - desc, - actor=actor, - ) + add_risk_event("其他", r, c, confirmed_days, + selected_r.get("ai_summary") or "手動確認的應變計畫", 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, gnews_api_key: str, @@ -281,11 +264,11 @@ def render_supply_chain_map( st.markdown("**AI 摘要**") news_context = "" try: - news_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30) + news_list = get_news_from_db(limit=10, order_by_latest=True, within_days=30, analyzed_only=True) 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}天]" + (n.get("title") or "") + " " + (n.get("analysis_summary") or n.get("summary") or "")[:200] + + f" [{n.get('published_at') or n.get('fetched_at') or ''}, 預估延遲: {n.get('estimated_delay') if n.get('estimated_delay') is not None else '未知'}天]" for n in news_list ]) except Exception: @@ -326,43 +309,12 @@ def render_supply_chain_map( 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: - name = (u.get("display_name") or "").strip() - 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 - - if risk_val is not None: - table_rows.append({ - "套用": True, - "地區": node_name, - "預估風險 (%)": float(risk_val), - "預估延遲 (天)": int(suggested_days) - }) + st.caption("套用會保存各據點的風險與延遲天數;空白代表未知,0 代表確認為零。正式事件需另行登錄。") + table_rows = build_heatmap_review_rows( + h_updates_raw, st.session_state.get("suggested_events", []), heatmap_rows_for_update + ) + if table_rows: df_upd = pd.DataFrame(table_rows) edited_risk_df = st.data_editor( @@ -370,7 +322,7 @@ 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("影響 %", min_value=0, max_value=100, step=1, required=True), "預估延遲 (天)": st.column_config.NumberColumn("延遲天數", min_value=0, max_value=365, step=1) }, hide_index=True, @@ -381,26 +333,17 @@ def render_supply_chain_map( 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 - st.session_state["suggested_events"] = current_suggested + final_updates = [{"display_name": r["地區"], "risk_pct": r["預估風險 (%)"], + "estimated_delay": None if pd.isna(r["預估延遲 (天)"]) else int(r["預估延遲 (天)"])} + for _, r in sel_risks.iterrows()] cnt = apply_heatmap_updates( final_updates, st.session_state["heatmap_ai_summary"], actor=actor, ) - st.session_state["heatmap_apply_success"] = f"✅ 已成功同步 {cnt} 個地區的風險等級與天數設定!" + st.session_state.pop("suggested_events", None) + st.session_state["heatmap_apply_success"] = f"✅ 已成功同步 {cnt} 個地區的風險與延遲天數至資料庫(尚未登錄正式事件)!" if "heatmap_updates" in st.session_state: del st.session_state["heatmap_updates"] st.rerun() @@ -434,7 +377,7 @@ def render_supply_chain_map( column_config={ "region_key": None, "display_name": st.column_config.TextColumn("熱點名稱", disabled=True), - "risk_pct": st.column_config.NumberColumn("影響 %", min_value=0, max_value=100, step=1) + "risk_pct": st.column_config.NumberColumn("影響 %", min_value=0, max_value=100, step=1, required=True) }, hide_index=True, use_container_width=True, @@ -457,6 +400,7 @@ def render_supply_chain_map( actor=actor, ) st.success("地圖已更新。") + st.rerun() def render_what_if_analysis( api_key: str, diff --git a/scripts/run_isolated.py b/scripts/run_isolated.py new file mode 100644 index 0000000..26c2619 --- /dev/null +++ b/scripts/run_isolated.py @@ -0,0 +1,55 @@ +"""python scripts/run_isolated.py [--seed-only | --scheduler-once KEY] [--port 8511]""" +import argparse +import os +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--seed-only", action="store_true") + parser.add_argument("--scheduler-once", metavar="KEY") + parser.add_argument("--port", type=int, default=8511) + parser.add_argument("--scenario", choices=("mixed", "success"), default="mixed") + args = parser.parse_args() + os.chdir(ROOT) + # Always pick a local test DB; never inherit a user's production database setting. + db_name = "erp-batch1.db" if args.scenario == "mixed" else "erp-batch1-success.db" + db_path = ROOT / ".isolated" / db_name + db_path.parent.mkdir(exist_ok=True) + for key in ("OPENAI_API_KEY", "GEMINI_API_KEY", "GNEWS_API_KEY", "LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): + os.environ.pop(key, None) + os.environ["ERP_ISOLATED_SCENARIO"] = args.scenario + os.environ.update(ERP_DB_PATH=str(db_path), ERP_DEMO_MODE="1", ERP_ISOLATED_TEST="1", + ERP_SCHEDULER_ENABLED="0", ERP_SCHEDULER_ACTOR="planner", + STREAMLIT_BROWSER_GATHER_USAGE_STATS="false", LITELLM_LOCAL_MODEL_COST_MAP="True", + OTEL_SDK_DISABLED="true") + from backend.isolated_runtime import block_external_network + block_external_network() + from backend.database import init_db + init_db() + # Stable official nodes for review, independent of the legacy random demo seed. + import sqlite3 + with sqlite3.connect(db_path) as conn: + conn.execute("UPDATE suppliers SET is_official=0 WHERE supplier_id NOT LIKE 'BATCH1-%'") + for sid, country, region, lat, lon in (("BATCH1-TWN","台灣","北區",25.03,121.56),("BATCH1-TWS","台灣","南區",22.63,120.30),("BATCH1-JP","日本","東京",35.68,139.69)): + conn.execute("INSERT OR IGNORE INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES (?,?,?,?,?,?,1)", (sid,f"測試供應商 {country} {region}",country,region,lat,lon)) + print(f"ISOLATED ERP_DB_PATH={db_path}") + print("Fixed news/LLM fixtures; external network blocked; background scheduler disabled.") + if args.scheduler_once: + from backend.scheduler import SchedulerConfig, run_scheduled_refresh + result = run_scheduled_refresh(SchedulerConfig(actor="planner", max_attempts=2, retry_seconds=0), job_key=args.scheduler_once) + print(result) + return 1 if result["status"] in {"failed", "busy", "cancelled"} else 0 + elif not args.seed_only: + from streamlit.web import cli + sys.argv = ["streamlit", "run", str(ROOT / "app.py"), "--server.address=127.0.0.1", + f"--server.port={args.port}", "--server.headless=true", "--browser.gatherUsageStats=false"] + cli.main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start-isolated.ps1 b/scripts/start-isolated.ps1 new file mode 100644 index 0000000..120a8c9 --- /dev/null +++ b/scripts/start-isolated.ps1 @@ -0,0 +1,11 @@ +param([int]$Port = 8511) +$ErrorActionPreference = 'Stop' +$isolatedRoot = Split-Path -Parent $PSScriptRoot +$isolatedPython = Join-Path $isolatedRoot '.venv\Scripts\python.exe' +if (-not (Test-Path -LiteralPath $isolatedPython)) { + $isolatedPython = Join-Path (Split-Path -Parent $isolatedRoot) 'AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +} +if (-not (Test-Path -LiteralPath $isolatedPython)) { + throw 'Python environment missing. Create .venv and install requirements.txt plus requirements-dev.txt.' +} +& $isolatedPython (Join-Path $PSScriptRoot 'run_isolated.py') --port $Port diff --git a/tests/conftest.py b/tests/conftest.py index 649c295..b161cf6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,3 +20,9 @@ os.environ["ERP_DB_PATH"] = os.path.join(_TMP_DIR, "test_erp.db") # 測試套件明確啟用合成資料;正式執行的安全預設維持關閉。 os.environ["ERP_DEMO_MODE"] = "1" + +# Tests must not access paid providers or external notification endpoints. +os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" +os.environ["ERP_SCHEDULER_ENABLED"] = "0" +from backend.isolated_runtime import block_external_network +block_external_network() diff --git a/tests/test_batch1_risk_pipeline.py b/tests/test_batch1_risk_pipeline.py new file mode 100644 index 0000000..6beb906 --- /dev/null +++ b/tests/test_batch1_risk_pipeline.py @@ -0,0 +1,260 @@ +import json +import sqlite3 +from datetime import datetime + +import pandas as pd +import pytest + +from backend import database, supply_chain_news as news, supply_chain_risk as risk +from backend.news_store import migrate, store_raw, store_analysis, identity_keys +from backend.region_matching import matches_location, connect_db, expanded_region_where +from backend.risk_validation import parse_news_batch, number + + +@pytest.fixture +def risk_db(tmp_path, monkeypatch): + path = str(tmp_path / "batch1.db") + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + for table in ("suppliers", "purchase_orders", "purchase_order_items", "inventory", "supply_chain_events", "risk_heatmap", "esg_risk_factors"): + conn.execute(f"DELETE FROM {table}") + for i, (country, region) in enumerate((("台灣", "北區"), ("台灣", "南區"), ("日本", "北區"), ("阿聯酋", "杜拜"))): + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,is_official,latitude,longitude) VALUES (?,?,?,?,1,25,121)", (f"S{i}", f"S{i}", country, region)) + conn.execute("INSERT INTO inventory(product_id,name,stock,reorder_point,daily_sales) VALUES (?,?,10,5,3)", (f"P{i}", f"P{i}")) + conn.execute("INSERT INTO purchase_orders(po_id,supplier_id,status,total_amount) VALUES (?,?, 'pending',100)", (f"PO{i}", f"S{i}")) + conn.execute("INSERT INTO purchase_order_items(po_id,product_id,qty,unit_price) VALUES (?,?,1,100)", (f"PO{i}", f"P{i}")) + return path + + +def item(**kwargs): + return dict(dict(country="台灣", region="北區", title="Original headline", summary="Original news body", + url="https://example.test/news", source="fixture", published_at=datetime.now().strftime("%Y-%m-%d %H:%M")), **kwargs) + + +def response(idx=0, **kwargs): + return dict(dict(news_id=idx, 相關性="YES", 國家="台灣", 地區="北區", 事件類型="交通", 預計延遲=0, 繁體中文簡要="分析摘要"), **kwargs) + + +def mock_llm(monkeypatch, rows): + def complete(prompt, **kwargs): + if kwargs.get("tag") == "analysis:heatmap": + return '{"摘要":"有效摘要","更新":[],"事件":[]}' + return json.dumps({"results": rows}, ensure_ascii=False) + monkeypatch.setattr("backend.llm_client.complete_text", complete) + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + + +@pytest.mark.parametrize("bad", [True, False, "0", "55%", -1, 366, 0.5, float("nan"), float("inf"), [], {}]) +def test_delay_rejects_invalid_values(bad): + with pytest.raises(ValueError): + number(bad, maximum=365, integer=True) + + +@pytest.mark.parametrize("value", [0, None, 365]) +def test_zero_unknown_and_known_remain_distinct(value): + rows = parse_news_batch(json.dumps({"results": [response(預計延遲=value)]}), 1) + assert rows[0]["analysis_status"] == "succeeded" + assert rows[0]["estimated_delay"] == value + + +@pytest.mark.parametrize("rows", [[response(news_id=True)], [response(news_id="0")], [response(news_id=2)], [response(), response()], [None]]) +def test_batch_rejects_ambiguous_identifiers(rows): + with pytest.raises(ValueError): + parse_news_batch(json.dumps({"results": rows}), 1) + + +def test_partial_output_marks_missing_item_failed(): + results = parse_news_batch(json.dumps({"results": [response()]}), 2) + assert results[0]["estimated_delay"] == 0 + assert results[1]["analysis_status"] == "failed" + assert results[1]["estimated_delay"] is None + assert results[1]["is_relevant"] is None + + +@pytest.mark.parametrize("changes", [{"相關性": "maybe"}, {"事件類型": "typo"}, {"國家": 42}, {"預計延遲": "7"}, {"繁體中文簡要": None}]) +def test_bad_fields_fail_without_inventing_risk(changes): + result = parse_news_batch(json.dumps({"results": [response(**changes)]}), 1)[0] + assert result["analysis_status"] == "failed" + assert result["estimated_delay"] is None and result["is_relevant"] is None + + +def test_provider_failure_retains_raw_content_and_cannot_create_risk(risk_db, monkeypatch): + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item()]) + monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) + def fail(*a, **k): + raise RuntimeError("paid-provider-secret must not become news") + monkeypatch.setattr("backend.llm_client.complete_text", fail) + result = news.refresh_news_for_countries(["台灣"], actor="planner") + assert result["failed_count"] == 1 and result["status"] == "partial_failure" + row = news.get_news_from_db()[0] + assert row["summary"] == "Original news body" and row["analysis_summary"] is None + assert row["estimated_delay"] is None and row["is_relevant"] is None + assert row["analysis_error"] == "provider_error" + assert news.get_news_from_db(analyzed_only=True) == [] + assert risk.get_active_risk_events().empty + with pytest.raises(ValueError): + risk.add_risk_event("交通", "北區", "台灣", 7, "bad", row["id"], actor="planner") + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_heatmap").fetchone()[0] == 0 + + +def test_dedupe_before_analysis_across_countries_and_refreshes(risk_db, monkeypatch): + mock_llm(monkeypatch, [response()]) + observed = [] + original = risk.batch_infer_affected_region_from_news + def infer(**kwargs): + observed.extend(kwargs["news_texts"]) + return original(**kwargs) + monkeypatch.setattr(risk, "batch_infer_affected_region_from_news", infer) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item(), item(url="https://example.test/news?utm_source=test#top"), item(url="https://other.test/syndicated")]) + result = news.refresh_news_for_countries(["台灣", "Taiwan", "日本"], actor="planner") + assert result["saved_count"] == 1 and result["duplicate_count"] == 5 + assert len(observed) == 1 + assert news.refresh_news_for_countries(["日本"], actor="planner")["saved_count"] == 0 + assert len(observed) == 1 + row = news.get_news_from_db()[0] + assert row["summary"] == "Original news body" and row["analysis_summary"] == "分析摘要" + assert row["estimated_delay"] == 0 + + +def test_failed_retained_news_retries_even_if_fetch_no_longer_returns_it(risk_db, monkeypatch): + mock_llm(monkeypatch, [response(預計延遲="invalid")]) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item()]) + assert news.refresh_news_for_countries(["台灣"], actor="planner")["failed_count"] == 1 + mock_llm(monkeypatch, [response(預計延遲=5)]) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: []) + result = news.refresh_news_for_countries(["台灣"], actor="planner") + assert result["saved_count"] == 0 and result["analyzed_count"] == 1 + assert news.get_news_from_db()[0]["estimated_delay"] == 5 + + +def test_no_model_retains_pending_and_empty_countries_is_safe(risk_db, monkeypatch): + monkeypatch.setattr("backend.llm_client.llm_available", lambda: False) + monkeypatch.setattr(news, "fetch_country_news", lambda *a, **k: [item()]) + assert news.refresh_news_for_countries([], actor="planner")["fetched_count"] == 0 + assert news.refresh_news_for_countries(["台灣"], actor="planner")["pending_count"] == 1 + assert news.get_news_from_db()[0]["analysis_status"] == "pending" + assert not news.get_news_from_db(analyzed_only=True) + + +def test_fetch_error_is_retryable_not_empty_success(risk_db, monkeypatch): + monkeypatch.setattr("backend.llm_client.llm_available", lambda: False) + def fail(*a, **k): + raise RuntimeError("offline") + monkeypatch.setattr(news, "fetch_country_news", fail) + result = news.refresh_news_for_countries(["台灣"], actor="planner") + assert result["status"] == "partial_failure" and result["fetch_failed_count"] == 1 + + +def test_migration_preserves_duplicates_and_ids(tmp_path): + with sqlite3.connect(tmp_path / "old.db") as conn: + conn.execute("CREATE TABLE supply_chain_news(id INTEGER PRIMARY KEY,title TEXT,url TEXT,source TEXT,published_at TEXT,summary TEXT)") + conn.execute("CREATE TABLE risk_heatmap(region_key TEXT PRIMARY KEY)") + for i in (10, 20): + conn.execute("INSERT INTO supply_chain_news VALUES (?,'same','https://test/','test','2026-09-13','original')", (i,)) + migrate(conn) + migrate(conn) + assert conn.execute("SELECT id,summary,analysis_status FROM supply_chain_news ORDER BY id").fetchall() == [(10,"original","legacy_unverified"),(20,"original","legacy_unverified")] + assert conn.execute("SELECT COUNT(url_key) FROM supply_chain_news").fetchone()[0] == 1 + + +@pytest.mark.parametrize("region,country,expected", [("北區", "台灣", ["S0"]), ("台灣 北區", None, ["S0"]), ("台灣|北區", None, ["S0"]), ("台灣", None, ["S0","S1"]), ("東亞", None, ["S0","S1","S2"]), ("中東", None, ["S3"]), ("阿拉伯聯合大公國", None, ["S3"]), ("台灣,日本", None, ["S0","S1","S2"]), ("%", None, []), ("灣", None, []), (None,None,[])]) +def test_all_risk_consumers_match_identical_geography(risk_db, region, country, expected): + suppliers = risk.get_affected_suppliers_by_event(region, country) + pos = risk.get_impacted_pos(region, country) + stock = risk.get_stockout_alerts_for_event(region, country, 5) + assert sorted(s["supplier_id"] for s in suppliers) == expected + if region or country: + assert sorted(p["po_id"] for p in pos) == [s.replace("S","PO") for s in expected] + assert sorted(p["product_id"] for p in stock) == [s.replace("S","P") for s in expected] + with connect_db(risk_db) as conn: + where, params = expanded_region_where(region, country) + sql_ids = [r[0] for r in conn.execute(f"SELECT supplier_id FROM suppliers WHERE {where[0]} ORDER BY supplier_id", params)] + python_ids = [r[0] for r in conn.execute("SELECT supplier_id,country,region FROM suppliers ORDER BY supplier_id") if matches_location(r[1],r[2],region,country)] + assert sql_ids == python_ids == expected + + +def test_spaces_aliases_and_country_region_intersection(): + assert matches_location("United States", "West", "United States West") + assert not matches_location("United States", "East", "United States West") + assert matches_location("韓國", "首爾", "南韓") + assert not matches_location("加拿大", "北美", "美國") + assert not matches_location("日本", "北區", "北區", "台灣") + + +def test_zero_risk_and_delay_persist_atomically_and_reload(risk_db): + before = risk.get_risk_heatmap_data() + review = risk.build_heatmap_review_rows([{"display_name":"台灣 北區","risk_pct":0}], + [{"country":"台灣","region":"北區","impact_days":0}], before) + assert review == [{"套用":True,"地區":"台灣 北區","預估風險 (%)":0.0,"預估延遲 (天)":0}] + assert risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=0,estimated_delay=0)], "zero", actor="planner") == 1 + reloaded = {r["region_key"]: r for r in risk.get_risk_heatmap_data()} + assert reloaded["台灣|北區"]["risk_pct"] == 0 and reloaded["台灣|北區"]["estimated_delay"] == 0 + assert reloaded["台灣|南區"]["risk_pct"] > 0 + with pytest.raises(ValueError): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=55),dict(display_name="日本",risk_pct=float("nan"))], actor="planner") + assert risk.get_risk_heatmap_data()[0]["risk_pct"] == 0 + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=10,estimated_delay=None)], actor="planner") + assert risk.get_risk_heatmap_data()[0]["estimated_delay"] is None + + +def test_zero_news_can_register_and_unknown_cannot(risk_db, monkeypatch): + mock_llm(monkeypatch, [response()]) + monkeypatch.setattr(news,"fetch_country_news",lambda *a,**k:[item()]) + news.refresh_news_for_countries(["台灣"],actor="planner") + row = news.get_news_from_db()[0] + event_id = risk.add_risk_event("交通","北區","台灣",0,"zero",row["id"],actor="planner") + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT impact_days FROM supply_chain_events WHERE id=?",(event_id,)).fetchone()[0] == 0 + conn.execute("UPDATE supply_chain_news SET estimated_delay=NULL WHERE id=?",(row["id"],)) + with pytest.raises(ValueError): + risk.add_risk_event("交通","北區","台灣",0,"unknown",row["id"],actor="planner") + + +def test_heatmap_transaction_rolls_back_on_storage_failure(risk_db): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=0,estimated_delay=0)],actor="planner") + with sqlite3.connect(risk_db) as conn: + conn.execute("CREATE TRIGGER reject_japan BEFORE INSERT ON risk_heatmap WHEN NEW.region_key='日本|北區' BEGIN SELECT RAISE(ABORT,'test failure'); END") + with pytest.raises(sqlite3.IntegrityError): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=25,estimated_delay=3),dict(display_name="日本",risk_pct=80,estimated_delay=8)],actor="planner") + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT risk_pct,estimated_delay FROM risk_heatmap").fetchall() == [(0,0)] + + +def test_manual_refresh_obeys_shared_pipeline_lock(risk_db,monkeypatch): + from backend.job_lock import exclusive_job_lock + monkeypatch.setattr(news,"fetch_country_news",lambda *a,**k:pytest.fail("Duplicate fetch")) + with exclusive_job_lock(risk_db,"news") as acquired: + assert acquired + assert news.refresh_news_for_countries(["台灣"],actor="planner")["status"] == "busy" + + +def test_legacy_unverified_source_events_are_not_risk_inputs(risk_db): + with sqlite3.connect(risk_db) as conn: + nid = conn.execute("INSERT INTO supply_chain_news(title,summary,analysis_status) VALUES ('old','previous content','legacy_unverified')").lastrowid + conn.execute("INSERT INTO supply_chain_events(event_type,country,region,impact_days,news_id) VALUES ('交通','台灣','北區',7,?)",(nid,)) + assert risk.get_recent_events_for_delay().empty + assert risk.get_active_risk_events().empty + assert not risk.get_historical_event_precedents() + with sqlite3.connect(risk_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM supply_chain_events").fetchone()[0] == 1 + + +def test_heatmap_invalid_numeric_output_is_explicit_failure(risk_db,monkeypatch): + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**k:'{"摘要":"bad","更新":[{"地區":"台灣","風險":101}],"事件":[]}') + result = risk.get_heatmap_ai_analysis(news_context="fixture") + assert result["analysis_status"] == "failed" + assert result["updates"] == [] and result["events"] == [] + + +def test_exact_nodes_outside_country_dictionary_can_be_reviewed_and_saved(risk_db,monkeypatch): + with sqlite3.connect(risk_db) as conn: + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,is_official,latitude,longitude) VALUES ('BR','BR','巴西','聖保羅',1,-23,-46)") + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**k:'{"摘要":"test","更新":[{"地區":"巴西","風險":0}],"事件":[{"類型":"交通","國家":"巴西","地區":"聖保羅","延遲天數":0,"描述":"test"}]}') + result = risk.get_heatmap_ai_analysis(news_context="fixture") + assert result["updates"] == [{"display_name":"巴西 聖保羅","risk_pct":0}] + assert len(result["events"]) == 1 + assert risk.apply_heatmap_updates(result["updates"],actor="planner") == 1 + assert matches_location("Czech Republic","Prague","Czech Republic Prague") diff --git a/tests/test_batch1_scheduler.py b/tests/test_batch1_scheduler.py new file mode 100644 index 0000000..cce7d7f --- /dev/null +++ b/tests/test_batch1_scheduler.py @@ -0,0 +1,97 @@ +import os +import sqlite3 +import subprocess +import sys + +import pytest +from backend import database, scheduler +from backend.job_lock import exclusive_job_lock + + +@pytest.fixture +def job_db(tmp_path, monkeypatch): + path = str(tmp_path / "scheduler.db") + monkeypatch.setattr(database, "DB_FILE", path) + database.init_db() + return path + + +def test_retry_then_success_and_skip_same_key(job_db, monkeypatch): + calls, waits = [], [] + def refresh(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + raise RuntimeError("first attempt") + return {"saved_count":1,"status":"succeeded"} + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",refresh) + cfg = scheduler.SchedulerConfig(actor="planner",max_attempts=3,retry_seconds=2) + assert scheduler.run_scheduled_refresh(cfg,job_key="fixed",wait=waits.append)["status"] == "succeeded" + assert scheduler.run_scheduled_refresh(cfg,job_key="fixed")["status"] == "skipped" + assert len(calls) == 2 and waits == [2] + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT status,attempts,error FROM scheduled_jobs").fetchone() == ("succeeded",2,None) + + +def test_partial_failure_can_retry_same_key_later(job_db, monkeypatch): + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:{"status":"partial_failure"}) + cfg = scheduler.SchedulerConfig(actor="planner",max_attempts=2,retry_seconds=0) + assert scheduler.run_scheduled_refresh(cfg,job_key="retry")["status"] == "failed" + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:{"status":"succeeded"}) + assert scheduler.run_scheduled_refresh(cfg,job_key="retry")["status"] == "succeeded" + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT attempts FROM scheduled_jobs").fetchone()[0] == 3 + + +def test_permission_revocation_does_not_retry(job_db, monkeypatch): + def revoked(**kwargs): + raise PermissionError("revoked") + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",revoked) + assert scheduler.run_scheduled_refresh(scheduler.SchedulerConfig(actor="planner"),job_key="revoked")["status"] == "failed" + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT attempts FROM scheduled_jobs").fetchone()[0] == 1 + + +def test_cross_process_lock_and_crash_release(job_db): + code = "from backend.job_lock import exclusive_job_lock; import sys;\nwith exclusive_job_lock(sys.argv[1], 'scheduler') as acquired: print(acquired)" + with exclusive_job_lock(job_db,"scheduler") as acquired: + assert acquired + result = subprocess.run([sys.executable,"-c",code,job_db],capture_output=True,text=True,timeout=30) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "False" + result = subprocess.run([sys.executable,"-c",code,job_db],capture_output=True,text=True,timeout=30) + assert result.returncode == 0 and result.stdout.strip() == "True" + crash = "from backend.job_lock import exclusive_job_lock; import sys,os;\nwith exclusive_job_lock(sys.argv[1], 'scheduler') as acquired: os._exit(17 if acquired else 18)" + assert subprocess.run([sys.executable,"-c",crash,job_db],timeout=30).returncode == 17 + with exclusive_job_lock(job_db,"scheduler") as acquired: + assert acquired + + +def test_abandoned_running_record_recovered(job_db,monkeypatch): + with sqlite3.connect(job_db) as conn: + conn.execute("INSERT INTO scheduled_jobs(job_key,status,attempts) VALUES ('crashed','running',1)") + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:{"status":"succeeded"}) + assert scheduler.run_scheduled_refresh(scheduler.SchedulerConfig(actor="planner"),job_key="crashed")["status"] == "succeeded" + + +def test_background_is_opt_in_and_isolation_overrides_enable(monkeypatch): + monkeypatch.setenv("ERP_SCHEDULER_ACTOR","planner") + monkeypatch.setenv("ERP_SCHEDULER_ENABLED","0") + assert scheduler.start_background_jobs() is False + monkeypatch.setenv("ERP_SCHEDULER_ENABLED","1") + monkeypatch.setenv("ERP_ISOLATED_TEST","1") + assert scheduler.start_background_jobs() is False + + +def test_configuration_rejects_invalid_interval(monkeypatch): + monkeypatch.setenv("ERP_SCHEDULER_INTERVAL_SECONDS","0") + with pytest.raises(ValueError): + scheduler.SchedulerConfig.from_env() + + +def test_scheduler_busy_does_not_start_another_job(job_db,monkeypatch): + monkeypatch.setattr(scheduler,"refresh_supply_chain_news_once",lambda **k:pytest.fail("Duplicate job")) + with exclusive_job_lock(job_db,"scheduler") as acquired: + assert acquired + assert scheduler.run_scheduled_refresh(scheduler.SchedulerConfig(actor="planner"),job_key="busy")["status"] == "busy" + with sqlite3.connect(job_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM scheduled_jobs").fetchone()[0] == 0 diff --git a/tests/test_batch1_ui.py b/tests/test_batch1_ui.py new file mode 100644 index 0000000..06d5458 --- /dev/null +++ b/tests/test_batch1_ui.py @@ -0,0 +1,53 @@ +import sqlite3 + +from streamlit.testing.v1 import AppTest + +from backend import database, supply_chain_risk as risk + + +def prepare(tmp_path, monkeypatch): + path = str(tmp_path / "ui.db") + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + conn.execute("DELETE FROM suppliers") + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES ('UI','UI','台灣','北區',25,121,1)") + monkeypatch.setattr("backend.llm_client.complete_text", lambda *a, **k: + '{"摘要":"測試摘要","更新":[{"地區":"台灣 北區","風險":0}],' + '"事件":[{"類型":"交通","國家":"台灣","地區":"北區","延遲天數":0,"描述":"零延遲"}]}') + return path + + +def test_streamlit_generate_apply_and_new_session_reload(tmp_path, monkeypatch): + path = prepare(tmp_path, monkeypatch) + script = "from frontend.components.supply_map import render_supply_chain_map\nrender_supply_chain_map('', '', actor='planner')" + at = AppTest.from_string(script, default_timeout=20).run() + assert not at.exception + at.button(key="heatmap_ai_btn").click().run() + assert not at.exception + assert not at.button(key="apply_ai_risk_btn").disabled + at.button(key="apply_ai_risk_btn").click().run() + assert not at.exception + assert any("至資料庫" in s.value for s in at.success) + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT risk_pct,estimated_delay FROM risk_heatmap WHERE region_key='台灣|北區'").fetchone() == (0,0) + fresh = AppTest.from_string(script, default_timeout=20).run() + assert not fresh.exception + rows = risk.get_risk_heatmap_data() + assert rows[0]["risk_pct"] == 0 and rows[0]["estimated_delay"] == 0 + + +def test_streamlit_failed_news_shows_original_and_disables_registration(tmp_path, monkeypatch): + path = prepare(tmp_path, monkeypatch) + with sqlite3.connect(path) as conn: + conn.execute("""INSERT INTO supply_chain_news(title,summary,country,source,published_at, + analysis_status,analysis_error,is_relevant,estimated_delay) + VALUES ('Failed fixture','Original body retained','台灣','fixture',date('now'), + 'failed','provider_error',NULL,NULL)""") + at = AppTest.from_string("from frontend.components.risk_dashboard import render_intelligence_gathering\nrender_intelligence_gathering(actor='planner')", default_timeout=20).run() + assert not at.exception + buttons = [b for b in at.button if "登錄" in b.label] + assert len(buttons) == 2 and all(b.disabled for b in buttons) + assert any("未知" in c.value and "failed" in c.value for c in at.caption) + assert any("Original body retained" in m.value for m in at.markdown) diff --git a/tests/test_prompt_p1p2.py b/tests/test_prompt_p1p2.py index f6c64ca..b8b82d7 100644 --- a/tests/test_prompt_p1p2.py +++ b/tests/test_prompt_p1p2.py @@ -64,24 +64,32 @@ def test_gate_drops_unknown_region(): assert out == [] # code-side gate:不在清單也不可展開 → 丟棄 -def test_gate_soft_mode_when_no_suppliers(): +def test_gate_fails_closed_when_no_suppliers(): fallback = ["(目前無正式供應商據點資料,請跳過風險建議清單)"] out = _gate_heatmap_updates([{"地區": "任何地方", "風險": "55%"}], fallback, {}) - assert out == [{"display_name": "任何地方", "risk_pct": 55.0}] # 寬鬆模式全收 + assert out == [] # 無據點或非數字風險不得套用 def test_coerce_events_types_and_defaults(): out = _coerce_heatmap_events([ - {"類型": "罷工", "地區": "台灣 北區", "國家": "台灣", "延遲天數": "14", "描述": "港口罷工"}, + {"類型": "罷工", "地區": "台灣 北區", "國家": "台灣", "延遲天數": 14, "描述": "港口罷工"}, {"類型": None, "延遲天數": "not-a-number"}, ]) assert out[0] == {"event_type": "罷工", "region": "台灣 北區", "country": "台灣", "impact_days": 14, "description": "港口罷工"} - assert out[1]["event_type"] == "其他" and out[1]["impact_days"] == 14 + assert len(out) == 1 # 非法天數不再補成 14 天 -def test_heatmap_flow_end_to_end(monkeypatch): +def test_heatmap_flow_end_to_end(monkeypatch, tmp_path): """整條 get_heatmap_ai_summary:假 JSON 回應 → 三元組契約不變。""" + import sqlite3 + from backend import database, supply_chain_risk + path = str(tmp_path / "heatmap-contract.db") + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(supply_chain_risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,is_official) VALUES ('P1P2','Fixture','台灣','北區',1)") import backend.llm_client as lc monkeypatch.setattr(lc, "complete_text", lambda *a, **kw: '{"摘要": "### 摘要\\n台灣風險升高。", ' diff --git a/tests/test_supply_chain_authorization.py b/tests/test_supply_chain_authorization.py index 9cda089..30d7ef3 100644 --- a/tests/test_supply_chain_authorization.py +++ b/tests/test_supply_chain_authorization.py @@ -321,7 +321,7 @@ def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): "summary": "Delay expected", "url": "https://example.test/news", "source": "test", - "published_at": "2026-07-20 00:00", + "published_at": __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M"), "relevance_tag": "supply_chain", } ], @@ -331,9 +331,10 @@ def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): "batch_infer_affected_region_from_news", lambda **kwargs: [ { + "analysis_status": "succeeded", "is_relevant": True, "estimated_delay": 5, - "event_type": "delay", + "event_type": "交通", "country": "Taiwan", "region": "Taichung", "chinese_summary": "Test summary", @@ -342,12 +343,8 @@ def test_planner_news_refresh_applies_heatmap_update(supply_db, monkeypatch): ) monkeypatch.setattr( risk, - "get_heatmap_ai_summary", - lambda **kwargs: ( - "Authorized update", - [{"display_name": "Taiwan Taichung", "risk_pct": 88}], - [], - ), + "get_heatmap_ai_analysis", + lambda **kwargs: dict(analysis_status="succeeded", summary="Authorized update", updates=[{"display_name": "Taiwan Taichung", "risk_pct": 88}], events=[]), ) result = news.refresh_news_for_countries(["Taiwan"], actor="planner") @@ -367,7 +364,10 @@ def test_news_refresh_does_not_swallow_midflight_authorization_failure( supply_db, monkeypatch ): monkeypatch.setattr("backend.llm_client.llm_available", lambda: True) - monkeypatch.setattr(news, "fetch_country_news", lambda *args, **kwargs: []) + def revoked_during_fetch(*args, **kwargs): + monkeypatch.setattr(news, "require_capability", lambda *a, **k: (_ for _ in ()).throw(PermissionError("entitlement was revoked"))) + return [] + monkeypatch.setattr(news, "fetch_country_news", revoked_during_fetch) monkeypatch.setattr( risk, "get_heatmap_ai_summary", From e46e20905dda15a59910be1f229e45cfd0e7aa25 Mon Sep 17 00:00:00 2001 From: weck06 Date: Sun, 13 Sep 2026 19:18:27 +0800 Subject: [PATCH 03/19] Add six-entry live RSS acceptance and offline replay report --- docs/batch1-live-news-acceptance.md | 66 ++++++++++ scripts/accept_live_news.py | 192 ++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 docs/batch1-live-news-acceptance.md create mode 100644 scripts/accept_live_news.py diff --git a/docs/batch1-live-news-acceptance.md b/docs/batch1-live-news-acceptance.md new file mode 100644 index 0000000..3afe563 --- /dev/null +++ b/docs/batch1-live-news-acceptance.md @@ -0,0 +1,66 @@ +# 第一階段追加驗收:6 筆真實 RSS 資料 + +抓取時間:2026-09-13 19:14(台灣時間)。原修補提交:`5c9aa36`。 + +**結果:14 項流程檢查通過;使用同一份擷取資料離線重播,14 項也全部通過。** + +本次外部來源是 Google News RSS。使用者指出的設定是 `GEMINI_API_KEY`,它不是 GNews 新聞金鑰;本次沒有使用 GNews 或 Gemini API。模型回應均為驗收模擬,沒有付費模型呼叫、背景排程或真實通知。 + +## 取得的 6 筆資料 + +以下是 RSS 條目的中文主題摘要;來源標示取自 RSS 標題,時間是 RSS 標示時間,未獨立核對各出版者頁面。 + +| # | 主題/來源連結 | RSS 標示來源 | RSS 標示時間 | +|---|---|---|---| +| 1 | [伊朗衝突追蹤](https://news.google.com/rss/articles/CBMingFBVV95cUxQMWduRExjZXhMUjFuYjV4Zzc0LS11OHVKUEhXSlpUNUdFMm9MVGlJeFd5eGh1ZWpEWWhFNEdJX0tOVTlJbnZ5NmpBQ3dYeGNpSTBOSWtISUduak5WSHhfQUhQcHBKbVpLdG1IVi1ySWVGd05NM18yektrem1TM3ZvaV9xVjR2eC04cE9RZ3FZajNCZWFHNzBaS0hhSS02dw?oc=5) | Council on Foreign Relations | 2026-09-09 00:00 | +| 2 | [美國 2022 年降低通膨法政策資料](https://news.google.com/rss/articles/CBMiZkFVX3lxTE1WZ1JFc24yV0d0Z09fdUF1ZTluaWJVVmZwY3I3SnFjb01GT3dUbk5zemY1OFAzeUU5aFVNNHQ3MlZ0MVlPS0hSVGV5eFZJWGRTdmFpNS1ueUVsbjQzUVoxWjRycmo0UQ?oc=5) | 美國能源部 | 2026-09-10 16:21 | +| 3 | [九一一事件 25 年後的恐怖主義情勢分析](https://news.google.com/rss/articles/CBMijwFBVV95cUxPaEFiSFBMOTZIeFNqLUJVRzFYcHdaV3ZoTHZ4NldxTnY2NlJwa3BuMDNqV21PUHUyWjRLV1Q1UXFNbGZYYTBiRnNaSEF5WC1BNHBXYUtTTG9ocWFrTUVYcHZtUGc1NklSNWs5T3lMeF9iMTdWbzdZaEtoOFNmSXVDX2JIMnNVd3VQc1JYOGRyMA?oc=5) | Atlantic Council | 2026-09-10 10:00 | +| 4 | [美國宣稱摧毀五艘伊朗油輪的報導](https://news.google.com/rss/articles/CBMirgFBVV95cUxNeTFBdWRVb3JiRXFnODNwakw4d3IxdlNzZUFnSXhKN1BVVTc4VXlZMl9sYzJFUGtGWnRyVU9wSi1Ua1FoVUhtYVNaaDdhMG9MOE1nd1F3RHlVNHJWSlYxbTY4RVdTQTFpSEtJYnV3TmpxR2FsS0x4RGVOdjZMak1HLWN0YzVmamxyYkZncy1jOEpybGloT1NxVzlPZG5jUlFwY082VUtndEFVUnVHUkHSAcIBQVVfeXFMUFVIY0NEVmtnbUZPTWpkQXVTZER5UXZDa2VuaFQwWmlfOGFrcmI2TXJOSmdaVW4tcVdBUXpyRTVnX2l0dE5Rb2VhdUo0Wno4UUV3dXVKWkY4eF9WdVRrejk3OW1YaWpIM3NLUUxab2h3azBWd3dqblZpZDRzel9USUFiak1UbElOY3NyNWtZbEprZ0N5ZnpfQmJIVTc2R3FMOTVEWS1VWWUxanpqS0ptZzZOWHZTTHNER05mX216SjRHcHc?oc=5) | EL PAÍS | 2026-09-09 09:54 | +| 5 | [荷姆茲海峽重新通航進展與伊朗戰事的報導](https://news.google.com/rss/articles/CBMixwFBVV95cUxPVFowaUI1M1lFY0RPVk9JMjY0XzdTdkVoV0JxZzZjcEZlZHM2eWpCZXE2N3JMajhqWUl5RlpCOGlsYjlqeHlvczN5ZHAtUVVVVnE4djF2LVFqUkxYbnNSVmRtQ3N2Z1pDbFZYM0s0N213RkU2Zjh4elFjZVpiUFEwc0FXNFRyd1RYTXlFV1ZLc01WbXpVSkxHMmk2UFp6aWM1Tk4tMThWX1I1YUlyU0FzSGJvU2FyMGMyYUhrRE01MVVuanRZdTJr0gHMAUFVX3lxTE5DMm15WVlJdkwwYXVLcy1KdS1mdlVGMmRST2tNSldPOFZWNGRaQmNHREFJWWVIWkVfdnFoSXJTVDU4WEZuWVFGenV5SzRxNXRrYTQ3cFVTTEtBdlphaC1KdzdORG5UUndqOU5Cb21IY2s0Wm11eTIwaGg3YlpUN09RQlpSMFhVRnZfR2dEWGZoNDVLc18tblg3R3pBRjAycWRtYURiTnlZOERPWWRVTUx5ZmlYSFNtOHU1NkVSODJIdzVRZGRBMi1aZmFscg?oc=5) | PBS | 2026-09-11 16:37 | +| 6 | [美國第十三修正案與廢除奴役倡議](https://news.google.com/rss/articles/CBMiYkFVX3lxTE1PSUFrNTR1QWtsQUp5MVNpR0FScFNTdlUyS2hYQjNZV0pQV3kzWTJpNjZrRmZIUXhYbG41UWtsN21abm1COTFfbGZXMlkwTjljeUVMdGs0MzloRXpWblJrWExn?oc=5) | Freedom United | 2026-09-09 19:01 | + +這 6 筆都來自實際外部回應,並非固定測試新聞。但第 2、6 筆從標題看屬政策/倡議資料,其他也包含背景追蹤與分析。因此本次應視為「6 筆真實 RSS 來源資料」的流程驗收,不能宣稱已完成 6 篇全文新聞的 AI 風險判讀。 + +## 追加驗證結果 + +| 驗證 | 結果 | +|---|---| +| 首次保存 | 新增 6 筆,狀態 pending,延遲未知 | +| 模擬模型服務失敗 | 6 筆標記 failed,不補入 7 天,不成為有效風險輸入 | +| 12 筆重複輸入 | 資料庫仍為 6 筆,逐篇分析僅執行 6 筆 | +| 嚴格輸出驗證 | 0、未知、5 天、無關資料分開處理;字串天數與負數遭拒絕 | +| 登錄事件 | 未知或失敗的新聞不能登錄為已知風險事件 | +| 重試 | 只重試 2 筆失敗資料;已成功資料不重做逐篇分析 | +| 再次刷新 | 新增 0 筆、逐篇分析 0 筆 | +| 原始資料保存 | RSS 標題、摘要、URL、來源、時間、搜尋國別在所有步驟後均保持原值 | +| 一次性排程測試 | 首次失敗後重試成功;相同工作識別碼再次執行為 skipped | +| 同時執行防護 | 持有新聞鎖時,另一刷新回傳 busy | +| 地區一致性 | 用合成供應商/採購/庫存驗證台灣北區只匹配北區 | +| 零值保存 | 0% 與 0 天在重新連接 SQLite 後仍保留 | +| 隔離限制 | 背景排程停用;擷取完成後阻擋所有後續對外連線 | + +上述天數、地區與百分比是刻意注入的驗收案例,不是對這些新聞的真實風險結論。原始 RSS 搜尋國別「美國」亦不能直接等同事件受影響地區。 + +## 本次發現的限制 + +- 6 筆 RSS 摘要基本上是標題與來源名稱的重複,不能替代新聞全文。 +- 目前 RSS 搜尋仍可能包含政策、背景或倡議頁面;本次沒有擴大實作來源品質篩選。 +- 原始文字中的 HTML entity(例如 ` `)保留在擷取資料內。 +- 尚未驗證 GNews API、Gemini 模型判斷品質、文章全文擷取或真實事件延遲推估。 + +本次沒有發現第一階段資料處理斷言失敗;也沒有改動既有業務程式。新增的只有一次性驗收腳本與此報告。第一階段原有 387 項測試結果維持在既有報告,本次沒有把 14 項腳本斷言混算為 pytest 測試數。 + +## 驗收資料與重播 + +- 資料庫:`C:\新EPR系統\ERP-batch1-isolated\.isolated\live-news-20260913T111447765066Z\acceptance.db` +- 原始擷取:`C:\新EPR系統\ERP-batch1-isolated\.isolated\live-news-20260913T111447765066Z\news-capture.json` +- 各階段計數與狀態:`C:\新EPR系統\ERP-batch1-isolated\.isolated\live-news-20260913T111447765066Z\acceptance-results.json` +- 離線重播結果:`.isolated/live-news-20260913T111702955359Z/acceptance-results.json`。 + +```powershell +Set-Location 'C:\新EPR系統\ERP-batch1-isolated' +$py = 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' +& $py scripts/accept_live_news.py --snapshot '.isolated/live-news-20260913T111447765066Z/news-capture.json' +``` + +此命令不再抓取外部新聞,會建立新的獨立驗收資料庫。原工作區與原有檢查資料庫均未修改,`.env` 未修改,未合併、推送或部署。 diff --git a/scripts/accept_live_news.py b/scripts/accept_live_news.py new file mode 100644 index 0000000..4161544 --- /dev/null +++ b/scripts/accept_live_news.py @@ -0,0 +1,192 @@ +"""Capture six real articles once; replay batch-one acceptance offline with mocked AI. + +Never loads provider credentials other than GNEWS_API_KEY, never starts a server +or background scheduler, and never uses the main workspace database. +""" +import argparse +from copy import deepcopy +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import sys +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +def write_json(path, data): + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", choices=("gnews", "rss"), default="gnews") + parser.add_argument("--env-file", type=Path) + parser.add_argument("--country", default="美國") + parser.add_argument("--snapshot", type=Path, help="Replay an earlier six-article capture without network") + args = parser.parse_args() + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + output = ROOT / ".isolated" / f"live-news-{stamp}" + output.mkdir(parents=True, exist_ok=False) + db_path = output / "acceptance.db" + key = os.getenv("GNEWS_API_KEY", "").strip() + if args.env_file: + from dotenv import dotenv_values + key = (dotenv_values(args.env_file).get("GNEWS_API_KEY") or "").strip() + # All other inherited credentials and paid model configuration are ignored. + for name in ("GNEWS_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", + "LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LLM_MODEL", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): + os.environ.pop(name, None) + os.environ.update(ERP_DB_PATH=str(db_path), ERP_DEMO_MODE="1", ERP_ISOLATED_TEST="1", + ERP_SCHEDULER_ENABLED="0", ERP_SCHEDULER_ACTOR="planner", + LITELLM_LOCAL_MODEL_COST_MAP="True", OTEL_SDK_DISABLED="true") + from backend import database, supply_chain_news as news, supply_chain_risk as risk, scheduler + from backend.isolated_runtime import block_external_network + from backend.news_store import identity_keys + from backend.job_lock import exclusive_job_lock + + # The only external operation is this explicit news capture. No automatic RSS + # fallback here: the report must identify which provider actually succeeded. + try: + if args.snapshot: + capture = json.loads(args.snapshot.read_text(encoding="utf-8")) + articles = capture["articles"] + else: + if args.source == "gnews": + if not key or key.startswith("replace_"): + raise ValueError("GNEWS_API_KEY is not configured in the selected file") + articles = news._fetch_via_gnews_api(args.country, key, max_results=6, within_days=7) + else: + articles = news._fetch_via_rss(args.country, max_results=6, within_days=7) + capture = dict(source=args.source, search_country=args.country, + captured_at=datetime.now(timezone.utc).isoformat(), articles=articles) + except Exception as exc: + # requests exceptions may contain the API key in their URL; never print them. + cause = exc.__cause__ + response = getattr(cause, "response", None) + error = dict(status="capture_failed", source=args.source, error_type=type(exc).__name__, + http_status=getattr(response, "status_code", None)) + write_json(output / "capture-error.json", error) + print(json.dumps(error)) + print(f"Output: {output}") + return 1 + finally: + key = "" + block_external_network() + + assert len(articles) == 6, f"Expected six articles; provider returned {len(articles)}" + assert len({identity_keys(a)[0] for a in articles}) == 6, "Provider returned duplicate URLs" + assert all(a.get("title") and a.get("url") and a.get("published_at") for a in articles) + write_json(output / "news-capture.json", capture) + database.init_db() + with sqlite3.connect(db_path) as conn: + conn.execute("UPDATE suppliers SET is_official=0") + for sid, country, region in (("LIVE-N", "台灣", "北區"), ("LIVE-S", "台灣", "南區"), ("LIVE-J", "日本", "北區")): + conn.execute("INSERT INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES (?,?,?,?,25,121,1)", (sid, sid, country, region)) + conn.execute("INSERT INTO inventory(product_id,name,stock,reorder_point,daily_sales) VALUES (?,?,10,5,3)", (sid, sid)) + conn.execute("INSERT INTO purchase_orders(po_id,supplier_id,status,total_amount) VALUES (?,?,'pending',100)", (sid, sid)) + conn.execute("INSERT INTO purchase_order_items(po_id,product_id,qty,unit_price) VALUES (?,?,1,100)", (sid, sid)) + + checks = [] + phases = {} + def checked(name, condition): + assert condition, name + checks.append(name) + + def rows(): + with sqlite3.connect(db_path) as conn: + conn.row_factory = sqlite3.Row + return [dict(r) for r in conn.execute("SELECT * FROM supply_chain_news ORDER BY id")] + + simulated = {article["title"]: i for i, article in enumerate(articles)} + mode = "failure" + analyzed_titles = [] + def mock_complete(prompt, **kwargs): + if kwargs.get("tag") == "analysis:heatmap": + return json.dumps({"摘要": "驗收模擬摘要,不代表真實新聞風險。", "更新": [], "事件": []}, ensure_ascii=False) + if mode == "failure": + raise RuntimeError("Simulated provider outage") + result = [] + for news_id, body in re.findall(r"【新聞編號 (\d+)】\n(.*?)(?=【新聞編號|$)", prompt, re.S): + title = body.splitlines()[0] + index = simulated[title] + analyzed_titles.append(title) + delay = [0, None, 5, 0, "7", -1][index] if mode == "mixed" else 0 + result.append({"news_id": int(news_id), "相關性": "NO" if index == 3 else "YES", + "國家": "台灣", "地區": "北區", "事件類型": "交通", "預計延遲": delay, + "繁體中文簡要": f"[驗收模擬,非新聞風險判斷] 測試案例 {index + 1}"}) + return json.dumps({"results": result}, ensure_ascii=False) + + country = capture.get("search_country", args.country) + with patch.object(news, "fetch_country_news", return_value=deepcopy(articles)), \ + patch("backend.llm_client.complete_text", side_effect=mock_complete): + with patch("backend.llm_client.llm_available", return_value=False): + phases["pending"] = news.refresh_news_for_countries([country], actor="planner") + checked("Six real articles saved as pending with unknown delay", len(rows()) == 6 and all(r["analysis_status"] == "pending" and r["estimated_delay"] is None for r in rows())) + phases["outage"] = news.refresh_news_for_countries([country], actor="planner") + checked("Provider failure cannot become a seven-day risk", all(r["analysis_status"] == "failed" and r["is_relevant"] is None and r["estimated_delay"] is None for r in rows())) + checked("Failed news excluded from risk inputs", not news.get_news_from_db(analyzed_only=True) and risk.get_active_risk_events().empty) + mode = "mixed" + with patch.object(news, "fetch_country_news", return_value=deepcopy(articles + articles)): + phases["strict_validation"] = news.refresh_news_for_countries([country], actor="planner") + mixed_rows = rows() + phases["strict_validation_states"] = [dict(id=r["id"], analysis_status=r["analysis_status"], is_relevant=r["is_relevant"], estimated_delay=r["estimated_delay"], analysis_error=r["analysis_error"]) for r in mixed_rows] + checked("Twelve replayed entries deduplicated before six analyses", phases["strict_validation"]["duplicate_count"] == 12 and len(analyzed_titles) == 6 and len(mixed_rows) == 6) + checked("Zero, unknown, delay, irrelevant, malformed remain distinct", [r["estimated_delay"] for r in mixed_rows] == [0, None, 5, 0, None, None] and [r["analysis_status"] for r in mixed_rows] == ["succeeded"] * 4 + ["failed"] * 2) + for source in (mixed_rows[1], mixed_rows[4], mixed_rows[5]): + try: + risk.add_risk_event("交通", "北區", "台灣", 7, "Must reject", source["id"], actor="planner") + except ValueError: + continue + raise AssertionError("Unknown or failed news was registered as risk") + checked("Unknown and failed news cannot register an event", True) + mode = "recovery" + analyzed_titles.clear() + phases["retry"] = news.refresh_news_for_countries([country], actor="planner") + checked("Only the two failed analyses retry", len(analyzed_titles) == 2 and all(r["analysis_status"] == "succeeded" for r in rows())) + analyzed_titles.clear() + phases["deduped_replay"] = news.refresh_news_for_countries([country], actor="planner") + checked("Successful news is not reanalyzed or inserted twice", not analyzed_titles and phases["deduped_replay"]["saved_count"] == 0 and len(rows()) == 6) + for raw, stored in zip(articles, rows()): + assert all(raw.get(k) == stored.get(k) for k in ("title", "summary", "url", "source", "published_at", "country", "region")) + checked("Original news content preserved through all failures and retries", True) + + cfg = scheduler.SchedulerConfig(actor="planner", max_attempts=2, retry_seconds=0) + scheduled_calls = [] + def scheduled_refresh(**kwargs): + scheduled_calls.append(1) + if len(scheduled_calls) == 1: + raise RuntimeError("Simulated retry") + return news.refresh_news_for_countries([country], actor=kwargs["actor"]) + with patch.object(scheduler, "refresh_supply_chain_news_once", side_effect=scheduled_refresh): + phases["scheduler"] = scheduler.run_scheduled_refresh(cfg, job_key="live-acceptance") + phases["scheduler_replay"] = scheduler.run_scheduled_refresh(cfg, job_key="live-acceptance") + checked("One-shot scheduler retries and skips the completed key", len(scheduled_calls) == 2 and phases["scheduler"]["status"] == "succeeded" and phases["scheduler_replay"]["status"] == "skipped") + with exclusive_job_lock(db_path, "news") as locked: + checked("Overlapping refresh is blocked", locked and news.refresh_news_for_countries([country], actor="planner")["status"] == "busy") + + checked("Supplier, PO and stockout scope agree on Taiwan north only", + [r["supplier_id"] for r in risk.get_affected_suppliers_by_event("北區", "台灣")] == ["LIVE-N"] + and [r["po_id"] for r in risk.get_impacted_pos("北區", "台灣")] == ["LIVE-N"] + and [r["product_id"] for r in risk.get_stockout_alerts_for_event("北區", "台灣", 5)] == ["LIVE-N"]) + risk.apply_heatmap_updates([dict(display_name="台灣 北區", risk_pct=0, estimated_delay=0)], "[驗收模擬] 零值保存", actor="planner") + with sqlite3.connect(db_path) as conn: + checked("Zero percent and zero days survive database reconnect", conn.execute("SELECT risk_pct,estimated_delay FROM risk_heatmap WHERE region_key='台灣|北區'").fetchone() == (0, 0)) + checked("Background scheduling remains disabled", scheduler.start_background_jobs() is False) + report = dict(status="passed", checked_at=datetime.now(timezone.utc).isoformat(), source=capture["source"], + capture_sha256=hashlib.sha256((output / "news-capture.json").read_bytes()).hexdigest(), + article_count=6, analysis_mode="mocked; not real news risk assessment", database=str(db_path), + checks=checks, phases=phases, articles=[{k:a.get(k) for k in ("title", "url", "source", "published_at")} for a in articles]) + write_json(output / "acceptance-results.json", report) + print(json.dumps(dict(status="passed", article_count=6, checks_passed=len(checks), source=capture["source"], output=str(output)), ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e9fe57de0fec057cf3ef1679f06e41f6d5f010f4 Mon Sep 17 00:00:00 2001 From: weck06 Date: Sun, 13 Sep 2026 19:49:09 +0800 Subject: [PATCH 04/19] Expose live news acceptance snapshot in isolated UI --- app.py | 3 +- backend/isolated_runtime.py | 11 +++++ frontend/components/news_acceptance.py | 65 ++++++++++++++++++++++++++ frontend/components/risk_dashboard.py | 17 +++++-- frontend/page_supply_chain_risk.py | 2 + scripts/run_isolated.py | 33 +++++++++++-- tests/test_batch1_ui.py | 28 +++++++++++ 7 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 frontend/components/news_acceptance.py diff --git a/app.py b/app.py index b885aa1..b7cc34f 100644 --- a/app.py +++ b/app.py @@ -33,7 +33,8 @@ # ── 頁面設定 ──────────────────────────────────────────────────────── st.set_page_config(page_title="進銷存安全系統", page_icon="🛡️", layout="wide") if os.getenv("ERP_ISOLATED_TEST") == "1": - st.warning("隔離測試環境|固定新聞與模擬 AI|不發送通知|背景排程關閉") + news_mode = "真實新聞快照" if os.getenv("ERP_NEWS_CAPTURE") else "固定新聞" + st.warning(f"隔離測試環境|{news_mode}與模擬 AI|不發送通知|背景排程關閉") # ── 全域 CSS ──────────────────────────────────────────────────────── st.markdown(""" diff --git a/backend/isolated_runtime.py b/backend/isolated_runtime.py index 665687d..efaf63c 100644 --- a/backend/isolated_runtime.py +++ b/backend/isolated_runtime.py @@ -4,6 +4,13 @@ import json import re import socket +from pathlib import Path + + +def news_capture(): + """Optional real-source snapshot selected by the local review launcher.""" + path = os.getenv("ERP_NEWS_CAPTURE", "") + return json.loads(Path(path).read_text(encoding="utf-8")) if path else None def block_external_network(): @@ -45,6 +52,10 @@ def getaddrinfo(host, *args, **kwargs): def fixture_news(country): + capture = news_capture() + if capture: + from .region_matching import normalize + return [dict(a) for a in capture["articles"] if normalize(a.get("country")) == normalize(country)] rows = [ ("zero", "港口恢復營運 [ZERO]", "確認目前無延遲。"), ("delay", "港口罷工 [DELAY]", "固定測試事件:延遲五天。"), diff --git a/frontend/components/news_acceptance.py b/frontend/components/news_acceptance.py new file mode 100644 index 0000000..7831ce8 --- /dev/null +++ b/frontend/components/news_acceptance.py @@ -0,0 +1,65 @@ +"""Show captured real news and acceptance evidence inside the risk workspace.""" +import json +import os +from pathlib import Path + +import pandas as pd +import streamlit as st + +from backend.isolated_runtime import news_capture +from backend.supply_chain_news import get_news_from_db + + +def render_news_acceptance(): + if os.getenv("ERP_ISOLATED_TEST") != "1": + return + capture = news_capture() + if not capture: + return + source = "GNews API" if capture["source"] == "gnews" else "Google News RSS" + report_path = os.getenv("ERP_NEWS_ACCEPTANCE", "") + report = json.loads(Path(report_path).read_text(encoding="utf-8")) if report_path else {} + st.subheader("📰 真實新聞追加驗收") + st.info(f"新聞來源:{source}|這是已抓取的真實新聞快照。AI 分析為模擬驗收,非實際風險判斷。") + st.caption(f"抓取時間:{capture['captured_at']}|此頁讀取獨立檢查資料庫;重播不再連線抓新聞。") + latest_attempt = Path(__file__).resolve().parents[2] / ".isolated" / "gnews-latest-attempt.json" + if latest_attempt.is_file(): + attempt = json.loads(latest_attempt.read_text(encoding="utf-8")) + if attempt.get("status") == "capture_failed": + st.warning(f"最近一次 GNews 擷取失敗(HTTP {attempt.get('http_status', '未知')}):{attempt.get('message', '請核對 API 設定')}。目前下表來源是 {source},不是該次 GNews 的成功結果。") + + rows = get_news_from_db(limit=1000) + captured_urls = {a["url"] for a in capture["articles"]} + rows = [r for r in rows if r["url"] in captured_urls] + c1, c2, c3 = st.columns(3) + c1.metric("本批真實來源資料", len(capture["articles"])) + c2.metric("目前資料庫筆數", len(rows)) + c3.metric("匯入時流程驗收", f"{len(report.get('checks', []))} 項通過") + states = {"succeeded": "成功", "failed": "失敗", "pending": "待分析", "legacy_unverified": "舊資料待確認"} + table = [] + for row in rows: + table.append({"新聞標題": row["title"], "出版來源": row.get("source") or source, + "發布時間": row.get("published_at"), + "分析狀態(模擬)": states.get(row.get("analysis_status"), "未知"), + "延遲(模擬)": "未知" if row.get("estimated_delay") is None else f"{row['estimated_delay']} 天", + "原文連結": row["url"]}) + if table: + st.dataframe(pd.DataFrame(table), hide_index=True, width="stretch", + column_config={"原文連結": st.column_config.LinkColumn("原文", display_text="開啟來源")}) + with st.expander("逐則查看來源摘要"): + for row in rows: + st.markdown(f"**{row['title']}**") + st.write(row.get("summary") or "來源沒有提供摘要。") + st.caption(f"來源:{row.get('source') or source}|發布:{row.get('published_at') or '未知'}") + with st.expander("查看追加驗收過程"): + phases = report.get("phases", {}) + labels = {"pending": "初次保存", "outage": "模擬模型中斷", "strict_validation": "輸出驗證與去重", + "retry": "只重試失敗項目", "deduped_replay": "再次重播"} + records = [] + for key, label in labels.items(): + phase = phases.get(key, {}) + records.append({"步驟": label, "新增": phase.get("saved_count", 0), + "重複": phase.get("duplicate_count", 0), "分析成功": phase.get("analyzed_count", 0), + "分析失敗": phase.get("failed_count", 0)}) + st.dataframe(pd.DataFrame(records), hide_index=True, width="stretch") + st.caption("此處是抓取後的驗收紀錄;天數與地區是測試案例,不是新聞的實際影響。") diff --git a/frontend/components/risk_dashboard.py b/frontend/components/risk_dashboard.py index 04b469b..d6ccd73 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,4 +1,5 @@ from backend.region_matching import matches_location, split_location +import os import streamlit as st import re import pandas as pd @@ -63,6 +64,9 @@ def render_intelligence_gathering( st.subheader("🔍 即時全球情報與事件登錄") st.caption("透過 GNews/RSS 抓取全球供應鏈相關新聞,並利用 AI 自動偵測受影響國家、地區與事件類型(戰爭、氣候、罷工等)。") + from backend.isolated_runtime import news_capture + capture = news_capture() if os.getenv("ERP_ISOLATED_TEST") == "1" else None + # 更新即時新聞:依供應商國家從 GNews/RSS 抓取並寫入 DB _suppliers = get_suppliers_for_map() _countries = [] @@ -72,6 +76,9 @@ def render_intelligence_gathering( if not _countries: _countries = ["台灣", "日本", "美國", "南韓", "中國", "越南", "墨西哥"] + if capture: + _countries = list(dict.fromkeys(a["country"] for a in capture["articles"])) + col_time, col_cate, col_btn, col_help = st.columns([1, 1, 1, 2]) with col_time: time_options = {"7 天": 7, "30 天": 30, "90 天": 90} @@ -104,10 +111,12 @@ def render_intelligence_gathering( st.caption("系統會過濾不相關新聞,並參考過往紀錄推估延遲。") with col_btn: st.markdown("
", unsafe_allow_html=True) - if st.button("📡 更新即時新聞", key="refresh_news_btn", help="依各供應商國家抓取最近新聞,並由 AI 自動分析類別與延遲天數。"): + refresh_label = "🔁 重播本批真實新聞" if capture else "📡 更新即時新聞" + refresh_help = "使用已抓取的新聞快照,不重新連線;成功資料不重做逐篇分析。" if capture else "依各供應商國家抓取最近新聞,並由 AI 自動分析類別與延遲天數。" + if st.button(refresh_label, key="refresh_news_btn", help=refresh_help): with st.status("正在獲獲取供應鏈情報並由 AI 進行分析評分...") as status: - status.write("📡 正在平行抓取各國原始新聞與預過濾...") - status.write("🧠 正在啟動 Gemini 進行深度風險評估 (約 30-40 秒)...") + status.write("正在重播已抓取的真實新聞..." if capture else "📡 正在平行抓取各國原始新聞與預過濾...") + status.write("AI 使用模擬回應;不呼叫付費模型。" if os.getenv("ERP_ISOLATED_TEST") == "1" else "🧠 正在啟動模型進行風險評估...") res = refresh_news_for_countries( _countries, gemini_api_key=api_key or None, @@ -127,7 +136,7 @@ def render_intelligence_gathering( st.rerun() # 讀取現有新聞 - news_list_raw = get_news_from_db(limit=60, order_by_latest=True, within_days=within_days) + news_list_raw = get_news_from_db(limit=60, order_by_latest=True, within_days=None if capture else within_days) # 執行類別過濾與去重 filtered_news = [] diff --git a/frontend/page_supply_chain_risk.py b/frontend/page_supply_chain_risk.py index e506373..f5ba79d 100644 --- a/frontend/page_supply_chain_risk.py +++ b/frontend/page_supply_chain_risk.py @@ -30,6 +30,8 @@ def render( return st.markdown("
🌱 供應鏈與風險監控
", unsafe_allow_html=True) + from frontend.components.news_acceptance import render_news_acceptance + render_news_acceptance() if "analysis" not in sections and "what_if" not in sections: render_risk_overview() diff --git a/scripts/run_isolated.py b/scripts/run_isolated.py index 26c2619..32a29e2 100644 --- a/scripts/run_isolated.py +++ b/scripts/run_isolated.py @@ -14,12 +14,31 @@ def main(): parser.add_argument("--scheduler-once", metavar="KEY") parser.add_argument("--port", type=int, default=8511) parser.add_argument("--scenario", choices=("mixed", "success"), default="mixed") + parser.add_argument("--acceptance-dir", type=Path, + help="Show a real-news acceptance snapshot in its own review database") args = parser.parse_args() os.chdir(ROOT) # Always pick a local test DB; never inherit a user's production database setting. db_name = "erp-batch1.db" if args.scenario == "mixed" else "erp-batch1-success.db" db_path = ROOT / ".isolated" / db_name db_path.parent.mkdir(exist_ok=True) + os.environ.pop("ERP_NEWS_CAPTURE", None) + os.environ.pop("ERP_NEWS_ACCEPTANCE", None) + if args.acceptance_dir: + import sqlite3 + acceptance_dir = args.acceptance_dir.resolve() + if not acceptance_dir.is_relative_to((ROOT / ".isolated").resolve()): + parser.error("Acceptance directory must be inside this worktree's .isolated folder") + capture_path = acceptance_dir / "news-capture.json" + report_path = acceptance_dir / "acceptance-results.json" + original_db = acceptance_dir / "acceptance.db" + if not all(p.is_file() for p in (capture_path, report_path, original_db)): + parser.error("A complete news capture, acceptance report and database are required") + db_path = acceptance_dir / "preview.db" + if not db_path.exists(): + with sqlite3.connect(str(original_db)) as source, sqlite3.connect(str(db_path)) as target: + source.backup(target) + os.environ.update(ERP_NEWS_CAPTURE=str(capture_path), ERP_NEWS_ACCEPTANCE=str(report_path)) for key in ("OPENAI_API_KEY", "GEMINI_API_KEY", "GNEWS_API_KEY", "LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): os.environ.pop(key, None) os.environ["ERP_ISOLATED_SCENARIO"] = args.scenario @@ -34,11 +53,12 @@ def main(): # Stable official nodes for review, independent of the legacy random demo seed. import sqlite3 with sqlite3.connect(db_path) as conn: - conn.execute("UPDATE suppliers SET is_official=0 WHERE supplier_id NOT LIKE 'BATCH1-%'") - for sid, country, region, lat, lon in (("BATCH1-TWN","台灣","北區",25.03,121.56),("BATCH1-TWS","台灣","南區",22.63,120.30),("BATCH1-JP","日本","東京",35.68,139.69)): - conn.execute("INSERT OR IGNORE INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES (?,?,?,?,?,?,1)", (sid,f"測試供應商 {country} {region}",country,region,lat,lon)) + if args.acceptance_dir: + print("Real news snapshot loaded; analysis remains mocked; acceptance evidence is preserved.") + else: + seed_preview_suppliers(conn) print(f"ISOLATED ERP_DB_PATH={db_path}") - print("Fixed news/LLM fixtures; external network blocked; background scheduler disabled.") + print("News replay/LLM fixtures; external network blocked; background scheduler disabled.") if args.scheduler_once: from backend.scheduler import SchedulerConfig, run_scheduled_refresh result = run_scheduled_refresh(SchedulerConfig(actor="planner", max_attempts=2, retry_seconds=0), job_key=args.scheduler_once) @@ -51,5 +71,10 @@ def main(): cli.main() +def seed_preview_suppliers(conn): + conn.execute("UPDATE suppliers SET is_official=0 WHERE supplier_id NOT LIKE 'BATCH1-%'") + for sid, country, region, lat, lon in (("BATCH1-TWN","台灣","北區",25.03,121.56),("BATCH1-TWS","台灣","南區",22.63,120.30),("BATCH1-JP","日本","東京",35.68,139.69)): + conn.execute("INSERT OR IGNORE INTO suppliers(supplier_id,name,country,region,latitude,longitude,is_official) VALUES (?,?,?,?,?,?,1)", (sid,f"測試供應商 {country} {region}",country,region,lat,lon)) + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/test_batch1_ui.py b/tests/test_batch1_ui.py index 06d5458..bd5becf 100644 --- a/tests/test_batch1_ui.py +++ b/tests/test_batch1_ui.py @@ -51,3 +51,31 @@ def test_streamlit_failed_news_shows_original_and_disables_registration(tmp_path assert len(buttons) == 2 and all(b.disabled for b in buttons) assert any("未知" in c.value and "failed" in c.value for c in at.caption) assert any("Original body retained" in m.value for m in at.markdown) + + +def test_real_news_snapshot_is_visible_and_replay_uses_captured_items(tmp_path, monkeypatch): + import json + from backend.isolated_runtime import fixture_news + path = prepare(tmp_path, monkeypatch) + articles = [dict(country="美國", title=f"Captured article {i}", summary=f"Source description {i}", + url=f"https://publisher.test/article-{i}", source="Publisher", published_at="2026-09-13 10:00") for i in range(6)] + capture_path = tmp_path / "capture.json" + capture_path.write_text(json.dumps(dict(source="gnews", captured_at="2026-09-13T10:01:00Z", articles=articles)), encoding="utf-8") + report_path = tmp_path / "results.json" + report_path.write_text(json.dumps(dict(checks=[str(i) for i in range(14)], phases={})), encoding="utf-8") + monkeypatch.setenv("ERP_ISOLATED_TEST", "1") + monkeypatch.setenv("ERP_NEWS_CAPTURE", str(capture_path)) + monkeypatch.setenv("ERP_NEWS_ACCEPTANCE", str(report_path)) + from backend.supply_chain_news import save_news_to_db + assert save_news_to_db(articles) == 6 + assert fixture_news("美國") == articles + assert fixture_news("日本") == [] + at = AppTest.from_string("from frontend.components.news_acceptance import render_news_acceptance\nrender_news_acceptance()", default_timeout=20).run() + assert not at.exception + assert any("GNews API" in item.value and "模擬" in item.value for item in at.info) + assert [m.value for m in at.metric] == ["6", "6", "14 項通過"] + assert len(at.dataframe[0].value) == 6 + assert at.dataframe[0].value["新聞標題"].str.startswith("Captured article").all() + at = AppTest.from_string("from frontend.components.risk_dashboard import render_intelligence_gathering\nrender_intelligence_gathering(actor='planner')", default_timeout=20).run() + assert not at.exception + assert at.button(key="refresh_news_btn").label == "🔁 重播本批真實新聞" From b258d4406537c021be7444a3ee4aca5addc27021 Mon Sep 17 00:00:00 2001 From: weck06 Date: Sun, 13 Sep 2026 20:44:12 +0800 Subject: [PATCH 05/19] Fix GNews search scope and country filtering --- backend/supply_chain_news.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/supply_chain_news.py b/backend/supply_chain_news.py index c98477e..49e4127 100644 --- a/backend/supply_chain_news.py +++ b/backend/supply_chain_news.py @@ -67,9 +67,10 @@ def _fetch_via_gnews_api(country_name: str, api_key: str, max_results: int = 10, import requests except ImportError: return [] - name_en, code = COUNTRY_MAP.get(country_name, (country_name, None)) - # 關鍵字:擴充相關範疇確保不漏抓 - query = f"{name_en} (supply chain OR logistics OR shipping OR export OR tariff OR strike OR port OR pandemic OR war OR shortage OR conflict OR disruption OR natural disaster)" + _name_en, code = COUNTRY_MAP.get(country_name, (country_name, None)) + # 地區由 GNews 的 country 參數篩選;不要把國名放進 q,否則搜尋會 + # 要求文章正文同時包含國名與供應鏈詞,容易在短時間窗內得到 0 筆。 + query = "supply chain OR logistics OR shipping OR export OR tariff OR strike OR port OR shortage OR disruption" url = "https://gnews.io/api/v4/search" # 產出 GNews API 格式的時間 (YYYY-MM-DDTHH:mm:SSZ) @@ -81,9 +82,9 @@ def _fetch_via_gnews_api(country_name: str, api_key: str, max_results: int = 10, "apikey": api_key, "lang": "en", "from": from_date, - } - if code: - params["country"] = code + } + if code: + params["country"] = code.lower() try: r = requests.get(url, params=params, timeout=15) r.raise_for_status() From 407bf055c97b7efa3e08bb7d4b81cf501b54d8a6 Mon Sep 17 00:00:00 2001 From: ewiwi Date: Sun, 13 Sep 2026 23:48:33 +0800 Subject: [PATCH 06/19] 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 07/19] 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 08/19] 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 09/19] 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 10/19] =?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 11/19] =?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 From 7538a410b7d14e6f88e6f9ac98d789109e014576 Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 02:25:07 +0800 Subject: [PATCH 12/19] Document batch-one scope and draft PR review workflow --- docs/batch1-isolated-review.md | 6 +- docs/batch1-live-news-acceptance.md | 2 + docs/batch1-technical-workflow.md | 94 ++++++++++++++++ ...00\345\241\212\351\242\250\351\232\252.md" | 102 ++++++++++++++++++ 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 docs/batch1-technical-workflow.md create mode 100644 "docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" diff --git a/docs/batch1-isolated-review.md b/docs/batch1-isolated-review.md index 534809d..bbfd48e 100644 --- a/docs/batch1-isolated-review.md +++ b/docs/batch1-isolated-review.md @@ -2,6 +2,8 @@ 基準:`fcc2737`;分支:`codex/batch1-isolated`。 +2026-09-14 更新:功能驗收提交 `b258d44` 的 Windows 完整測試為 **388 passed(27.41 秒)**;後續 GNews 真實擷取 6 篇、模擬 AI 流程驗收 14 項通過。以下 387 項與本機網站健康檢查是最初修補的歷史紀錄,並非網站目前仍啟動。這批成果以 Draft PR 供協作審查,暫不合併或部署。技術步驟見 [技術流程與 PR 操作說明](batch1-technical-workflow.md)。 + ## 環境與範圍 - Worktree:`C:\新EPR系統\ERP-batch1-isolated`。 @@ -10,7 +12,7 @@ - 啟動器在任何後端匯入之前明確設定 `ERP_DB_PATH`,不繼承外部資料庫路徑;成功案例使用另一個 `erp-batch1-success.db`。 - 重用現有 Python 3.12.3 虛擬環境的已安裝依賴,未修改依賴版本。亦可在 worktree 建立自己的 `.venv`,安裝 `requirements.txt` 與 `requirements-dev.txt`,啟動器會優先使用它。 - 新聞與 LLM 使用固定模擬資料;關閉背景排程、不載入原工作區 `.env`,移除程序內供應商金鑰及代理設定,阻擋 Python 程序的對外 DNS/TCP 連線;只允許本機連線。未啟動 LINE Bot。 -- 未執行第二、三批功能;未合併、推送或部署。 +- 未執行第二、三批功能;初次驗收未推送,後續以 Draft PR 協作審查,暫不合併或部署。 ## 修改對照 @@ -82,4 +84,4 @@ pytest 在載入後端前將 `ERP_DB_PATH` 指向獨立暫存資料庫,且阻 本機測試平台為 Windows/Python 3.12.3。POSIX 檔案鎖分支未在本機執行。 -尚未驗證:真實 GNews/RSS 服務、付費模型品質、真實 LINE 通知、正式資料庫遷移、大量資料效能及長時間背景運行。這些均未在本次隔離測試中執行。瀏覽器地圖底圖的外部素材可用性也不屬於本次後端連線驗證。 +初次驗收未包含真實新聞服務;後續已分別完成 RSS 與 GNews 擷取驗收,AI 仍為模擬回應。尚未驗證:付費模型品質、真實 LINE 通知、正式資料庫遷移、大量資料效能及長時間背景運行。瀏覽器地圖底圖的外部素材可用性也不屬於本次後端連線驗證。 diff --git a/docs/batch1-live-news-acceptance.md b/docs/batch1-live-news-acceptance.md index 3afe563..0a5e92d 100644 --- a/docs/batch1-live-news-acceptance.md +++ b/docs/batch1-live-news-acceptance.md @@ -1,5 +1,7 @@ # 第一階段追加驗收:6 筆真實 RSS 資料 +> 本文記錄最初的 RSS 驗收。後續在 `b258d44` 已完成 GNews 真實擷取 6 篇與 14 項模擬 AI 流程檢查;功能分支完整 pytest 為 388 項通過。GNews 本機資料夾為 `.isolated/live-news-20260913T124300104848Z/`,資料庫、快照與詳細執行報告不包含於 Git。後續操作方式見 [技術流程與 PR 操作說明](batch1-technical-workflow.md)。下文提到的「尚未驗證 GNews」僅適用於最初 RSS 驗收當時。 + 抓取時間:2026-09-13 19:14(台灣時間)。原修補提交:`5c9aa36`。 **結果:14 項流程檢查通過;使用同一份擷取資料離線重播,14 項也全部通過。** diff --git a/docs/batch1-technical-workflow.md b/docs/batch1-technical-workflow.md new file mode 100644 index 0000000..93751f2 --- /dev/null +++ b/docs/batch1-technical-workflow.md @@ -0,0 +1,94 @@ +# 第一階段技術流程與 Draft PR 操作說明 + +本文件說明本批修補的執行流程、隔離方式、驗證證據,以及上傳 Draft PR 各步驟的用途。白話範圍說明見 [修改六項供應鏈區塊風險](修改六項供應鏈區塊風險.md)。 + +## 一、程式如何處理新聞與風險 + +| 步驟 | 技術做法 | 用途 | +| --- | --- | --- | +| 1. 驗證操作者 | 刷新入口要求 `risk.workspace.write`,透過 `actor` 載入有效權限;排程使用 `ERP_SCHEDULER_ACTOR` | 避免沒有權限的呼叫開始讀取與寫入風險工作區。 | +| 2. 取得執行鎖 | 新聞流程取得資料庫路徑對應的 OS 檔案鎖;排程另有自己的鎖 | 避免同一資料庫的不同程序同時刷新;程序結束或崩潰後由 OS 釋放鎖。 | +| 3. 取得新聞 | 一般模式使用 GNews,有需要時使用 RSS 備援;隔離模式讀固定資料或快照 | 將外部來源擷取和可重播的驗收分開。GNews 的來源國別不直接等於事件影響國別。 | +| 4. 找出重複新聞 | 正規化 URL、移除追蹤參數,另以標題、來源、發布日建立識別雜湊,搭配資料庫唯一索引 | 先找既有資料再分析,減少重複寫入與逐篇 AI 分析。 | +| 5. 保存原始內容 | `news_store.store_raw()` 保存原始標題、摘要、URL 等欄位;新資料為 `pending` | AI 失敗或重試時仍能追溯來源,不以 AI 內容覆蓋原文。 | +| 6. 驗證 AI 輸出 | 檢查 JSON、新聞編號、缺漏或重複結果、類型、數值範圍;熱圖更新另驗證合法據點 | 明確區分零、未知、失敗,不以預設 7 天補齊錯誤結果。格式驗證不等於證據充分或 AI 判斷正確。 | +| 7. 保存分析狀態 | 分開保存 `analysis_status`、錯誤代碼、分析摘要與分析地區 | 原始內容與分析結果可分別檢視;失敗資料不冒充成功結果。 | +| 8. 更新風險 | 熱圖 AI 輸入限成功且相關新聞;新聞來源事件另檢查有效來源與已知天數;查詢共用地區匹配函式 | 避免失敗新聞與錯誤地區影響熱圖、供應商、採購與缺貨判斷。 | +| 9. 套用與保存 | 審核與寫入共用據點解析;一次交易保存所選熱圖節點的風險、摘要、延遲 | 0% 與 0 天能保存;中途失敗整批回滾;新 session 可讀回資料庫值。 | +| 10. 排程記錄與重試 | `scheduled_jobs` 保存工作識別碼、狀態、次數與結果;成功識別碼跳過,失敗可重試,權限失敗停止重試 | 讓更新可追蹤,且重跑不代表重複執行已成功的工作。 | + +新聞分析狀態為 `pending`、`succeeded`、`failed`、`legacy_unverified`。這些狀態描述分析結果,不代表人工已確認或已核准,也不等於事件已讀/處理中狀態。 + +## 二、隔離環境如何保護主工作區 + +1. **Git worktree 與分支分開。** 第一階段位於 `ERP-batch1-isolated`,分支為 `codex/batch1-isolated`。worktree 共享 Git 物件與 refs,但有獨立檔案目錄與索引;fetch 更新 refs 不等於把程式合併到 main。 +2. **Python 執行環境與資料庫分開管理。** 本機重用既有虛擬環境的 Python/套件,未藉此共用正式資料庫。虛擬環境主要隔離套件,真正的資料隔離由明確的 `ERP_DB_PATH` 決定。 +3. **啟動時明確指定測試資料庫。** `run_isolated.py` 在載入後端前設定 `.isolated/` 下的資料庫路徑,不繼承外部正式資料庫設定。 +4. **網站檢查使用模擬。** 啟動器設定 `ERP_ISOLATED_TEST=1`、移除程序內供應商金鑰與代理設定、封鎖外部 DNS/TCP、停用背景排程。允許本機連線供 Streamlit 檢視;這不是整台電腦或瀏覽器的網路防火牆。 +5. **真實新聞擷取使用獨立入口。** `accept_live_news.py` 明確讀取所指定檔案的 `GNEWS_API_KEY`,一次取得六篇,再封鎖後續外部網路,用模擬 AI 驗收。每次建立新的資料夾與 `acceptance.db`。 +6. **快照檢視另外保存操作結果。** 指定驗收資料夾啟動網站時,先以 `acceptance.db` 建立 `preview.db`,畫面操作不覆寫原驗收資料庫或 `news-capture.json`。 + +## 三、如何在本機重現 + +以下指令供組員選擇執行;建立 Draft PR 本身不會啟動這些網站或真實新聞擷取。 + +### 準備依賴與執行離線測試 + +在自己的專案工作目錄執行: + +```powershell +python -m venv .venv +& .\.venv\Scripts\python.exe -m pip install -r requirements.txt -r requirements-dev.txt +& .\.venv\Scripts\python.exe -m pytest tests/ -q +``` + +用途:建立自己的套件環境、安裝相同依賴、驗證程式。`tests/conftest.py` 在後端載入前將 `ERP_DB_PATH` 指向暫存資料庫,並封鎖外部網路、停用背景排程。 + +### 檢視固定測試案例 + +```powershell +& .\.venv\Scripts\python.exe scripts/run_isolated.py --port 8511 +``` + +用途:啟動本機隔離 Streamlit。開啟 `http://127.0.0.1:8511`,以測試帳號 `planner`/`planner` 檢查第一階段流程。帳號由隔離 Demo 資料建立,不能當成正式部署的帳號設定。 + +### 一次性 GNews 驗收(會使用新聞 API 配額) + +```powershell +& .\.venv\Scripts\python.exe scripts/accept_live_news.py --source gnews --env-file 'C:\你的設定位置\gnews.env' --country 美國 +``` + +設定檔只需 `GNEWS_API_KEY=你的金鑰`;不要提交金鑰。腳本輸出 `.isolated/live-news-時間戳/` 路徑,包含原始新聞快照、驗收資料庫與結果報告。抓取不足六篇或 API 失敗時,不代表驗收成功;應先檢查查詢、配額與回應。 + +### 檢視已抓取的快照 + +```powershell +& .\.venv\Scripts\python.exe scripts/run_isolated.py --port 8511 --acceptance-dir '.isolated/live-news-實際時間戳' +``` + +用途:在供應鏈區塊檢視新聞來源與驗收資料。按「重播本批真實新聞」讀既有快照,AI 仍為模擬,不重新抓取 GNews。 + +## 四、上傳 Draft PR 的步驟與用途 + +| 順序 | 操作 | 用途與影響 | +| --- | --- | --- | +| 1 | 查看 `git status`、`git remote -v`、目前分支與提交 | 確認將上傳的是第一階段分支,保留使用者目前文件名稱與其他工作區資料。 | +| 2 | `git fetch --no-tags origin main`,比對 `origin/main` 與第一階段 | 取得最新基準,確認 PR 差異範圍;不 checkout、merge 或 pull 到主工作區。 | +| 3 | 檢查差異、忽略規則與待推送提交內容 | 排除 `.env`、金鑰、`.isolated/`、資料庫與驗收快照。只看 `.gitignore` 不夠,已追蹤檔案和新增提交內容也需檢查。 | +| 4 | 核對本機驗證紀錄、GitHub Actions 設定 | PR 說明區分真實新聞與模擬 AI;目前工作流程在 PR 上執行 Ubuntu/Python 3.11 測試,沒有部署步驟。 | +| 5 | 將本次說明文件明確 `git add`,再 `git commit` | 把程式、範圍、驗收與限制一起交給審查者;本機 commit 尚未上傳遠端。 | +| 6 | `git push --set-upstream origin codex/batch1-isolated` | 將此分支上傳,設定追蹤分支;不更新 main,不使用 force push。 | +| 7 | 建立 GitHub PR,`base=main`、`head=codex/batch1-isolated`、`draft=true` | 提供可討論的程式差異並標示仍待整合。此環境未安裝 gh,使用 Git Credential Manager 既有登入,透過 GitHub REST API 建立;憑證只在程序記憶體使用,不輸出或寫入 PR。 | +| 8 | 讀回 PR 狀態、SHA 與 CI 結果 | 確認是 Draft、來源與目標正確、遠端內容等於本機提交。Draft 仍可能執行 CI;CI 通過不代表已部署或完成業務驗收。 | +| 9 | 等待組員 PR,做獨立副本整合測試 | 對照 L1~L3 的實際修改,保留失敗隔離、去重、地區匹配與零值保存,避免直接整檔覆蓋。 | +| 10 | 日後完成整合審查,再另行決定轉正式審查與合併 | 這次只建立 Draft PR。合併與部署需要後續決定;不設定自動合併。 | + +## 五、已驗證的範圍與後續整合 + +- 功能驗收提交 `b258d44`:Windows/Python 3.12.3,完整測試 **388 passed in 27.41s**。這是既有本機執行紀錄,不是本文件編輯時重新跑出的數字。 +- GNews:六篇真實來源資料,模擬 AI 驗收 **14 項通過**。原始快照、資料庫與細節報告留在本機 `.isolated/`,不放入 PR。 +- 先前分別與 PR #12、#13、#14、#15 的固定版本整合測試:406、392、393、395 項通過。這不是四個 PR 一起合併的結果,也不涵蓋尚未取得的組員 L1~L3 成果。 +- #12 與 #15 在 LINE 身分處理互有衝突;#13 與 #15 在 `backend/auth.py` 衝突,整合時需保留缺少有效身分即拒絕的授權邊界。 +- 尚未完成真實 LLM 品質、正式資料遷移、真實通知、正式負載與長時間排程驗收。新聞格式合法不等於證據充分,分析成功不等於人工確認,新聞去重也不等於事件去重。 + +PR 需維持 Draft,待組員提供 PR 後確認共同基準與重疊功能,再規劃第二階段剩餘工作。 diff --git "a/docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" "b/docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" new file mode 100644 index 0000000..05585af --- /dev/null +++ "b/docs/\344\277\256\346\224\271\345\205\255\351\240\205\344\276\233\346\207\211\351\217\210\345\215\200\345\241\212\351\242\250\351\232\252.md" @@ -0,0 +1,102 @@ +# 供應鏈風險功能:第一階段修改說明與整合協作 + +整理日期:2026-09-14 + +這份文件說明我原本第一階段要修正的範圍,方便大家對照目前的成果,避免整合時互相覆蓋修正或重複開發。 + +## 第一階段主要修了什麼? + +這一批主要處理新聞、AI 分析與風險資料的正確性,共六項。 + +### 1. AI 分析失敗,不能被當成真的有風險 + +原本 AI 發生錯誤時,可能被當成「相關新聞、延遲 7 天」。現在會明確標示分析失敗,保留新聞原始內容,並將 AI 分析結果分開保存。 + +分析失敗的新聞不能直接成為有效風險,也不能直接拿去登錄正式事件,避免影響熱圖與後續判斷。 + +### 2. 更新工作可以重試,但不能重複執行 + +加入可設定的排程入口。更新失敗時可以重試;同一個已完成的工作不重跑。同時有人按更新或排程正在執行時,也要避免重複處理同一批資料。 + +這次只測試排程入口與執行規則,沒有啟動正式背景排程。 + +### 3. 重複新聞先排除,再交給 AI + +相同新聞不重複新增,也不反覆交給 AI 分析。之前尚未分析或分析失敗的新聞,仍然可以重試。 + +這裡處理的是「新聞去重」。至於不同新聞是否描述同一件事件,或同一篇新聞是否包含多個事件,需要另外整合事件管理規則。 + +### 4. 分清楚「0 天」「不知道幾天」「分析失敗」 + +這三種情況不能混用: + +- **0 天**:分析明確判斷沒有延遲,是有效數值。 +- **未知**:有新聞或分析結果,但無法確認延遲天數。 +- **分析失敗**:AI 沒有提供可使用的結果,需要重試或人工處理。 + +同時檢查 AI 回傳的格式、數值範圍,以及熱圖建議是否對應系統合法據點,避免把缺漏或不合法內容隨便補成風險數字。 + +這些檢查不代表已證明 AI 的判斷有新聞證據支持。組員做的「證據過濾」可以進一步補強這部分。 + +### 5. 各畫面使用同一套地區判斷 + +熱圖、供應商、採購單與缺貨分析,對「哪些地區受到影響」必須一致。 + +例如選台灣北區時,不能把台灣南區或其他國家的同名地區一起算進去;常見的國家別名也要一致處理。 + +### 6. 畫面套用的內容,要真的存進資料庫 + +修正畫面顯示與保存結果不一致的問題,尤其是 **0% 風險、0 天延遲**,不能因為數值是零就被忽略。 + +重新開頁後應讀到保存的百分比與天數;一次套用多個據點時,若保存中途失敗,也不能只存一半。 + +這批已保存套用到熱圖的摘要,但不代表已完成「完整 AI 摘要跨頁共享、L1 可查看」的全部流程,這部分仍可整合組員的成果。 + +## 額外完成的 GNews 驗收 + +測試真實新聞時,也修正了 GNews 的搜尋條件與國家代碼,已成功取得六篇真實新聞。 + +- 真實新聞由 GNews 抓取。 +- 後續 AI 分析使用固定模擬回應,驗證失敗、未知、零值、去重與重試等流程。 +- 隔離網站使用這批新聞快照重播;按鈕不會重新連線抓取 GNews。 +- 正式模式的新聞更新與隔離驗收是不同執行方式,正式上線前仍需另做整合驗證。 + +## 目前完成與驗證狀態 + +- 程式在獨立 Git worktree 與分支實作,使用獨立測試資料庫。 +- 第一階段最新完整測試:**388 項通過**。 +- 六篇 GNews 真實新聞搭配模擬 AI 的追加驗收:**14 項檢查通過**。 +- 這批成果以 Draft PR 供組員比對;暫不合併回 main 或部署。 +- 尚未開始原定第二、三批功能。 + +尚未驗證的部分包括真實 LLM 分析品質、正式資料庫遷移、真實通知、長時間正式排程與正式負載,因此上述測試結果不等於已完成正式上線驗收。 + +程式識別資訊: + +- 本機分支:`codex/batch1-isolated` +- 本機功能驗收提交:`b258d44`(含第一階段修改與後續 GNews 修正;後續文件更新另行提交) +- 開發基準:`fcc2737` + +## 與組員成果如何整合? + +你完成的 L1~L3 流程與這批修改有部分重疊,主要需要一起確認: + +| 組員成果 | 整合時需要保留的規則 | +| --- | --- | +| L1 已確認/AI 待確認告警 | AI 分析成功不等於人工確認;分析狀態與人工確認狀態要分開,失敗資料不能被當成有效風險。 | +| 熱圖、曝險金額、採購單對映 | 共用地區判斷,保留零值、未知值與一致的保存結果。 | +| AI 地區與天數的證據過濾 | 同時保留格式與數值驗證,再加入證據檢查。 | +| AI 摘要存 DB,L1、L2 都看得到 | 統一摘要的保存位置、來源與分析狀態,避免不同畫面讀到不同版本。 | +| 事件不互相覆蓋、一鍵建立應變計畫 | 不同事件不能互相覆蓋,同一事件重按也不能重複建立;這部分需要一起確認事件識別方式。 | +| 標記受影響採購單、提案與審批證據 | 沿用一致的新聞、事件、地區與延遲資料,並保留各角色的權限限制。 | +| 已讀/處理中、通知 L2、審批結果回寫、沖銷防重按 | 主要是新增流程,可接在第一階段基礎上,再確認狀態保存、通知與防重複操作。 | + +目前僅依功能清單判斷重疊處,尚未取得這份成果的程式分支,因此還不能確認實際 Git 衝突或宣稱整合後測試通過。 + +## 請先開 PR,後續依實際差異調整 + +麻煩先把目前成果上傳並開成 PR,**Draft PR 也可以**。PR 請附上已完成項目、尚未完成或已知問題,以及測試結果;若有資料表或套件變更,也請一起註明。 + +拿到 PR 後,我會先比對雙方修改,再依實際成果調整我們這邊的程式,在獨立測試環境驗證整合結果。整合前先不要用整個檔案互相覆蓋,也不用為了避開重疊先刪掉已完成的功能。 + +等這批成果整合清楚後,再確認第二階段還有哪些工作需要做,避免兩邊重複實作。 From 891172eac61c5c3dfea26c18c0dd937cd575bdeb Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 14:41:56 +0800 Subject: [PATCH 13/19] Document PR16 PR17 integration verification --- docs/pr16-pr17-integration-report.md | 164 +++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/pr16-pr17-integration-report.md diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md new file mode 100644 index 0000000..e8dc390 --- /dev/null +++ b/docs/pr16-pr17-integration-report.md @@ -0,0 +1,164 @@ +# PR #16 + PR #17 整合交付報告 + +日期:2026-09-14 +整合分支:`codex/integrate-pr16-pr17` +本機提交:`2932e1a0f524967ba91c346ce5fea0848f231ad1` +PR #16 基準:`7538a410b7d14e6f88e6f9ac98d789109e014576` +PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` + +## 做了什麼 + +這次以 #16 的新聞資料、分析狀態、去重、排程、地區規則及熱圖保存為底,再把 #17 的 L1、L2、L3 功能搬入同一個 worktree。沒有整份採用任一 PR,也沒有改主工作區、推送遠端、更新 PR、部署或啟動正式服務。 + +### #16 的規則保留 + +- 原始新聞保留在原始欄位;AI 結果寫入 `analysis_status`、`analysis_error`、`analysis_country`、`analysis_region`、`analysis_summary` 等欄位。 +- `succeeded`、`failed`、`pending`、`legacy_unverified` 分開處理;失敗不會自動變成 7 天風險。 +- URL/content hash 及資料庫唯一索引負責去重;成功分析不重做,失敗與待處理資料可再次分析。 +- 排程維持明確觸發、重試及跨程序工作鎖;背景排程預設關閉。 +- `backend/region_matching.py` 成為 Python 與 SQLite 的地區匹配入口,國家與分區同時存在時使用交集。 +- 熱圖的風險、延遲及摘要使用同一筆套用交易;0% 與 0 天保留為有效值,未知值保留為 `None`。 +- 隔離執行器固定使用 fixture、關閉外部網路、移除 API 金鑰及正式通知設定。 + +### #17 的功能整合 + +- L1 直接讀資料庫顯示已確認事件與 AI 待確認告警,保存已讀、處理中及通知 L2 狀態。 +- L1 可將系統內未結採購單對映到風險事件,無須上傳 CSV。 +- L2 熱圖分數加入事件嚴重度、事件數量、時效及可讀依據;曝險金額改讀未結採購單並顯示供應商數。 +- L2 摘要、更新建議、事件建議及來源資料落地到 `risk_ai_summaries`,換頁或重整後可重新載入。 +- L2 可標記受影響採購單、建立替代供應商提案;L3 可查看事件與新聞證據、核准或駁回,結果回到 L1 的提案計數。 +- 事件 identity 保留事件類型,因此同一地點的罷工與地震不會互相覆蓋。 +- LLM 額外 headers、timeout 及 demo seed 設定保留,但隔離測試預設關閉 demo seed。 + +## 衝突取捨與相容性修補 + +### 分析與來源 + +新增 `backend/risk_contract.py` 統一新聞有效性與事件欄位驗證。L1、L2、L3 不再用原始新聞的國家、地區或摘要取代分析欄位;新聞來源若不是成功、相關且延遲已知,不能建立新聞事件,也不會出現在有效告警或摘要證據中。 + +### 證據與摘要 + +新增 `backend/risk_intelligence.py`。證據只收成功且相關的新聞,地點以國家/分區組合保存,來源 ID、分析狀態、摘要與時間一起保存。沒有有效證據時,AI 更新與事件會被略過;未知延遲不能被當作 0 或 7 天。失敗摘要不會取代資料庫內最新成功摘要。 + +### 地區 + +事件、L1 告警、L2 證據、熱圖節點、供應商曝險及採購對映均改用共用 resolver。這修正了 #17 原本只比國家、雙向 substring 以及未處理「臺灣/台灣」別名造成的跨分區誤命中。 + +### 0、未知與 UI 保存 + +所有事件建立/更新 API 使用嚴格數字驗證。地圖新聞捷徑、批次登錄、熱圖編輯器與手動登錄都保留 0;未知值不會被補成 7。AI 摘要失敗時畫面保留上一個有效摘要並顯示失敗狀態。 + +### 事件與權限 + +採用 #17 的事件類型 identity,再接回 #16 的來源、型別、範圍及地區驗證。planner 的 `RISK_WORKSPACE_WRITE` 只能修改採購單的風險註記欄位,不會核准採購或改供應商/金額/交易狀態。L3 的提案核准仍須 approver 權限。 + +### 沖銷 + +新增 `backend/approval_reversal.py`。沖銷現在以 `BEGIN IMMEDIATE`、`approval_reversals` 唯一鍵、原始執行收據及同一交易保護訂單/庫存/異動紀錄/稽核寫入。同時請求只會有一筆有效沖銷;舊審批若沒有唯一執行收據會拒絕自動處理,交由人工對帳。Gateway 也不再接受未綁定審批的直接 `rollback_inventory`/`cancel_order`。 + +## 測試 + +### 已通過 + +- 完整 pytest:**504 passed in 36.69s**。 +- `pip check`:`No broken requirements found`。 +- `git diff --check`:通過。 +- 兩個 PR 的相關 L1/L2/L3、授權、排程、資料管線及 UI 測試均在同一份整合工作樹執行。 +- 新增 `tests/test_pr16_pr17_integration.py`,涵蓋: + - L1 → L2 通知 → 事件登錄 → L3 提案/證據 → L1 提案狀態回讀。 + - 成功、失敗、legacy、0、未知、非法天數及重複事件。 + - 共用地區匹配、別名、分區隔離及曝險金額。 + - 摘要 provenance、失敗摘要不覆蓋成功摘要、熱圖交易回滾。 + - 空資料庫/舊資料庫升級與重複初始化。 + - 兩個子程序同時沖銷只產生一次庫存異動。 + - Streamlit L1/L2/L3 主要元件重整後仍讀到資料。 +- 隔離單次排程: + + ```text + fetched_count=6, saved_count=6, analyzed_count=6, + failed_count=0, pending_count=0, heatmap_status=succeeded + ``` + +- 同一 `job_key=integration-review-v1` 再執行回傳 `status=skipped`。 +- 啟動隔離 Streamlit 後 `http://127.0.0.1:8513/_stcore/health` 回傳 HTTP 200,之後已停止。 + +### 尚未驗證 + +- 沒有使用真實 GNews、Gemini 或其他付費模型;此次網路被封鎖,6 則是既有固定 fixture。 +- 沒有發送 LINE、Email 或其他真實通知;L1 通知是資料庫內狀態轉移。 +- 沒有在正式資料庫、正式排程器或生產多組織環境執行。 +- 沒有做瀏覽器人工逐頁操作錄影;Streamlit `AppTest` 已涵蓋主要元件流程。 +- 舊資料的自動分析回補策略尚未決定;目前維持 `legacy_unverified`,不自動升格為有效分析。 + +## 隔離環境 + +整合 worktree: + +```text +C:\新EPR系統\ERP-pr16-pr17-isolated +``` + +分支:`codex/integrate-pr16-pr17` +測試資料庫(已被 `.gitignore` 排除): + +```text +C:\新EPR系統\ERP-pr16-pr17-isolated\.isolated\erp-batch1-success.db +``` + +啟動 Streamlit 預覽(固定新聞、模擬 LLM、禁止外網、背景排程關閉): + +```powershell +Set-Location 'C:\新EPR系統\ERP-pr16-pr17-isolated' +& 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' scripts/run_isolated.py --scenario success --integration-demo --port 8513 +``` + +單次排程驗收(必須明確指定工作鍵): + +```powershell +& 'C:\新EPR系統\AI-Risk-Based-Inventory-ERP-new\.venv\Scripts\python.exe' scripts/run_isolated.py --scenario success --integration-demo --scheduler-once integration-review-v1 +``` + +停止預覽: + +```powershell +Get-NetTCPConnection -State Listen -LocalPort 8513 -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | + Stop-Process -Force +``` + +`run_isolated.py` 會明確設定 `ERP_DB_PATH`、`ERP_SCHEDULER_ENABLED=0`、`ERP_ISOLATED_TEST=1`、`ERP_ENABLE_DEMO_SEED=0`,並在啟動時移除 GNews/LLM/通知金鑰。不要把 `.isolated` 內資料庫、XML、快照或任何 `.env` 加入 Git。 + +## 可審查差異與提交 + +第一個整合提交: + +```text +2932e1a0f524967ba91c346ce5fea0848f231ad1 +Integrate PR17 tiers with PR16 analysis, geography and persistence contracts +``` + +主要新增模組: + +- `backend/risk_contract.py`:新聞/事件共用資料契約。 +- `backend/risk_intelligence.py`:摘要 provenance、有效證據及安全閘門。 +- `backend/approval_reversal.py`:審批綁定的 exactly-once 沖銷。 +- `tests/test_pr16_pr17_integration.py`:整合回歸測試。 + +可用下列命令檢查差異: + +```powershell +Set-Location 'C:\新EPR系統\ERP-pr16-pr17-isolated' +git show --stat --oneline 2932e1a +git diff 7538a410..2932e1a -- backend frontend tests scripts +git status --short +``` + +第二個本機提交只包含本報告;沒有遠端提交或 PR 更新。 + +## 剩餘問題與建議方向 + +整合前必須由審查者確認:事件 identity 是否還需要「事件批次/episode」欄位、legacy 資料要採人工重審還是離線回補、以及組織權限資料在正式部署的初始 migration。這三項會影響資料治理,不應在未決定前自動修改既有資料。 + +可留到下一階段的工作包括真實新聞供應商輪替、付費模型觀測與成本控管、外部通知傳送、更多瀏覽器端 UX、以及報表/效能優化。本次沒有開始這些功能。 + +目前整合分支停在本機,主工作區與兩個原始 PR 分支都保留,等待你檢查提交及隔離畫面。 From 253c01d322d5b9a58c87f65bef37011329b9f7f3 Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 14:42:05 +0800 Subject: [PATCH 14/19] Add integration regression coverage and strict event filters --- backend/l1_monitoring.py | 5 +- backend/risk_contract.py | 4 +- tests/test_pr16_pr17_integration.py | 279 ++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 tests/test_pr16_pr17_integration.py diff --git a/backend/l1_monitoring.py b/backend/l1_monitoring.py index 5eabb6a..58edbcf 100644 --- a/backend/l1_monitoring.py +++ b/backend/l1_monitoring.py @@ -6,6 +6,7 @@ import sqlite3 from backend import database +from backend.risk_contract import valid_event_sql from backend.access_control import RISK_ALERT_ACK, RISK_ANALYSIS_READ, RISK_OVERVIEW_READ, require_capability @@ -164,13 +165,13 @@ def _window_start(since_days: int, *, now: datetime | None = None) -> str: def _load_confirmed_alerts(conn: sqlite3.Connection, *, since: str, limit: int) -> list[dict]: rows = conn.execute( - """ + f""" 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 (e.news_id IS NULL OR (n.analysis_status='succeeded' AND n.is_relevant=1 AND n.estimated_delay IS NOT NULL)) + WHERE {valid_event_sql('e.')} AND substr(COALESCE(e.created_at, ''), 1, 10) >= ? ORDER BY COALESCE(e.created_at, '') DESC, e.id DESC LIMIT ? diff --git a/backend/risk_contract.py b/backend/risk_contract.py index 14e511a..1acb01b 100644 --- a/backend/risk_contract.py +++ b/backend/risk_contract.py @@ -12,7 +12,9 @@ def valid_news_sql(alias=""): def valid_event_sql(alias=""): if alias not in ("", "e."): raise ValueError("Unsupported event alias") - return f"({alias}news_id IS NULL OR {alias}news_id IN (SELECT id FROM supply_chain_news WHERE {valid_news_sql()} AND estimated_delay IS NOT NULL))" + return (f"(typeof({alias}impact_days) IN ('integer','real') AND {alias}impact_days BETWEEN 0 AND 365 " + f"AND CAST({alias}impact_days AS INTEGER)={alias}impact_days AND " + f"({alias}news_id IS NULL OR {alias}news_id IN (SELECT id FROM supply_chain_news WHERE {valid_news_sql()} AND estimated_delay IS NOT NULL)))") def analyzed_news(row): diff --git a/tests/test_pr16_pr17_integration.py b/tests/test_pr16_pr17_integration.py new file mode 100644 index 0000000..05f97b2 --- /dev/null +++ b/tests/test_pr16_pr17_integration.py @@ -0,0 +1,279 @@ +"""Regression acceptance for the contracts joining PR16's pipeline to PR17's tiers.""" +import json +import os +import sqlite3 +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import pytest +from streamlit.testing.v1 import AppTest +from backend import database, supply_chain_risk as risk, l1_monitoring as l1 +from backend import news_store, risk_intelligence as intelligence +from backend.approval_reversal import reverse_approval +from test_l3_proposal_closure import flow_db, _mark_first_po, _first_impacted, _propose + + +@pytest.fixture +def integration_db(tmp_path, monkeypatch): + path = str(tmp_path / "integration.db") + monkeypatch.setenv("ERP_DB_PATH", path) + monkeypatch.setattr(database, "DB_FILE", path) + monkeypatch.setattr(risk, "DB_FILE", path) + database.init_db() + with sqlite3.connect(path) as conn: + conn.execute("DELETE FROM suppliers") + conn.executemany("INSERT INTO suppliers(supplier_id,name,country,region,is_official,latitude,longitude) VALUES(?,?,?,?,1,25,121)", + [("N","North","台灣","北區"),("S","South","台灣","南區")]) + return path + + +def news(path, *, status="succeeded", days=5, country="台灣", region="北區", title="fixture"): + with sqlite3.connect(path) as conn: + nid, _ = news_store.store_raw(conn, dict(title=title, country="美國", region="北美", summary="raw body", source="fixture", published_at=datetime.now().isoformat(), url=f"https://fixture.invalid/{title}")) + if status in {"succeeded", "failed"}: + news_store.store_analysis(conn, nid, dict(analysis_status=status, analysis_error="provider_error", + country=country, region=region, event_type="交通", chinese_summary="分析結果", is_relevant=True, estimated_delay=days)) + elif status == "legacy_unverified": + conn.execute("UPDATE supply_chain_news SET analysis_status=?,is_relevant=1,estimated_delay=7 WHERE id=?", (status,nid)) + conn.row_factory=sqlite3.Row + return dict(conn.execute("SELECT * FROM supply_chain_news WHERE id=?", (nid,)).fetchone()) + + +def test_l1_uses_analysis_and_rejects_legacy_sources(integration_db): + valid = news(integration_db) + for status in ("pending","failed","legacy_unverified"): + row=news(integration_db,status=status,title=status) + with sqlite3.connect(integration_db) as conn: + conn.execute("INSERT INTO supply_chain_events(event_type,country,region,impact_days,created_at,news_id) VALUES('其他','美國','北美',7,datetime('now'),?)", (row["id"],)) + feed=l1.get_latest_event_alerts(actor="viewer") + assert feed["confirmed"] == [] + assert [(r["news_id"],r["country"],r["region"],r["summary"]) for r in feed["candidates"]] == [(valid["id"],"台灣","北區","分析結果")] + l1.set_alert_status(l1.ALERT_KIND_CANDIDATE,valid["id"],l1.ALERT_STATUS_NOTIFIED_L2,actor="viewer",note="請 L2 確認") + assert l1.list_l1_notifications_for_l2(actor="planner")[0]["country"] == "台灣" + eid=risk.add_risk_event("交通","北區","台灣",5,"登錄",valid["id"],actor="planner") + assert l1.list_l1_notifications_for_l2(actor="planner") == [] + assert l1.get_latest_event_alerts(actor="viewer")["confirmed"][0]["id"] == eid + + +@pytest.mark.parametrize("days,expected", [(0,0),(None,None),(5,7)]) +def test_evidence_distinguishes_zero_unknown_and_known(integration_db,days,expected): + n=news(integration_db,days=days) + evidence=risk.build_risk_evidence([n],[]) + _,events,audit=risk.gate_by_evidence([], [dict(country="台灣",region="北區",event_type="交通",impact_days=7)],evidence) + if expected is None: + assert events == [] and audit + else: + assert events[0]["impact_days"] == expected + if days == 0: + assert audit[0]["action"] == "調整" + + +def test_empty_failed_evidence_never_authorizes_action(integration_db): + n=news(integration_db,status="failed") + evidence=risk.build_risk_evidence([n],[]) + u,e,a=risk.gate_by_evidence([dict(display_name="台灣 北區",risk_pct=90)], [dict(country="台灣",region="北區",event_type="交通",impact_days=7)], evidence) + assert not evidence["locations"] and u == e == [] and len(a)==2 + + +def test_geography_shared_by_cards_alerts_evidence_exposure(integration_db): + ev=dict(country="臺灣",region="北區",event_type="交通",impact_days=5) + assert risk.events_for_location("台灣","南區",[ev]) == [] + assert risk.events_for_location("台灣","北區",[ev]) == [ev] + assert not l1._event_matches_supplier(ev,dict(country="台灣",region="南區")) + assert l1._event_matches_supplier(ev,dict(country="台灣",region="北區")) + evidence=risk.build_risk_evidence([news(integration_db)],[]) + u,e,a=risk.gate_by_evidence([dict(display_name="台灣 南區",risk_pct=99)], [{**ev,"region":"南區"}],evidence) + assert u == e == [] + with sqlite3.connect(integration_db) as conn: + conn.executemany("INSERT INTO purchase_orders(po_id,supplier_id,total_amount,status) VALUES(?,?,?,'已下單')",[("PN","N",100),("PS","S",900)]) + assert risk.get_region_exposure("臺灣|北區")["open_po_amount"] == 100 + assert [r["supplier_id"] for r in risk.get_affected_suppliers_by_event("北區","台灣")] == ["N"] + + +@pytest.mark.parametrize("value", [True,-1,1.5,"7",None]) +def test_event_new_rejects_invalid_days(integration_db,value): + with pytest.raises(ValueError): + risk.add_risk_event("交通","北區","台灣",value,"x",actor="planner") + + +@pytest.mark.parametrize("value", [True,-1,1.5,"7"]) +def test_event_update_validates_and_preserves_row(integration_db,value): + eid=risk.add_risk_event("交通","北區","台灣",0,"x",actor="planner") + with pytest.raises(ValueError): + risk.update_risk_event(eid,impact_days=value,actor="planner") + assert risk.get_risk_events_list().iloc[0]["impact_days"] == 0 + + +def test_event_source_revalidated_on_update_and_types_do_not_overwrite(integration_db): + n=news(integration_db) + one=risk.add_risk_event("交通","北區","台灣",5,"one",n["id"],actor="planner") + two=risk.add_risk_event("政策","北區","台灣",5,"two",n["id"],actor="planner") + assert one != two + with pytest.raises(ValueError): + risk.add_risk_event("交通","南區","台灣",5,"wrong",n["id"],actor="planner") + with sqlite3.connect(integration_db) as conn: + conn.execute("UPDATE supply_chain_news SET analysis_status='failed' WHERE id=?",(n["id"],)) + with pytest.raises(ValueError): + risk.update_risk_event(one,description="try",actor="planner") + + +def test_summary_provenance_failure_and_atomic_apply(integration_db,monkeypatch): + n=news(integration_db,days=0) + payload={"摘要":"確認零延遲","更新":[{"地區":"台灣 北區","風險":0}],"事件":[{"類型":"交通","國家":"台灣","地區":"北區","延遲天數":0,"描述":"無延遲"}]} + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**kw:json.dumps(payload)) + result=risk.analyze_heatmap_risk([n],actor="planner") + assert result["analysis_status"] == "succeeded" + latest=intelligence.get_latest_ai_risk_summary() + assert latest["sources"][0]["id"] == n["id"] and latest["sources"][0]["country"] == "台灣" + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**kw:"not JSON") + failed=risk.analyze_heatmap_risk([n],actor="planner") + assert failed["analysis_status"] == "failed" and failed["error"] + intelligence.save_ai_risk_summary(failed,actor="planner") + assert intelligence.get_latest_ai_risk_summary()["summary_id"] == latest["summary_id"] + with sqlite3.connect(integration_db) as conn: + conn.execute("CREATE TRIGGER fail_summary BEFORE INSERT ON risk_ai_summaries BEGIN SELECT RAISE(ABORT,'test rollback'); END") + with pytest.raises(sqlite3.IntegrityError): + risk.apply_heatmap_updates([dict(display_name="台灣 北區",risk_pct=0,estimated_delay=0)],"x",actor="planner",summary_result=result) + with sqlite3.connect(integration_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM risk_heatmap").fetchone()[0] == 0 + + +def test_empty_and_legacy_database_migrations_preserve_ids(tmp_path,monkeypatch): + path=str(tmp_path/"legacy.db") + monkeypatch.setattr(database,"DB_FILE",path) + with sqlite3.connect(path) as conn: + conn.executescript(""" + CREATE TABLE supply_chain_news(id INTEGER PRIMARY KEY,country TEXT,region TEXT,title TEXT,summary TEXT,url TEXT,source TEXT,published_at TEXT,relevance_tag TEXT,fetched_at TEXT,category TEXT,is_relevant INTEGER,estimated_delay INTEGER); + CREATE TABLE risk_heatmap(region_key TEXT PRIMARY KEY,display_name TEXT,latitude REAL,longitude REAL,risk_pct REAL,ai_summary TEXT,updated_at TEXT); + INSERT INTO supply_chain_news VALUES(42,'美國','','old','raw','https://example.invalid/old','fixture','2026-09-13','','','其他',1,7); + """) + database.init_db();database.init_db() + with sqlite3.connect(path) as conn: + assert conn.execute("SELECT id,summary,analysis_status FROM supply_chain_news").fetchone() == (42,"raw","legacy_unverified") + assert "estimated_delay" in {r[1] for r in conn.execute("PRAGMA table_info(risk_heatmap)")} + assert "sources_json" in {r[1] for r in conn.execute("PRAGMA table_info(risk_ai_summaries)")} + assert conn.execute("SELECT COUNT(*) FROM approval_reversals").fetchone()[0] == 0 + + +def seed_reversal(path,kind="update_inventory",receipt=True): + args={"product_id":"REV","quantity_change":5} if kind=="update_inventory" else {"product_id":"REV","quantity":5} + with sqlite3.connect(path) as conn: + conn.execute("INSERT INTO inventory(product_id,name,stock,warehouse_id) VALUES('REV','fixture',20,'WH01')") + conn.execute("INSERT INTO pending_approvals(approval_id,tool_name,parameters,status) VALUES('REV-A',?,?,'approved')",(kind,json.dumps(args))) + if receipt: + conn.execute("INSERT INTO effect_receipts(operation_id,approval_id,payload_digest,result,created_at) VALUES('REV-OP','REV-A','fixture','成功 單號: ORD-20260914-010101',datetime('now'))") + if kind=="create_order": + conn.execute("INSERT INTO orders(order_id,product_id,quantity,status) VALUES('ORD-20260914-010101','REV',5,'處理中')") + + +def test_reversal_cross_process_exactly_once(integration_db): + seed_reversal(integration_db) + code="from backend.isolated_runtime import block_external_network; block_external_network(); from backend.approval_reversal import reverse_approval; print(reverse_approval('REV-A',actor='admin')['status'])" + env=dict(os.environ,ERP_DB_PATH=integration_db,PYTHONIOENCODING="utf-8") + procs=[subprocess.Popen([sys.executable,"-c",code],cwd=Path(__file__).resolve().parents[1],env=env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,encoding="utf-8") for _ in range(2)] + outputs=[p.communicate(timeout=30) for p in procs] + assert all(p.returncode==0 for p in procs),outputs + assert sorted(o[0].strip() for o in outputs)==["already_reversed","ok"] + with sqlite3.connect(integration_db) as conn: + assert conn.execute("SELECT stock FROM inventory WHERE product_id='REV'").fetchone()[0] == 15 + assert conn.execute("SELECT COUNT(*) FROM approval_reversals").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM stock_moves WHERE ref_no='REV-A'").fetchone()[0] == 1 + + +def test_reversal_rolls_back_when_audit_fails(integration_db): + seed_reversal(integration_db,kind="create_order") + with sqlite3.connect(integration_db) as conn: + conn.execute("CREATE TRIGGER fail_audit BEFORE INSERT ON agent_action_logs BEGIN SELECT RAISE(ABORT,'fail'); END") + with pytest.raises(sqlite3.IntegrityError): + reverse_approval("REV-A",actor="admin") + with sqlite3.connect(integration_db) as conn: + assert conn.execute("SELECT stock FROM inventory WHERE product_id='REV'").fetchone()[0] == 20 + assert conn.execute("SELECT status FROM orders WHERE order_id='ORD-20260914-010101'").fetchone()[0] == "處理中" + assert conn.execute("SELECT COUNT(*) FROM approval_reversals").fetchone()[0] == 0 + + +def test_reversal_denies_planner_and_receiptless_legacy(integration_db): + seed_reversal(integration_db,receipt=False) + with pytest.raises(PermissionError): + reverse_approval("REV-A",actor="planner") + with pytest.raises(ValueError,match="收據"): + reverse_approval("REV-A",actor="admin") + + +def test_zero_day_shortcut_and_fresh_summary_ui(integration_db): + n=news(integration_db,days=0) + risk.add_risk_event("交通","北區","台灣",0,"新聞",n["id"],actor="planner") + risk.upsert_risk_heatmap("台灣|北區","台灣 北區",25,121,60,"fixture",actor="planner") + script="from frontend.components.supply_map import render_risk_shortcuts\nrender_risk_shortcuts('integration',actor='planner')" + at=AppTest.from_string(script,default_timeout=20).run() + assert not at.exception + button=next(b for b in at.button if "建立應變計畫" in b.label) + assert "0 天" in button.label + button.click().run() + assert not at.exception + events=risk.get_risk_events_list() + assert events["impact_days"].tolist()==[0,0] + fresh=AppTest.from_string(script,default_timeout=20).run() + assert not fresh.exception and any("查看分析" in b.label for b in fresh.button) + + +def test_l1_to_l2_to_l3_and_fresh_views(flow_db,monkeypatch): + from backend import purchase_proposals as pp + po_id,country,region=_mark_first_po(flow_db) + n=news(flow_db,country=country,region=region,title="L1-L2-L3") + l1.set_alert_status(l1.ALERT_KIND_CANDIDATE,n["id"],l1.ALERT_STATUS_NOTIFIED_L2,actor="viewer",note="檢查供貨") + assert l1.list_l1_notifications_for_l2(actor="planner")[0]["news_id"] == n["id"] + eid=risk.add_risk_event("交通",region,country,5,"L2 確認",n["id"],actor="planner") + l1.set_alert_status(l1.ALERT_KIND_CONFIRMED,eid,"處理中",actor="viewer") + payload={"摘要":"本次有效分析摘要","更新":[],"事件":[]} + monkeypatch.setattr("backend.llm_client.complete_text",lambda *a,**k:json.dumps(payload)) + summary=risk.analyze_heatmap_risk([n],actor="planner") + assert summary["analysis_status"]=="succeeded" + proposal=_propose(_first_impacted(),"integrated-flow",eid) + # Planner cannot bypass the reviewer even after being allowed to annotate POs. + with pytest.raises(PermissionError): + pp.decide_purchase_proposal(pp.ApprovalDecision(proposal_id=proposal.proposal_id,outcome="approve"),actor="planner") + ctx=pp.get_purchase_proposal_context(proposal,actor="approver") + assert ctx["event"]["analysis_summary"]=="分析結果" + l3script=("from backend.purchase_proposals import get_purchase_proposal_for_operation\n" + "from backend.access_control import load_principal\n" + "from frontend.page_agent_dashboard import _render_domain_proposal_evidence\n" + f"proposal=get_purchase_proposal_for_operation({pp.proposal_operation_id(proposal.proposal_id)!r},actor='approver')\n" + "_render_domain_proposal_evidence(proposal,load_principal('approver'))") + l3=AppTest.from_string(l3script,default_timeout=20).run() + assert not l3.exception + assert any("分析結果" in m.value for m in l3.markdown) + pp.decide_purchase_proposal(pp.ApprovalDecision(proposal_id=proposal.proposal_id,outcome="approve"),actor="approver") + feed=l1.get_latest_event_alerts(actor="viewer") + confirmed=next(e for e in feed["confirmed"] if e["id"]==eid) + assert confirmed["ack_status"]=="處理中" and confirmed["proposals"]["approved"]==1 + assert not l1.list_l1_notifications_for_l2(actor="planner") + assert intelligence.get_latest_ai_risk_summary()["summary_id"]==summary["summary_id"] + for script in ( + "from frontend.components.risk_overview import _render_latest_event_alerts,_render_latest_ai_summary\n_render_latest_event_alerts(actor='viewer')\n_render_latest_ai_summary(actor='viewer')", + "from frontend.components.purchase_proposal_workbench import render_purchase_proposal_workbench\nrender_purchase_proposal_workbench(actor='planner')", + ): + fresh=AppTest.from_string(script,default_timeout=20).run() + assert not fresh.exception + 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 + + +def test_empty_database_repeated_init_without_demo(tmp_path,monkeypatch): + path=str(tmp_path/"empty.db") + monkeypatch.setattr(database,"DB_FILE",path) + monkeypatch.setenv("ERP_DEMO_MODE","0") + database.init_db();database.init_db() + with sqlite3.connect(path) as conn: + for table in ("supply_chain_news","supply_chain_events","purchase_orders","risk_ai_summaries","risk_alert_states","approval_reversals"): + assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]==0 + + +@pytest.mark.parametrize("days", [None,-1,1.5,"unknown",366]) +def test_legacy_manual_unknown_or_invalid_days_are_not_confirmed_zero(integration_db,days): + with sqlite3.connect(integration_db) as conn: + conn.execute("INSERT INTO supply_chain_events(event_type,country,region,impact_days,created_at) VALUES('其他','台灣','北區',?,datetime('now'))",(days,)) + assert l1.get_latest_event_alerts(actor="viewer")["confirmed"]==[] + assert risk.get_active_risk_events().empty From 3985a17766ca958416572aa5a52be2ae6af5916c Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 14:42:28 +0800 Subject: [PATCH 15/19] Record final integration test commit --- docs/pr16-pr17-integration-report.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md index e8dc390..b9a3396 100644 --- a/docs/pr16-pr17-integration-report.md +++ b/docs/pr16-pr17-integration-report.md @@ -2,7 +2,7 @@ 日期:2026-09-14 整合分支:`codex/integrate-pr16-pr17` -本機提交:`2932e1a0f524967ba91c346ce5fea0848f231ad1` +本機程式提交:`2932e1a0f524967ba91c346ce5fea0848f231ad1`、`253c01d322d5b9a58c87f65bef37011329b9f7f3` PR #16 基準:`7538a410b7d14e6f88e6f9ac98d789109e014576` PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` @@ -155,6 +155,8 @@ git status --short 第二個本機提交只包含本報告;沒有遠端提交或 PR 更新。 +第三個本機提交 `253c01d` 加入整合回歸測試及最後的嚴格事件來源/天數過濾。 + ## 剩餘問題與建議方向 整合前必須由審查者確認:事件 identity 是否還需要「事件批次/episode」欄位、legacy 資料要採人工重審還是離線回補、以及組織權限資料在正式部署的初始 migration。這三項會影響資料治理,不應在未決定前自動修改既有資料。 From 6b0feb01af633f95479e1a362d11ac940f4a76b2 Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 14:42:40 +0800 Subject: [PATCH 16/19] Clarify local integration commit history --- docs/pr16-pr17-integration-report.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md index b9a3396..c1c613b 100644 --- a/docs/pr16-pr17-integration-report.md +++ b/docs/pr16-pr17-integration-report.md @@ -157,6 +157,8 @@ git status --short 第三個本機提交 `253c01d` 加入整合回歸測試及最後的嚴格事件來源/天數過濾。 +第四個本機提交 `3985a17` 只修正本報告中的完整提交清單。 + ## 剩餘問題與建議方向 整合前必須由審查者確認:事件 identity 是否還需要「事件批次/episode」欄位、legacy 資料要採人工重審還是離線回補、以及組織權限資料在正式部署的初始 migration。這三項會影響資料治理,不應在未決定前自動修改既有資料。 From 5036663d4d1104270b53440aba67d1c11ef17b15 Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 14:44:40 +0800 Subject: [PATCH 17/19] Document final local integration history --- docs/pr16-pr17-integration-report.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md index c1c613b..474707c 100644 --- a/docs/pr16-pr17-integration-report.md +++ b/docs/pr16-pr17-integration-report.md @@ -159,6 +159,8 @@ git status --short 第四個本機提交 `3985a17` 只修正本報告中的完整提交清單。 +第五個本機提交 `6b0feb0` 只補充本機提交歷史說明;程式內容仍由第一個與第三個提交提供。 + ## 剩餘問題與建議方向 整合前必須由審查者確認:事件 identity 是否還需要「事件批次/episode」欄位、legacy 資料要採人工重審還是離線回補、以及組織權限資料在正式部署的初始 migration。這三項會影響資料治理,不應在未決定前自動修改既有資料。 From 933443cc06b5378935e445aaafd34730a443a22b Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 14:45:15 +0800 Subject: [PATCH 18/19] Normalize integration report formatting --- docs/pr16-pr17-integration-report.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md index 474707c..f7ece50 100644 --- a/docs/pr16-pr17-integration-report.md +++ b/docs/pr16-pr17-integration-report.md @@ -1,9 +1,9 @@ # PR #16 + PR #17 整合交付報告 -日期:2026-09-14 -整合分支:`codex/integrate-pr16-pr17` -本機程式提交:`2932e1a0f524967ba91c346ce5fea0848f231ad1`、`253c01d322d5b9a58c87f65bef37011329b9f7f3` -PR #16 基準:`7538a410b7d14e6f88e6f9ac98d789109e014576` +日期:2026-09-14 +整合分支:`codex/integrate-pr16-pr17` +本機程式提交:`2932e1a0f524967ba91c346ce5fea0848f231ad1`、`253c01d322d5b9a58c87f65bef37011329b9f7f3` +PR #16 基準:`7538a410b7d14e6f88e6f9ac98d789109e014576` PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` ## 做了什麼 @@ -98,7 +98,7 @@ PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` C:\新EPR系統\ERP-pr16-pr17-isolated ``` -分支:`codex/integrate-pr16-pr17` +分支:`codex/integrate-pr16-pr17` 測試資料庫(已被 `.gitignore` 排除): ```text From df84e64179dec10fb06c9b4bf576c0e0b7a1e24c Mon Sep 17 00:00:00 2001 From: weck06 Date: Mon, 14 Sep 2026 23:55:43 +0800 Subject: [PATCH 19/19] Prepare integration report for consolidated draft review --- docs/pr16-pr17-integration-report.md | 29 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/pr16-pr17-integration-report.md b/docs/pr16-pr17-integration-report.md index f7ece50..a407ecd 100644 --- a/docs/pr16-pr17-integration-report.md +++ b/docs/pr16-pr17-integration-report.md @@ -8,7 +8,9 @@ PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` ## 做了什麼 -這次以 #16 的新聞資料、分析狀態、去重、排程、地區規則及熱圖保存為底,再把 #17 的 L1、L2、L3 功能搬入同一個 worktree。沒有整份採用任一 PR,也沒有改主工作區、推送遠端、更新 PR、部署或啟動正式服務。 +這次以 #16 的新聞資料、分析狀態、去重、排程、地區規則及熱圖保存為底,再把 #17 的 L1、L2、L3 功能依資料流整合到獨立 worktree。主工作區及原始分支保留;本整合分支準備提交獨立 Draft PR,供組員審查、組長決定合併,未部署或啟動正式服務。 + +本整合 PR 涵蓋 #16、#17,建議集中審查此分支,暫緩分別合併原 PR;待整合 PR 合併後,由維護者將原 PR 標記為已被涵蓋並關閉。審查期間若 main 或原 PR 更新,需重新核對新增差異及測試。 ### #16 的規則保留 @@ -34,7 +36,7 @@ PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` ### 分析與來源 -新增 `backend/risk_contract.py` 統一新聞有效性與事件欄位驗證。L1、L2、L3 不再用原始新聞的國家、地區或摘要取代分析欄位;新聞來源若不是成功、相關且延遲已知,不能建立新聞事件,也不會出現在有效告警或摘要證據中。 +新增 `backend/risk_contract.py` 統一新聞有效性與事件欄位驗證。L1、L2、L3 不再用原始新聞的國家、地區或摘要取代分析欄位。成功且相關的新聞才可作為摘要證據;延遲未知仍保留為未知,不能建立新聞事件,L1 待確認風險告警另要求正延遲。 ### 證據與摘要 @@ -54,13 +56,14 @@ PR #17 基準:`0bc642f9468a50567eb4f765534236cee155e8af` ### 沖銷 -新增 `backend/approval_reversal.py`。沖銷現在以 `BEGIN IMMEDIATE`、`approval_reversals` 唯一鍵、原始執行收據及同一交易保護訂單/庫存/異動紀錄/稽核寫入。同時請求只會有一筆有效沖銷;舊審批若沒有唯一執行收據會拒絕自動處理,交由人工對帳。Gateway 也不再接受未綁定審批的直接 `rollback_inventory`/`cancel_order`。 +新增 `backend/approval_reversal.py`。沖銷現在以 `BEGIN IMMEDIATE`、`approval_reversals` 唯一鍵、原始執行收據及同一交易保護訂單/庫存/異動紀錄/稽核寫入。同時請求只會有一筆有效沖銷;舊審批若沒有唯一執行收據會拒絕自動處理,交由人工對帳。Gateway 也不再接受未綁定審批的直接 `rollback_inventory`/`cancel_order`。提案審批由 approver 執行,沖銷另要求 admin 身分。 ## 測試 ### 已通過 -- 完整 pytest:**504 passed in 36.69s**。 +- 整合完成時完整 pytest:**504 passed in 36.69s**;送審前於程式版本 `933443c` 重新執行:**504 passed in 47.32s**。後續僅更新本報告。 +- 本機環境為 Windows/Python 3.12.3;GitHub Actions 使用 Ubuntu/Python 3.11,整合 PR 的 CI 結果須另行確認。 - `pip check`:`No broken requirements found`。 - `git diff --check`:通過。 - 兩個 PR 的相關 L1/L2/L3、授權、排程、資料管線及 UI 測試均在同一份整合工作樹執行。 @@ -149,22 +152,20 @@ Integrate PR17 tiers with PR16 analysis, geography and persistence contracts ```powershell Set-Location 'C:\新EPR系統\ERP-pr16-pr17-isolated' git show --stat --oneline 2932e1a -git diff 7538a410..2932e1a -- backend frontend tests scripts +git diff 7538a410..HEAD -- backend frontend tests scripts +git diff fcc2737..HEAD --stat +git log --oneline fcc2737..HEAD git status --short ``` -第二個本機提交只包含本報告;沒有遠端提交或 PR 更新。 - -第三個本機提交 `253c01d` 加入整合回歸測試及最後的嚴格事件來源/天數過濾。 - -第四個本機提交 `3985a17` 只修正本報告中的完整提交清單。 - -第五個本機提交 `6b0feb0` 只補充本機提交歷史說明;程式內容仍由第一個與第三個提交提供。 +程式變更集中於整合提交 `2932e1a` 與回歸修補 `253c01d`。其餘後續提交是報告與送審說明整理;完整提交清單以上述 `git log` 為準。整合提交保留 #16 與 #17 作為兩個 parent,原作者提交歷史保留。 ## 剩餘問題與建議方向 -整合前必須由審查者確認:事件 identity 是否還需要「事件批次/episode」欄位、legacy 資料要採人工重審還是離線回補、以及組織權限資料在正式部署的初始 migration。這三項會影響資料治理,不應在未決定前自動修改既有資料。 +合併前需確認整合 PR 的 CI、組員的 L1 → L2 → L3 畫面驗收,以及正式資料庫隔離副本的升級與重複初始化。目前只驗證空庫及合成舊版測試資料庫,沒有使用正式資料。審查者也應確認接受 legacy 資料暫不列為有效分析、缺少執行收據的舊審批改採人工對帳等行為。 + +事件是否需要額外「事件批次/episode」欄位及 legacy 自動回補可另案規劃;本次保留事件類型 identity,legacy 維持未驗證。正式部署前需核對現有組織權限初始化設定;本次沒有自動改動正式組織或授權資料。 可留到下一階段的工作包括真實新聞供應商輪替、付費模型觀測與成本控管、外部通知傳送、更多瀏覽器端 UX、以及報表/效能優化。本次沒有開始這些功能。 -目前整合分支停在本機,主工作區與兩個原始 PR 分支都保留,等待你檢查提交及隔離畫面。 +此分支供 Draft PR 審查,是否轉為可合併及實際合併由組員與組長依驗收結果決定。主工作區與兩個原始 PR 分支保留;本次不啟用自動合併。