Skip to content
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ── 新聞來源(供應鏈風險頁)──
Expand Down
8 changes: 7 additions & 1 deletion backend/access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -62,6 +66,7 @@
"procurement_approver": frozenset(
{
RISK_OVERVIEW_READ,
RISK_ALERT_ACK,
PROPOSAL_EVIDENCE_READ,
APPROVAL_QUEUE_READ,
APPROVAL_DECIDE,
Expand All @@ -74,6 +79,7 @@
"warehouse": frozenset(
{
RISK_OVERVIEW_READ,
RISK_ALERT_ACK,
RISK_ANALYSIS_READ,
RISK_WHAT_IF_RUN,
RISK_WORKSPACE_WRITE,
Expand Down
27 changes: 27 additions & 0 deletions backend/agent_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]}
40 changes: 39 additions & 1 deletion backend/agent_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 單一真實來源)
# ════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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:<id> / aggregate / smalltalk)供歸因。
"""
kw = {"messages": messages, "temperature": temperature}
kw = {"messages": messages, "temperature": temperature, "timeout": _LLM_TIMEOUT}
if tools:
kw["tools"] = tools
kw["tool_choice"] = "auto"
Expand All @@ -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]
Expand Down
65 changes: 65 additions & 0 deletions backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -170,6 +208,27 @@ 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,
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))''')
Expand Down Expand Up @@ -740,6 +799,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
Expand Down
Loading
Loading