mengxaingshuo commited on
Commit
f356427
·
1 Parent(s): 6b73049

deploy: unified ZAI marine agent

Browse files
DEPLOY.md CHANGED
@@ -13,6 +13,18 @@ Recommended Space Secret:
13
  Required Space Variable:
14
  - `MARINE_API_URL`
15
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  Optional Space Variables:
17
  - `HF_SQUID_DATASET_REPO` (default `globalsquiddatabase/squid_dataset`)
18
  - `HF_TUNA_DATASET_REPO` (default `globalsquiddatabase/Tuna-Fisheries-Dataset`)
 
13
  Required Space Variable:
14
  - `MARINE_API_URL`
15
 
16
+ For the school deployment, keep `MARINE_API_URL=http://127.0.0.1:8000` and set
17
+ `MARINE_PROXY_UPSTREAM_URL=http://127.0.0.1:8000`. The fixed ngrok web domain
18
+ continues to point to port 7861; requests sent to `/marine/...` are forwarded
19
+ to the local Marine API. On Hugging Face, set `MARINE_API_URL` to the public
20
+ web URL with the `/marine` suffix, for example:
21
+ `https://subway-unbiased-barcode.ngrok-free.dev/marine`.
22
+
23
+ When the school network cannot reach Hugging Face, set these school-only
24
+ variables so fisheries inventory and downloads use the local mirror:
25
+ - `LOCAL_SQUID_DATA_ROOT=/data0/zqyan/squid_data/raw`
26
+ - `LOCAL_TUNA_DATA_ROOT=/data0/zqyan/tuna_data/raw`
27
+
28
  Optional Space Variables:
29
  - `HF_SQUID_DATASET_REPO` (default `globalsquiddatabase/squid_dataset`)
30
  - `HF_TUNA_DATASET_REPO` (default `globalsquiddatabase/Tuna-Fisheries-Dataset`)
DEPLOY_UNIFIED.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Unified deployment notes
2
+
3
+ This package is intentionally the same source tree for the Hugging Face Space
4
+ and the school server. Deployment settings, not separate code branches,
5
+ select the data plane.
6
+
7
+ ## School server
8
+
9
+ Set `MARINE_API_URL=http://127.0.0.1:8000` and
10
+ `MARINE_PROXY_UPSTREAM_URL=http://127.0.0.1:8000`. If local data mirrors are
11
+ available, set `LOCAL_SQUID_DATA_ROOT=/data0/zqyan/squid_data/raw` and
12
+ `LOCAL_TUNA_DATA_ROOT=/data0/zqyan/tuna_data/raw`.
13
+
14
+ The public ngrok URL exposes the UI on `/` and the Marine API through
15
+ `/marine/*`. Therefore its health endpoint is `/marine/health`, not `/health`.
16
+
17
+ ## Hugging Face Space
18
+
19
+ Set the following Variables:
20
+
21
+ - `MARINE_API_URL=https://subway-unbiased-barcode.ngrok-free.dev/marine`
22
+ - `CODEX_PROVIDER=zai`
23
+ - `CODEX_MODEL=glm-5.3`
24
+ - `CODEX_HARNESS_RUNTIME=native`
25
+ - `REQUIRE_CODEX_HARNESS=1`
26
+
27
+ Set these Secrets:
28
+
29
+ - `ZAI_API_KEY`
30
+ - `CODEWHALE_RUNTIME_TOKEN`
31
+ - `HF_TOKEN` when the fisheries datasets require authenticated reads
32
+
33
+ Do not set `MARINE_PROXY_UPSTREAM_URL` or local data-root variables in the
34
+ Space. They belong only to the school server.
35
+
36
+ ## Runtime behavior
37
+
38
+ The following read-only requests bypass the model and are returned by the
39
+ application directly: greetings, runtime identity, Marine health, and
40
+ metadata-only Tuna/Squid file inventories. This makes these operations fast
41
+ and avoids the non-interactive MCP approval problem.
42
+
43
+ Actual Ocean exports and fisheries content analysis remain guarded operations
44
+ through the official Codex CLI Harness. Do not enable
45
+ `CODEX_ALLOW_MCP_BYPASS` by default: it passes the Codex CLI's dangerous
46
+ approval-and-sandbox bypass flag and is not suitable for a public deployment.
README.md CHANGED
@@ -5,11 +5,12 @@ colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
  app_port: 7860
 
8
  ---
9
 
10
  # Global Marine Foundation Data Agent
11
 
12
- Current UI release: **v4.3.0**.
13
 
14
 
15
  ## v3.4.0 稳定性重构(第一阶段)
 
5
  colorTo: indigo
6
  sdk: docker
7
  app_port: 7860
8
+ pinned: false
9
  ---
10
 
11
  # Global Marine Foundation Data Agent
12
 
