Skip to content

feat: L1–L3 tier completion (risk overview / decision workspace / governed action) - #17

Draft
ewiwi22255 wants to merge 7 commits into
mainfrom
feat/l1-l3-completion
Draft

ewiwi22255 wants to merge 7 commits into
mainfrom
feat/l1-l3-completion

Conversation

@ewiwi22255

Copy link
Copy Markdown
Collaborator

摘要

L1(風險總覽)/L2(情報與決策)/L3(受治理行動)三個分層的功能完善,重點是把「情報 → 事件 → 應變 → 提案 → 核准」整條鏈接通並閉環。base main@fcc2737,7 commits,25 files,+3201/−358,414 tests passed。三個 tier 都用 demo 帳號(viewer / planner / approver)在本機實機走過完整流程。

這支分支與 @組員 的 codex/batch1-isolated(AI 失敗狀態、去重、地區一致、零值保存)有重疊,先開 Draft 讓雙方比對,整合前不合併。

已完成項目

L2 情報與決策(51d731e d4fc8f5 b9c755f

# 原本 改後
1 熱圖有事件一律 60% 依最嚴重事件天數計分(30d→+50/14d→+40/7d→+30)、多筆加成、逾 30 天減半;卡片顯示「依據:N 則事件・最長延遲 X 天」;國家事件不再點亮整個大區
2 曝險金額永遠 $0 「$X(N 張未結採購單)」,無採購單時改顯示供應商家數;ERP_ENABLE_DEMO_SEED 時替正式供應商各種一張進行中採購單
3 新聞登錄的據點永遠「待評估」 卡片辨識新聞登錄事件 → 一鍵「建立應變計畫(類型・天數)」用真實資料
4 同地區再登錄事件會覆蓋 dedupe 鍵加 event_type;新增 update_risk_event(id) 就地修改
5 AI 摘要只在 session 新表 risk_ai_summaries 落地;L2 重開自動載回;L1 唯讀顯示;排程路徑也落地(建議事件不再丟掉)
6 AI 憑空寫「美國 戰爭 30 天」 證據閘門(新聞 country/region/category/delay + 已登錄事件):無證據地區略過、天數上限=證據×2、類型矛盾改「其他」、只有未分類證據標「提醒」;UI 可展開「證據檢核」
7 步驟 5 替代採購提案永遠空的(沒有畫面寫延遲/替代建議到採購單) 步驟 3 新增「受影響採購單標記」:列出事件地區未結採購單 → AI 建議延遲與替代來源(可改)→ 寫回 → 步驟 5 立刻有可提案項目

L3 受治理行動(39383dc

  • 提案綁定風險事件(步驟 5 自動帶步驟 3 選的事件,否則依供應商地區找最嚴重的)
  • 審批頁新增「風險事件依據」+「受影響採購單註記」(延遲、替代建議、金額、來源新聞)
  • 核准/拒絕回寫步驟 5:每條明細標 ⏳/✅/❌,已核准顯示替代採購單號與核准人,拒絕顯示原因
  • 沖銷冪等:已沖銷的單顯示「已沖銷於 …」,按鈕不再出現;handler 執行前再查一次

L1 風險總覽(d0baf28 0bc642f

  • 最新事件告警直接讀 DB,分「已確認」與「AI 偵測待確認」,7/30/90 天
  • 已確認事件可改「處理狀態」(未讀/已讀/處理中)+備註並落地;顯示 L3 替代提案核准進度
  • 待確認情報勾「通知 L2」→ L2 頁頂端列出「L1 轉來 N 則待確認情報」;L2 登錄後自動結案
  • 告警與通知中心預設對映系統內未結採購單,上傳 CSV 保留為第二選項
  • 唯讀「最新 AI 風險摘要」

基礎設施(407bf05

  • LLM_EXTRA_HEADERS(OpenCode Go 必帶 x-opencode-session)、LLM_TIMEOUT(預設 120 秒,逾時換 fallback)
  • scripts/check_llm_env.py --call 一鍵檢查端點/金鑰/模型

資料表/權限/設定變更(整合請留意)

  • 新表risk_ai_summaries(AI 摘要落地)、risk_alert_states(L1 告警狀態)— 皆 CREATE TABLE IF NOT EXISTS,也有 lazy create
  • 新能力RISK_ALERT_ACK(tier L1_MONITOR)→ risk_viewer / supply_planner / procurement_approver / warehouse;只寫 risk_alert_states,不碰 ERP
  • 權限調整update_po_impact(採購單延遲/替代建議)由 ERP_POLICY_WRITERISK_WORKSPACE_WRITE。理由:這兩欄是給 L3 看的評估證據,不動採購單本體;L2 標證據、L3 在步驟 5 核准提案,與 CSV exchange 的 propose/approve 分工一致。授權測試已同步
  • demo 資料ERP_ENABLE_DEMO_SEED 開啟且 purchase_orders 為空時,替每家正式供應商種一張進行中採購單(opt-in,只跑一次)
  • promptHEATMAP_AI_SUMMARY_PROMPT_V2 加「只依資料、不自行推測」;事件清單不再把 NaN 地區餵給模型
  • .env(不進 git):建議 LLM_ANALYSIS_MODEL=openai/glm-5.3-flash(新聞分類 15 分鐘 → 74 秒)
  • 沒有新增套件

尚未完成/已知問題

  • 抓新聞時的分類品質仍取決於上游 prompt(之前全部「其他 7 天」,換 glm 後有改善但未系統性驗證)
  • LLM_ANALYSIS_MODEL 會套到所有分析任務(含熱圖摘要),這是既有設計
  • L1 KPI 卡(受波及供應商/訂單)仍用舊比對邏輯,未改用新的地區判斷
  • 沒有真實通知發送(LINE/mail),「通知 L2」只是頁內提醒
  • 「AI 分析失敗 ≠ 有效風險」「新聞去重」「0 天/未知/失敗分開」「跨畫面地區一致」— 這些在組員 batch1 已處理,本分支未重做,整合時以 batch1 為準;本分支的證據閘門、事件識別(country+region+event_type+news_id)、摘要落地位置需一起對齊

測試

  • python -m pytest tests/ -q414 passed
  • 新增:tests/test_l2_risk_workspace.py(33)、tests/test_l3_proposal_closure.py(10)、tests/test_l1_alert_states.py(19)、tests/test_llm_extra_headers.py(6)、tests/test_l1_event_alerts.py(15)
  • 修改:tests/test_supply_chain_authorization.pyupdate_po_impact 分組)、tests/test_tier_authorization.py(viewer 多 RISK_ALERT_ACK
  • 實機:planner 更新新聞 → 一鍵登錄 → 產生摘要 → 建立應變計畫 → 標記受影響採購單 → 送提案;approver 看到事件依據後核准;planner 步驟 5 顯示已核准;viewer 標記告警、通知 L2 後 planner 頁頂端看到

🤖 Generated with Claude Code

ewiwi22255 and others added 7 commits September 12, 2026 21:50
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 <noreply@anthropic.com>
…tible 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 <noreply@anthropic.com>
…e-gated

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… input

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 <noreply@anthropic.com>
…, idempotent reversal

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 <noreply@anthropic.com>
…osal status

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant