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..b7cc34f 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,9 @@ # ── 頁面設定 ──────────────────────────────────────────────────────── st.set_page_config(page_title="進銷存安全系統", page_icon="🛡️", layout="wide") +if os.getenv("ERP_ISOLATED_TEST") == "1": + news_mode = "真實新聞快照" if os.getenv("ERP_NEWS_CAPTURE") else "固定新聞" + st.warning(f"隔離測試環境|{news_mode}與模擬 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..efaf63c --- /dev/null +++ b/backend/isolated_runtime.py @@ -0,0 +1,89 @@ +"""Deterministic local fixtures and a network guard for the isolated launcher.""" +import ipaddress +import os +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(): + 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): + 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]", "固定測試事件:延遲五天。"), + ("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..49e4127 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 @@ -65,23 +67,24 @@ 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) from_date = (datetime.now() - timedelta(days=within_days)).strftime("%Y-%m-%dT00:00:00Z") - + params = { "q": query, "max": max_results, "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() @@ -104,8 +107,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 +138,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 +159,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 +168,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 分析 - 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) + 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: - 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..bbfd48e --- /dev/null +++ b/docs/batch1-isolated-review.md @@ -0,0 +1,87 @@ +# 第一批改善 1~6:隔離實作與檢查 + +基準:`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`。 +- 主要工作區 `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。 +- 未執行第二、三批功能;初次驗收未推送,後續以 Draft PR 協作審查,暫不合併或部署。 + +## 修改對照 + +| 項目 | 修改與行為 | 主要位置 | +|---|---|---| +| 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 檔案鎖分支未在本機執行。 + +初次驗收未包含真實新聞服務;後續已分別完成 RSS 與 GNews 擷取驗收,AI 仍為模擬回應。尚未驗證:付費模型品質、真實 LINE 通知、正式資料庫遷移、大量資料效能及長時間背景運行。瀏覽器地圖底圖的外部素材可用性也不屬於本次後端連線驗證。 diff --git a/docs/batch1-live-news-acceptance.md b/docs/batch1-live-news-acceptance.md new file mode 100644 index 0000000..0a5e92d --- /dev/null +++ b/docs/batch1-live-news-acceptance.md @@ -0,0 +1,68 @@ +# 第一階段追加驗收: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 項也全部通過。** + +本次外部來源是 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/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 後,我會先比對雙方修改,再依實際成果調整我們這邊的程式,在獨立測試環境驗證整合結果。整合前先不要用整個檔案互相覆蓋,也不用為了避開重疊先刪掉已完成的功能。 + +等這批成果整合清楚後,再確認第二階段還有哪些工作需要做,避免兩邊重複實作。 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 1731a53..d6ccd73 100644 --- a/frontend/components/risk_dashboard.py +++ b/frontend/components/risk_dashboard.py @@ -1,3 +1,5 @@ +from backend.region_matching import matches_location, split_location +import os import streamlit as st import re import pandas as pd @@ -33,7 +35,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([ @@ -62,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 = [] @@ -71,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} @@ -103,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, @@ -120,17 +130,17 @@ 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() # 讀取現有新聞 - 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 = [] 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 +150,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 +177,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 +190,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 +213,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 +234,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 +301,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/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/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()) diff --git a/scripts/run_isolated.py b/scripts/run_isolated.py new file mode 100644 index 0000000..32a29e2 --- /dev/null +++ b/scripts/run_isolated.py @@ -0,0 +1,80 @@ +"""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") + 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 + 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: + 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("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) + 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() + + +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/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..bd5becf --- /dev/null +++ b/tests/test_batch1_ui.py @@ -0,0 +1,81 @@ +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) + + +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 == "🔁 重播本批真實新聞" 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",