13
+ Current UI release: **v4.3.2**.
14
 
15
 
16
  ## v3.4.0 稳定性重构(第一阶段)
codex_native_harness.py CHANGED
@@ -105,6 +105,15 @@ class CodexNativeHarness:
105
  command.extend(
106
  ["--disable", "responses_websockets", "--disable", "responses_websockets_v2"]
107
  )
 
 
 
 
 
 
 
 
 
108
  command.extend(
109
  [
110
  "--skip-git-repo-check",
 
105
  command.extend(
106
  ["--disable", "responses_websockets", "--disable", "responses_websockets_v2"]
107
  )
108
+ # Current Codex CLI releases may still cancel stdio MCP calls from
109
+ # non-interactive `codex exec` after stdin is closed, even when the
110
+ # MCP server's default_tools_approval_mode is `approve`. Keep the
111
+ # broader bypass opt-in and disabled by default; it is only for a
112
+ # trusted deployment whose operator explicitly accepts the tradeoff.
113
+ if os.environ.get("CODEX_ALLOW_MCP_BYPASS", "").strip().lower() in {
114
+ "1", "true", "yes", "on"
115
+ }:
116
+ command.append("--dangerously-bypass-approvals-and-sandbox")
117
  command.extend(
118
  [
119
  "--skip-git-repo-check",
fisheries_hf.py CHANGED
@@ -40,6 +40,9 @@ HF_DATASET_REPOS = {
40
  }
41
  # Backwards-compatible default used when old callers omit a repository.
42
  HF_DATASET_REPO = HF_SQUID_DATASET_REPO
 
 
 
43
  HF_SQUID_DATASET_REVISION = (
44
  os.environ.get("HF_SQUID_DATASET_REVISION")
45
  or os.environ.get("HF_DATASET_REVISION")
@@ -87,7 +90,37 @@ def normalize_repository(repository: str | None = None) -> str:
87
  raise ValueError("repository must be squid, tuna, or an exact configured repository id")
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def _revision(repository: str) -> str:
 
 
91
  pinned = (
92
  HF_TUNA_DATASET_REVISION
93
  if repository == HF_TUNA_DATASET_REPO
@@ -143,6 +176,17 @@ def download_dataset_file(
143
 
144
  repository = normalize_repository(repository)
145
  revision = _revision(repository)
 
 
 
 
 
 
 
 
 
 
 
146
  target = _safe_cache_path(repository, clean, revision)
147
  if target.exists() and target.stat().st_size == expected_size:
148
  return target, revision
 
40
  }
41
  # Backwards-compatible default used when old callers omit a repository.
42
  HF_DATASET_REPO = HF_SQUID_DATASET_REPO
43
+ LOCAL_SQUID_DATA_ROOT = Path(os.environ.get("LOCAL_SQUID_DATA_ROOT", "").strip()).expanduser() if os.environ.get("LOCAL_SQUID_DATA_ROOT", "").strip() else None
44
+ LOCAL_TUNA_DATA_ROOT = Path(os.environ.get("LOCAL_TUNA_DATA_ROOT", "").strip()).expanduser() if os.environ.get("LOCAL_TUNA_DATA_ROOT", "").strip() else None
45
+ LOCAL_DATA_ROOTS = {"squid": LOCAL_SQUID_DATA_ROOT, "tuna": LOCAL_TUNA_DATA_ROOT}
46
  HF_SQUID_DATASET_REVISION = (
47
  os.environ.get("HF_SQUID_DATASET_REVISION")
48
  or os.environ.get("HF_DATASET_REVISION")
 
90
  raise ValueError("repository must be squid, tuna, or an exact configured repository id")
91
 
92
 
93
+ def local_root_for_repository(repository: str | None = None) -> Path | None:
94
+ """Return the configured local mirror root for a logical repository."""
95
+ value = str(repository or "squid").strip()
96
+ domain = value.lower() if value.lower() in LOCAL_DATA_ROOTS else None
97
+ if domain is None:
98
+ for key, repo in HF_DATASET_REPOS.items():
99
+ if value == repo:
100
+ domain = key
101
+ break
102
+ root = LOCAL_DATA_ROOTS.get(domain or "")
103
+ if root and root.is_dir():
104
+ return root.resolve()
105
+ return None
106
+
107
+
108
+ def local_dataset_tree(repository: str | None = None) -> list[dict[str, Any]]:
109
+ """List supported files from a local school-server mirror."""
110
+ root = local_root_for_repository(repository)
111
+ if root is None:
112
+ return []
113
+ items: list[dict[str, Any]] = []
114
+ for path in root.rglob("*"):
115
+ if not path.is_file() or path.suffix.lower() not in {".csv", ".tsv", ".zip"}:
116
+ continue
117
+ items.append({"path": path.relative_to(root).as_posix(), "type": "file", "size": path.stat().st_size})
118
+ return sorted(items, key=lambda item: str(item["path"]))
119
+
120
+
121
  def _revision(repository: str) -> str:
122
+ if local_root_for_repository(repository) is not None:
123
+ return "local"
124
  pinned = (
125
  HF_TUNA_DATASET_REVISION
126
  if repository == HF_TUNA_DATASET_REPO
 
176
 
177
  repository = normalize_repository(repository)
178
  revision = _revision(repository)
179
+ local_root = local_root_for_repository(repository)
180
+ if local_root is not None:
181
+ candidate = (local_root / clean).resolve()
182
+ if local_root not in candidate.parents and candidate != local_root:
183
+ raise ValueError("invalid local dataset path")
184
+ if not candidate.is_file():
185
+ raise FileNotFoundError(f"本地数据文件不存在:{clean}")
186
+ actual_size = candidate.stat().st_size
187
+ if expected_size and actual_size != expected_size:
188
+ raise RuntimeError(f"本地文件大小不一致:expected={expected_size}, actual={actual_size}")
189
+ return candidate, revision
190
  target = _safe_cache_path(repository, clean, revision)
191
  if target.exists() and target.stat().st_size == expected_size:
192
  return target, revision
marine_mcp.py CHANGED
@@ -4,7 +4,7 @@ import os, re, time
4
  from typing import Any
5
  import httpx
6
  from mcp.server.mcpserver import MCPServer
7
- from fisheries_hf import analyze_and_export, download_dataset_file
8
 
9
  API_URL = os.environ.get("MARINE_API_URL", "").strip().rstrip("/")
10
  if not API_URL:
@@ -326,6 +326,9 @@ def _hf_headers() -> dict[str, str]:
326
 
327
  def _hf_tree(repo: str, force: bool = False) -> list[dict[str, Any]]:
328
  repo = repo.strip()
 
 
 
329
  now = time.time()
330
  cache = _HF_TREE_CACHE.get(repo) or {}
331
  if (
 
4
  from typing import Any
5
  import httpx
6
  from mcp.server.mcpserver import MCPServer
7
+ from fisheries_hf import analyze_and_export, download_dataset_file, local_dataset_tree
8
 
9
  API_URL = os.environ.get("MARINE_API_URL", "").strip().rstrip("/")
10
  if not API_URL:
 
326
 
327
  def _hf_tree(repo: str, force: bool = False) -> list[dict[str, Any]]:
328
  repo = repo.strip()
329
+ local_items = local_dataset_tree(repo)
330
+ if local_items:
331
+ return local_items
332
  now = time.time()
333
  cache = _HF_TREE_CACHE.get(repo) or {}
334
  if (
services/chat_runtime.py CHANGED
@@ -1,5 +1,7 @@
1
  from __future__ import annotations
2
 
 
 
3
  # P0 chat runtime service.
4
  # Dependencies are injected once from ui_server during app assembly so the
5
  # large streaming/runtime logic is no longer owned by the entry module.
@@ -636,6 +638,204 @@ async def harness_stream_chat(tid,prompt):
636
  DATA_REQUEST_TTL_SECONDS = 6 * 3600
637
 
638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  def _context_spec_has_data(spec) -> bool:
640
  if not isinstance(spec, dict):
641
  return False
@@ -653,10 +853,50 @@ async def dispatch_chat_stream(tid,prompt):
653
  # bootstrap. Keep data requests on the official Codex Harness path, but
654
  # answer these deterministic prompts immediately so a simple "你好" does
655
  # not pay the full CLI/network startup cost.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  greeting = str(prompt or "").strip().lower()
 
 
 
657
  greeting_replies = {
658
  "你好": "你好!有什么可以帮你的吗?",
 
 
 
 
 
 
659
  "您好": "您好!有什么可以帮你的吗?",
 
 
660
  "嗨": "你好!有什么可以帮你的吗?",
661
  "hello": "Hello!有什么可以帮你的吗?",
662
  "hi": "你好!有什么可以帮你的吗?",
 
1
  from __future__ import annotations
2
 
3
+ import os
4
+
5
  # P0 chat runtime service.
6
  # Dependencies are injected once from ui_server during app assembly so the
7
  # large streaming/runtime logic is no longer owned by the entry module.
 
638
  DATA_REQUEST_TTL_SECONDS = 6 * 3600
639
 
640
 
641
+ def _is_runtime_info_prompt(prompt: str) -> bool:
642
+ """Recognize the read-only deployment-info question locally."""
643
+ text = str(prompt or "").strip().lower()
644
+ return (
645
+ ("供应商" in text or "provider" in text)
646
+ and ("模型" in text or "model" in text)
647
+ and ("运行时" in text or "runtime" in text)
648
+ and not _needs_ocean_mcp(text)
649
+ and not _is_fisheries_prompt(text)
650
+ )
651
+
652
+
653
+ def _is_marine_health_prompt(prompt: str) -> bool:
654
+ """Recognize a live Marine API status question without invoking the model."""
655
+ text = str(prompt or "").strip().lower()
656
+ health_terms = (
657
+ "海洋数据服务器连接", "检查海洋数据服务器", "数据服务器连接",
658
+ "三个数据域", "三个域是否在线", "ocean、tuna、squid",
659
+ "ocean, tuna, squid", "marine health", "marine server health",
660
+ )
661
+ return any(term in text for term in health_terms)
662
+
663
+
664
+ def _is_fisheries_inventory_prompt(prompt: str) -> bool:
665
+ """Recognize file-list requests that do not need an agent turn.
666
+
667
+ A repository inventory is a read-only server operation. Sending this
668
+ request through the Codex CLI is both slow and, in headless deployments,
669
+ vulnerable to the interactive-MCP approval limitation. Keep actual file
670
+ reads, analysis and exports on their normal guarded paths.
671
+ """
672
+ text = str(prompt or "").strip().lower()
673
+ if not _is_fisheries_prompt(text):
674
+ return False
675
+ inventory_terms = (
676
+ "可用数据集", "当前可用", "文件来源", "文件清单", "列出",
677
+ "有哪些文件", "入库", "目录", "inventory", "catalog",
678
+ )
679
+ if not any(term in text for term in inventory_terms):
680
+ return False
681
+ # “不读取文件内容” is explicitly a metadata-only request, rather than a
682
+ # request to open a file. Remove these negated phrases before testing for
683
+ # real content work.
684
+ metadata_text = text
685
+ for phrase in (
686
+ "不读取文件内容", "不读文件内容", "不读取内容", "不读内容",
687
+ "不读取文件", "不读文件", "无需读取", "不要读取",
688
+ ):
689
+ metadata_text = metadata_text.replace(phrase, "")
690
+ content_terms = (
691
+ "字段", "记录数", "缺失", "重复", "筛选", "汇总", "聚合",
692
+ "导出", "下载", "实际读取", "读取csv", "读取 csv",
693
+ )
694
+ return not any(term in metadata_text for term in content_terms)
695
+
696
+
697
+ async def _direct_fisheries_inventory_answer(prompt: str) -> str:
698
+ """Return a live fisheries file inventory without invoking the LLM/MCP."""
699
+ text = str(prompt or "").lower()
700
+ wants_tuna = any(term in text for term in ("tuna", "金枪鱼"))
701
+ wants_squid = any(term in text for term in ("squid", "鱿鱼", "柔鱼"))
702
+ domains = []
703
+ if wants_squid or not wants_tuna:
704
+ domains.append("squid")
705
+ if wants_tuna:
706
+ domains.append("tuna")
707
+
708
+ repo_map = globals().get("HF_DATASET_REPOS") or {}
709
+ tree_reader = globals().get("hf_live_tree")
710
+ files_only = globals().get("_hf_live_files")
711
+ human_bytes = globals().get("_human_bytes")
712
+ local_tree = globals().get("local_dataset_tree")
713
+ if not callable(tree_reader) or not callable(files_only) or not callable(human_bytes):
714
+ return "渔业数据清单服务尚未就绪。"
715
+
716
+ source_terms = {
717
+ "GFW": ("gfw", "global_fishing_watch", "global-fishing-watch"),
718
+ "SPRFMO": ("sprfmo",),
719
+ "NPFC": ("npfc",),
720
+ "WCPFC": ("wcpfc",),
721
+ "IATTC": ("iattc",),
722
+ "ICCAT": ("iccat",),
723
+ "IOTC": ("iotc",),
724
+ "CCSBT": ("ccsbt",),
725
+ "FAO": ("fao",),
726
+ "Sea Around Us": ("sea_around", "sea-around", "sea around"),
727
+ "RAM Legacy": ("ram",),
728
+ "VIIRS": ("viirs",),
729
+ }
730
+ lines = []
731
+ for domain in domains:
732
+ repo = str(repo_map.get(domain) or "").strip()
733
+ if not repo:
734
+ lines.append(f"- {domain.title()}:未配置数据仓库。")
735
+ continue
736
+ try:
737
+ raw_items = await tree_reader(repo)
738
+ files = files_only(raw_items)
739
+ except Exception as exc:
740
+ lines.append(f"- {domain.title()}:清单读取失败({str(exc)[:220]})。")
741
+ continue
742
+
743
+ total = sum(int(item.get("size_bytes") or 0) for item in files)
744
+ is_local = False
745
+ try:
746
+ is_local = bool(callable(local_tree) and local_tree(repo))
747
+ except Exception:
748
+ pass
749
+ origin = "学校服务器本地镜像" if is_local else "Hugging Face main 分支"
750
+ lines.extend([
751
+ f"## {domain.title()} 数据清单",
752
+ f"- 仓库:`{repo}`",
753
+ f"- 来源:{origin}",
754
+ f"- 可用文件:{len(files)} 个,约 {human_bytes(total)}",
755
+ ])
756
+
757
+ groups = []
758
+ for name, terms in source_terms.items():
759
+ matched = [
760
+ item for item in files
761
+ if any(term in str(item.get("path") or "").lower() for term in terms)
762
+ ]
763
+ if matched:
764
+ groups.append(
765
+ f"- {name}:{len(matched)} 个文件,约 "
766
+ f"{human_bytes(sum(int(item.get('size_bytes') or 0) for item in matched))}"
767
+ )
768
+ if groups:
769
+ lines.append("- 按来源:")
770
+ lines.extend(groups)
771
+ else:
772
+ lines.append("- 按来源:当前文件路径未匹配到可识别来源标签。")
773
+
774
+ preview = files[:12]
775
+ if preview:
776
+ lines.append("- 文件示例:")
777
+ lines.extend(
778
+ f" - `{item.get('path')}`({human_bytes(item.get('size_bytes') or 0)})"
779
+ for item in preview
780
+ )
781
+ lines.append("- 本次仅读取文件清单元数据,未打开或分析文件内容。")
782
+
783
+ return "\n".join(lines) if lines else "未找到可查询的渔业数据域。"
784
+
785
+
786
+ async def _direct_marine_health_answer():
787
+ """Query the configured Marine API directly for a deterministic status answer."""
788
+ httpx_module = globals().get("httpx")
789
+ if httpx_module is None:
790
+ try:
791
+ import httpx as httpx_module
792
+ except Exception as exc:
793
+ return f"海洋数据服务器连接检查失败:HTTP 客户端不可用({exc})"
794
+ base = str(globals().get("MARINE_API_URL") or "").rstrip("/")
795
+ if not base:
796
+ return "海洋数据服务器未配置 MARINE_API_URL。"
797
+
798
+ candidates = [f"{base}/health"]
799
+ # HF may expose the school API below the app's /marine reverse-proxy path.
800
+ if not base.endswith("/marine"):
801
+ candidates.append(f"{base}/marine/health")
802
+
803
+ last_error = ""
804
+ async with httpx_module.AsyncClient(timeout=12.0, follow_redirects=True) as client:
805
+ for url in candidates:
806
+ try:
807
+ response = await client.get(url)
808
+ if response.is_success:
809
+ payload = response.json()
810
+ domains = payload.get("domains") if isinstance(payload, dict) else None
811
+ labels = {
812
+ "ocean": "在线",
813
+ "tuna": "在线",
814
+ "squid": "在线",
815
+ }
816
+ if isinstance(domains, dict):
817
+ for key in labels:
818
+ value = domains.get(key)
819
+ if isinstance(value, dict):
820
+ value = value.get("status") or value.get("online")
821
+ if value in (False, "offline", "未连接", "不可用"):
822
+ labels[key] = "离线/不可用"
823
+ elif isinstance(domains, list):
824
+ for key in labels:
825
+ if key not in {str(item).lower() for item in domains}:
826
+ labels[key] = "未在健康响应中列出"
827
+ return (
828
+ "海洋数据服务器连接正常。\n"
829
+ f"- Ocean:{labels['ocean']}\n"
830
+ f"- Tuna:{labels['tuna']}\n"
831
+ f"- Squid:{labels['squid']}"
832
+ )
833
+ last_error = f"HTTP {response.status_code}"
834
+ except Exception as exc:
835
+ last_error = str(exc)
836
+ return f"海洋数据服务器连接检查失败:{last_error[:240]}"
837
+
838
+
839
  def _context_spec_has_data(spec) -> bool:
840
  if not isinstance(spec, dict):
841
  return False
 
853
  # bootstrap. Keep data requests on the official Codex Harness path, but
854
  # answer these deterministic prompts immediately so a simple "你好" does
855
  # not pay the full CLI/network startup cost.
856
+ if _is_runtime_info_prompt(prompt):
857
+ provider = str(globals().get("CODEX_PROVIDER_NAME") or os.environ.get("CODEX_PROVIDER", "ZAI")).upper()
858
+ model = str(globals().get("HARNESS_MODEL") or os.environ.get("CODEX_MODEL", "glm-5.3"))
859
+ runtime = "官方 Codex CLI Harness(native)"
860
+ answer = f"- 供应商:{provider}\n- 模型名称:{model}\n- 运行时:{runtime}"
861
+ yield out("token", {"text": answer})
862
+ yield out("done", {"text": answer, "runtime": "local-fastpath", "model": model, "finish_reason": "completed"})
863
+ return
864
+
865
+ if _is_marine_health_prompt(prompt):
866
+ answer = await _direct_marine_health_answer()
867
+ yield out("token", {"text": answer})
868
+ yield out("done", {"text": answer, "runtime": "direct-marine-health", "model": "none", "finish_reason": "completed"})
869
+ return
870
+
871
+ if _is_fisheries_inventory_prompt(prompt):
872
+ answer = await _direct_fisheries_inventory_answer(prompt)
873
+ yield out("token", {"text": answer})
874
+ yield out(
875
+ "done",
876
+ {
877
+ "text": answer,
878
+ "runtime": "direct-fisheries-inventory",
879
+ "model": "none",
880
+ "finish_reason": "completed",
881
+ },
882
+ )
883
+ return
884
+
885
  greeting = str(prompt or "").strip().lower()
886
+ # Keep short conversational greetings local. Variants such as
887
+ # "你好不好" must not start the full Codex/MCP network path.
888
+ greeting = greeting.replace("!", "!").replace("?", "?")
889
  greeting_replies = {
890
  "你好": "你好!有什么可以帮你的吗?",
891
+ "你好!": "你好!有什么可以帮你的吗?",
892
+ "你好?": "你好!有什么可以帮你的吗?",
893
+ "你好不好": "你好!我很好,谢谢关心。有什么可以帮你的吗?",
894
+ "你好吗": "你好!我很好,谢谢关心。有什么可以帮你的吗?",
895
+ "你好啊": "你好!有什么可以帮你的吗?",
896
+ "你好呀": "你好!有什么可以帮你的吗?",
897
  "您好": "您好!有什么可以帮你的吗?",
898
+ "您好!": "您好!有什么可以帮你的吗?",
899
+ "您好?": "您好!有什么可以帮你的吗?",
900
  "嗨": "你好!有什么可以帮你的吗?",
901
  "hello": "Hello!有什么可以帮你的吗?",
902
  "hi": "你好!有什么可以帮你的吗?",
start.sh CHANGED
@@ -115,6 +115,9 @@ env = {
115
  hf_token = os.environ.get("HF_TOKEN", "").strip()
116
  if hf_token:
117
  env["HF_TOKEN"] = hf_token
 
 
 
118
 
119
  cfg = {
120
  "timeouts": {
@@ -168,6 +171,9 @@ marine_env = {
168
  for name in ("HF_TOKEN", "HF_SQUID_DATASET_REVISION", "HF_TUNA_DATASET_REVISION"):
169
  if os.environ.get(name, "").strip():
170
  marine_env[name] = os.environ[name]
 
 
 
171
 
172
  auth_line = (
173
  "experimental_bearer_token = " + q(os.environ["OPENAI_API_KEY"])
@@ -193,6 +199,11 @@ lines = [
193
  "command = \"python3\"",
194
  "args = [" + q(os.path.join(os.environ["APP_ROOT"], "marine_mcp.py")) + "]",
195
  "required = false",
 
 
 
 
 
196
  "startup_timeout_sec = 30",
197
  "tool_timeout_sec = 900",
198
  "env = { " + ", ".join(f"{k} = {q(v)}" for k, v in marine_env.items()) + " }",
 
115
  hf_token = os.environ.get("HF_TOKEN", "").strip()
116
  if hf_token:
117
  env["HF_TOKEN"] = hf_token
118
+ for name in ("LOCAL_SQUID_DATA_ROOT", "LOCAL_TUNA_DATA_ROOT"):
119
+ if os.environ.get(name, "").strip():
120
+ env[name] = os.environ[name]
121
 
122
  cfg = {
123
  "timeouts": {
 
171
  for name in ("HF_TOKEN", "HF_SQUID_DATASET_REVISION", "HF_TUNA_DATASET_REVISION"):
172
  if os.environ.get(name, "").strip():
173
  marine_env[name] = os.environ[name]
174
+ for name in ("LOCAL_SQUID_DATA_ROOT", "LOCAL_TUNA_DATA_ROOT"):
175
+ if os.environ.get(name, "").strip():
176
+ marine_env[name] = os.environ[name]
177
 
178
  auth_line = (
179
  "experimental_bearer_token = " + q(os.environ["OPENAI_API_KEY"])
 
199
  "command = \"python3\"",
200
  "args = [" + q(os.path.join(os.environ["APP_ROOT"], "marine_mcp.py")) + "]",
201
  "required = false",
202
+ # Native/headless Codex runs cannot display an interactive MCP approval
203
+ # prompt. Marine is the deployment's explicitly configured, read-only
204
+ # data server, so approve its tool calls in the CLI config instead of
205
+ # leaving requests pending until the harness timeout.
206
+ "default_tools_approval_mode = \"approve\"",
207
  "startup_timeout_sec = 30",
208
  "tool_timeout_sec = 900",
209
  "env = { " + ", ".join(f"{k} = {q(v)}" for k, v in marine_env.items()) + " }",
static/js/app.js CHANGED
@@ -961,7 +961,7 @@ async function renderServices(){
961
  const [appState,data]=await Promise.all([j("/api/status"),j("/api/sidebar/services")]);
962
  const marine=data.marine||{},hf=data.huggingface||{},d=data.diagnostic||{};
963
  const rows=[
964
- {name:appState.harness_available?"Codex Harness · ZAI GLM-5.3":"Codex Runtime fallback",ok:!!(appState.harness_available),detail:"模型:"+(appState.harness_model||appState.model||"-"),ms:null},
965
  {name:"学校 Marine API",ok:!!marine.health?.ok,detail:serviceText(marine.health),ms:marine.health?.response_ms},
966
  {name:"Ocean 数据域",ok:!!marine.ocean?.ok,detail:serviceText(marine.ocean),ms:marine.ocean?.response_ms},
967
  {name:"Tuna 渔业数据",ok:!!hf.ok&&Number(hf.tuna_file_count||0)>0,detail:hf.ok?("Hugging Face Dataset · "+(hf.tuna_file_count||0)+" 个匹配文件 · "+formatBytes(hf.tuna_size_bytes)):(hf.error||"读取失败"),ms:hf.response_ms},
@@ -1545,7 +1545,7 @@ async function status(){
1545
  $("dot").className="dot "+(ok?"ok":"warn");
1546
  const runtimeName=s.harness_available
1547
  ? "Codex Harness · ZAI GLM-5.3"
1548
- : "Codex Runtime fallback";
1549
  $("st").textContent=ok
1550
  ? runtimeName+" · "+(s.marine_mcp_active_for_thread?"Ocean MCP 已连接":"数据服务在线")
1551
  : "部分服务不可用";
 
961
  const [appState,data]=await Promise.all([j("/api/status"),j("/api/sidebar/services")]);
962
  const marine=data.marine||{},hf=data.huggingface||{},d=data.diagnostic||{};
963
  const rows=[
964
+ {name:appState.harness_available?"Codex Harness · ZAI GLM-5.3":"AI 服务待连接",ok:!!(appState.harness_available),detail:"模型:"+(appState.harness_model||appState.model||"-"),ms:null},
965
  {name:"学校 Marine API",ok:!!marine.health?.ok,detail:serviceText(marine.health),ms:marine.health?.response_ms},
966
  {name:"Ocean 数据域",ok:!!marine.ocean?.ok,detail:serviceText(marine.ocean),ms:marine.ocean?.response_ms},
967
  {name:"Tuna 渔业数据",ok:!!hf.ok&&Number(hf.tuna_file_count||0)>0,detail:hf.ok?("Hugging Face Dataset · "+(hf.tuna_file_count||0)+" 个匹配文件 · "+formatBytes(hf.tuna_size_bytes)):(hf.error||"读取失败"),ms:hf.response_ms},
 
1545
  $("dot").className="dot "+(ok?"ok":"warn");
1546
  const runtimeName=s.harness_available
1547
  ? "Codex Harness · ZAI GLM-5.3"
1548
+ : "AI 服务待连接";
1549
  $("st").textContent=ok
1550
  ? runtimeName+" · "+(s.marine_mcp_active_for_thread?"Ocean MCP 已连接":"数据服务在线")
1551
  : "部分服务不可用";
ui_server.py CHANGED
@@ -49,6 +49,7 @@ from services.context_compressor import (
49
  from fisheries_hf import (
50
  download_dataset_file,
51
  HF_DATASET_REPOS,
 
52
  )
53
 
54
  from sidebar_catalog import (
@@ -68,6 +69,12 @@ from sidebar_catalog import (
68
  CW_URL = os.environ.get("CODEWHALE_INTERNAL_URL","http://127.0.0.1:7878").rstrip("/")
69
  CW_TOKEN = os.environ["CODEWHALE_RUNTIME_TOKEN"]
70
  MARINE_API_URL = os.environ["MARINE_API_URL"].rstrip("/")
 
 
 
 
 
 
71
  # v4.3.1: remote memory is optional and must never block chat creation.
72
  # Do not silently reuse MARINE_API_URL: it is a different service and may be a
73
  # temporary tunnel. Enable memory only when both dedicated settings exist.
@@ -1957,6 +1964,9 @@ async def hf_live_tree(repo: str | None = None, force: bool = False) -> list[dic
1957
  incorrectly reporting ``0 files``.
1958
  """
1959
  repo = (repo or HF_SQUID_DATASET_REPO).strip()
 
 
 
1960
  now = time.time()
1961
  cache = HF_TREE_CACHE.get(repo) or {}
1962
  cached = cache.get("items") or []
@@ -2729,6 +2739,63 @@ app=FastAPI(title="Global Marine Foundation Data Agent",lifespan=lifespan)
2729
  STATIC_DIR = Path(__file__).with_name("static")
2730
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
2731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2732
 
2733
 
2734
 
 
49
  from fisheries_hf import (
50
  download_dataset_file,
51
  HF_DATASET_REPOS,
52
+ local_dataset_tree,
53
  )
54
 
55
  from sidebar_catalog import (
 
69
  CW_URL = os.environ.get("CODEWHALE_INTERNAL_URL","http://127.0.0.1:7878").rstrip("/")
70
  CW_TOKEN = os.environ["CODEWHALE_RUNTIME_TOKEN"]
71
  MARINE_API_URL = os.environ["MARINE_API_URL"].rstrip("/")
72
+ # Optional public reverse-proxy upstream. Set this only on the school UI
73
+ # process (normally http://127.0.0.1:8000). HF points MARINE_API_URL at the
74
+ # public /marine prefix and must not proxy that prefix back into itself.
75
+ MARINE_PROXY_UPSTREAM_URL = os.environ.get(
76
+ "MARINE_PROXY_UPSTREAM_URL", ""
77
+ ).strip().rstrip("/")
78
  # v4.3.1: remote memory is optional and must never block chat creation.
79
  # Do not silently reuse MARINE_API_URL: it is a different service and may be a
80
  # temporary tunnel. Enable memory only when both dedicated settings exist.
 
1964
  incorrectly reporting ``0 files``.
1965
  """
1966
  repo = (repo or HF_SQUID_DATASET_REPO).strip()
1967
+ local_items = local_dataset_tree(repo)
1968
+ if local_items:
1969
+ return local_items
1970
  now = time.time()
1971
  cache = HF_TREE_CACHE.get(repo) or {}
1972
  cached = cache.get("items") or []
 
2739
  STATIC_DIR = Path(__file__).with_name("static")
2740
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
2741
 
2742
+ # The school server exposes this UI through the single fixed ngrok endpoint.
2743
+ # Keep the UI on the root while forwarding /marine/* to the private Marine API
2744
+ # on 127.0.0.1:8000. This lets HF use one stable public URL without requiring
2745
+ # a second ngrok domain. MARINE_API_URL is intentionally not reused here:
2746
+ # on the school server it remains the local API URL, while on HF it points to
2747
+ # this public /marine prefix.
2748
+ _PROXY_HOP_BY_HOP = {
2749
+ "connection", "keep-alive", "proxy-authenticate",
2750
+ "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
2751
+ }
2752
+
2753
+
2754
+ async def _proxy_marine_request(request: Request, suffix: str = "") -> Response:
2755
+ if not MARINE_PROXY_UPSTREAM_URL:
2756
+ raise HTTPException(status_code=404, detail="Marine proxy is not enabled")
2757
+ suffix = "/" + suffix.lstrip("/") if suffix else ""
2758
+ target = f"{MARINE_PROXY_UPSTREAM_URL}{suffix}"
2759
+ if request.url.query:
2760
+ target = f"{target}?{request.url.query}"
2761
+
2762
+ headers = {
2763
+ key: value
2764
+ for key, value in request.headers.items()
2765
+ if key.lower() not in _PROXY_HOP_BY_HOP and key.lower() != "host"
2766
+ }
2767
+ body = await request.body()
2768
+ try:
2769
+ async with httpx.AsyncClient(
2770
+ timeout=httpx.Timeout(connect=8, read=90, write=30, pool=8),
2771
+ follow_redirects=True,
2772
+ ) as client:
2773
+ upstream = await client.request(
2774
+ request.method,
2775
+ target,
2776
+ headers=headers,
2777
+ content=body or None,
2778
+ )
2779
+ except httpx.HTTPError as exc:
2780
+ raise HTTPException(status_code=502, detail=f"Marine API proxy failed: {exc}") from exc
2781
+
2782
+ response_headers = {
2783
+ key: value
2784
+ for key, value in upstream.headers.items()
2785
+ if key.lower() not in _PROXY_HOP_BY_HOP and key.lower() != "content-length"
2786
+ }
2787
+ return Response(
2788
+ content=upstream.content,
2789
+ status_code=upstream.status_code,
2790
+ headers=response_headers,
2791
+ media_type=upstream.headers.get("content-type"),
2792
+ )
2793
+
2794
+
2795
+ _MARINE_PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
2796
+ app.api_route("/marine", methods=_MARINE_PROXY_METHODS)(_proxy_marine_request)
2797
+ app.api_route("/marine/{suffix:path}", methods=_MARINE_PROXY_METHODS)(_proxy_marine_request)
2798
+
2799
 
2800
 
2801