Zin299 commited on
Commit
b66badc
·
1 Parent(s): dc12de1

Deploy v4.5.0 platform reliability

Browse files
FIX_REPORT_v4.5.0.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # v4.5.0 平台可靠性整合修复
2
+
3
+ 本版针对《鱿鱼数据平台功能测试排查报告》的四项已定位问题,以及 v4.4.2
4
+ 多任务实测中的“多任务路由优先级/上下文串扰/并行过程不可验证”问题进行整合修复。
5
+
6
+ ## 已处理
7
+ 1. 数据中心按实时 Hugging Face 仓库成员关系展示来源,Squid 不再只看 category=squid;
8
+ 实时文件数直接采用 squid_file_count/tuna_file_count,避免来源匹配重复计数。
9
+ 2. Ocean 单文件导出不再让浏览器直连上游/ngrok 地址,统一进入 `/api/download/jobs`
10
+ 管理下载链路;批量 Ocean 下载继续沿用既有代理、Range、重试和链接刷新。
11
+ 3. 连续追问扩展到深度、水深、米层、图层、层位和变量;结构化上下文新增 layer。
12
+ 4. 新增 `services/region_registry.py`,命名海域通过平台固定映射解析;
13
+ 显式经纬度优先,结构化上下文会回显实际 bbox 和映射来源。
14
+ 5. 多任务解析支持“任务1:...”格式;显式多任务在前端先拆分,再让每个独立子会话
15
+ 自行进入 Ocean/Fisheries/普通 Agent 路由。
16
+ 6. 每个子任务线程独立、history 为空;原始多任务请求只作为共享指代解析材料,
17
+ 并通过 `[TARGET_CHILD_TASK]` 明确限制目标,降低兄弟任务上下文串扰。
18
+ 7. 多任务面板增加实时并发数、单任务耗时、墙钟耗时、串行估算和加速比。
README.md CHANGED
@@ -10,7 +10,7 @@ pinned: false
10
 
11
  # Global Marine Foundation Data Agent
12
 
13
- Current UI release: **v4.4.2**.
14
 
15
 
16
  ## v3.4.0 稳定性重构(第一阶段)
 
10
 
11
  # Global Marine Foundation Data Agent
12
 
13
+ Current UI release: **v4.5.0**.
14
 
15
 
16
  ## v3.4.0 稳定性重构(第一阶段)
VERSION CHANGED
@@ -1 +1 @@
1
- 4.4.2
 
1
+ 4.5.0
routes/datasets.py CHANGED
@@ -55,6 +55,14 @@ async def sidebar_datasets(request: Request, refresh: bool = False):
55
  "size_bytes": sum(item["size_bytes"] for item in matched),
56
  "examples": [item["path"] for item in matched[:4]],
57
  "matched_aliases": matched_aliases,
 
 
 
 
 
 
 
 
58
  "metadata": {
59
  "repository": "、".join(sorted({
60
  item.get("repository", "") for item in matched
 
55
  "size_bytes": sum(item["size_bytes"] for item in matched),
56
  "examples": [item["path"] for item in matched[:4]],
57
  "matched_aliases": matched_aliases,
58
+ "repository_domains": sorted({
59
+ str(item.get("repository_domain") or "")
60
+ for item in matched if item.get("repository_domain")
61
+ }),
62
+ "file_count_by_repository": {
63
+ domain: sum(1 for item in matched if item.get("repository_domain")==domain)
64
+ for domain in ("tuna","squid")
65
+ },
66
  "metadata": {
67
  "repository": "、".join(sorted({
68
  item.get("repository", "") for item in matched
services/context_compressor.py CHANGED
@@ -19,6 +19,8 @@ import re
19
  from datetime import date
20
  from typing import Any
21
 
 
 
22
  from sidebar_catalog import (
23
  HF_SOURCE_ALIASES,
24
  OCEAN_CATALOG,
@@ -62,6 +64,7 @@ EXTRA_VARIABLE_ALIASES = {
62
  "海温": "sst",
63
  "海面高度": "zos",
64
  "海面高度异常": "zos",
 
65
  "叶绿素": "chl",
66
  "溶解氧": "o2",
67
  "混合层深度": "mlotst",
@@ -354,6 +357,9 @@ def _extract_region_text(text: str) -> str:
354
 
355
  def extract_depth(text: str) -> str:
356
  q = str(text or "")
 
 
 
357
  match = DEPTH_RE.search(q)
358
  if match:
359
  return match.group(1).strip()
@@ -369,6 +375,16 @@ def extract_depth(text: str) -> str:
369
  return ""
370
 
371
 
 
 
 
 
 
 
 
 
 
 
372
  def extract_request_spec(text: Any) -> dict[str, Any]:
373
  """Extract the stable data-request fields from one user message."""
374
  source_text = _clean_text(text)
@@ -419,7 +435,9 @@ def extract_request_spec(text: Any) -> dict[str, Any]:
419
 
420
  variables = _dedupe(ocean_variables + fishery_variables)
421
  region = _extract_region_text(source_text)
 
422
  depth = extract_depth(source_text)
 
423
  date_text = _extract_date_text(source_text)
424
  output_format = extract_format(source_text)
425
 
@@ -428,8 +446,15 @@ def extract_request_spec(text: Any) -> dict[str, Any]:
428
  "dataset": dataset_parts,
429
  "dataset_origin": dataset_origin,
430
  "variables": variables,
431
- "region": region,
 
 
 
 
 
 
432
  "depth": depth,
 
433
  "date": date_text,
434
  "format": output_format,
435
  "raw_request": source_text[:2000],
@@ -496,8 +521,13 @@ def merge_request_spec(
496
  spec["variables"] = new_vars
497
  if parsed.get("region"):
498
  spec["region"] = _merge_region(spec.get("region", ""), parsed["region"])
 
 
 
499
  if parsed.get("depth"):
500
  spec["depth"] = parsed["depth"]
 
 
501
  if parsed.get("date"):
502
  spec["date"] = parsed["date"]
503
  if parsed.get("format"):
@@ -531,8 +561,14 @@ def render_request_spec(
531
  lines.append(f"变量:{'、'.join(spec['variables'])}")
532
  if spec.get("region"):
533
  lines.append(f"区域:{spec['region']}")
 
 
 
 
534
  if spec.get("depth"):
535
  lines.append(f"深度:{spec['depth']}")
 
 
536
  if spec.get("date"):
537
  lines.append(f"日期/时间:{spec['date']}")
538
  if spec.get("format"):
@@ -543,7 +579,7 @@ def render_request_spec(
543
  spec.get(key)
544
  for key in (
545
  "domain", "dataset", "variables", "region",
546
- "depth", "date", "format",
547
  )
548
  )
549
  if include_raw and spec.get("raw_request") and not has_structured:
@@ -633,7 +669,7 @@ def build_request_spec_from_messages(
633
  parsed.get(key)
634
  for key in (
635
  "domain", "dataset", "variables", "region",
636
- "depth", "date", "format",
637
  )
638
  )
639
  if has_data:
 
19
  from datetime import date
20
  from typing import Any
21
 
22
+ from services.region_registry import resolve_region, format_bbox
23
+
24
  from sidebar_catalog import (
25
  HF_SOURCE_ALIASES,
26
  OCEAN_CATALOG,
 
64
  "海温": "sst",
65
  "海面高度": "zos",
66
  "海面高度异常": "zos",
67
+ "ssh": "zos",
68
  "叶绿素": "chl",
69
  "溶解氧": "o2",
70
  "混合层深度": "mlotst",
 
357
 
358
  def extract_depth(text: str) -> str:
359
  q = str(text or "")
360
+ layer_depth=re.search(r"([0-9]+(?:\.[0-9]+)?\s*(?:米|m|dbar|db))\s*层",q,re.IGNORECASE)
361
+ if layer_depth:
362
+ return layer_depth.group(1).strip()
363
  match = DEPTH_RE.search(q)
364
  if match:
365
  return match.group(1).strip()
 
375
  return ""
376
 
377
 
378
+ def extract_layer(text: str) -> str:
379
+ q=str(text or "")
380
+ m=re.search(r"(?:第\s*)?(\d{1,3})\s*(?:层|layer)",q,re.IGNORECASE)
381
+ if m:
382
+ return m.group(1)
383
+ if re.search(r"(?:图层|层位|layer)",q,re.IGNORECASE):
384
+ return "unspecified"
385
+ return ""
386
+
387
+
388
  def extract_request_spec(text: Any) -> dict[str, Any]:
389
  """Extract the stable data-request fields from one user message."""
390
  source_text = _clean_text(text)
 
435
 
436
  variables = _dedupe(ocean_variables + fishery_variables)
437
  region = _extract_region_text(source_text)
438
+ region_info = resolve_region(source_text) or (resolve_region(region) if region else None)
439
  depth = extract_depth(source_text)
440
+ layer = extract_layer(source_text)
441
  date_text = _extract_date_text(source_text)
442
  output_format = extract_format(source_text)
443
 
 
446
  "dataset": dataset_parts,
447
  "dataset_origin": dataset_origin,
448
  "variables": variables,
449
+ "region": (
450
+ region
451
+ if (region_info or {}).get("source")=="explicit_coordinates" and region
452
+ else ((region_info or {}).get("name") or region)
453
+ ),
454
+ "region_bbox": (region_info or {}).get("bbox") or {},
455
+ "region_source": (region_info or {}).get("source") or "",
456
  "depth": depth,
457
+ "layer": layer,
458
  "date": date_text,
459
  "format": output_format,
460
  "raw_request": source_text[:2000],
 
521
  spec["variables"] = new_vars
522
  if parsed.get("region"):
523
  spec["region"] = _merge_region(spec.get("region", ""), parsed["region"])
524
+ if parsed.get("region_bbox"):
525
+ spec["region_bbox"] = dict(parsed["region_bbox"])
526
+ spec["region_source"] = parsed.get("region_source") or ""
527
  if parsed.get("depth"):
528
  spec["depth"] = parsed["depth"]
529
+ if parsed.get("layer"):
530
+ spec["layer"] = parsed["layer"]
531
  if parsed.get("date"):
532
  spec["date"] = parsed["date"]
533
  if parsed.get("format"):
 
561
  lines.append(f"变量:{'、'.join(spec['variables'])}")
562
  if spec.get("region"):
563
  lines.append(f"区域:{spec['region']}")
564
+ if spec.get("region_bbox") and spec.get("region_source")!="explicit_coordinates":
565
+ bbox_text=format_bbox(spec.get("region_bbox"))
566
+ if bbox_text:
567
+ lines.append(f"平台本次固定经纬度范围:{bbox_text}({spec.get('region_source') or 'registry'})")
568
  if spec.get("depth"):
569
  lines.append(f"深度:{spec['depth']}")
570
+ if spec.get("layer"):
571
+ lines.append(f"图层:{spec['layer']}")
572
  if spec.get("date"):
573
  lines.append(f"日期/时间:{spec['date']}")
574
  if spec.get("format"):
 
579
  spec.get(key)
580
  for key in (
581
  "domain", "dataset", "variables", "region",
582
+ "depth", "layer", "date", "format",
583
  )
584
  )
585
  if include_raw and spec.get("raw_request") and not has_structured:
 
669
  parsed.get(key)
670
  for key in (
671
  "domain", "dataset", "variables", "region",
672
+ "depth", "layer", "date", "format",
673
  )
674
  )
675
  if has_data:
services/ocean_batch.py CHANGED
@@ -12,6 +12,8 @@ from pathlib import Path
12
  from typing import Any
13
  from urllib.parse import urlparse
14
 
 
 
15
  import httpx
16
 
17
  VARIABLE_MAP={
@@ -25,21 +27,6 @@ VARIABLE_MAP={
25
  "vo":{"source":"cmems_physics","variable":"vo","label":"VO"},
26
  }
27
 
28
- # Named-region defaults used only when the user does not provide explicit
29
- # coordinates. Keep these conservative and visible in the task metadata.
30
- NAMED_OCEAN_REGIONS=(
31
- {
32
- "name":"西北太平洋",
33
- "aliases":("西北太平洋","northwest pacific","north-west pacific","nw pacific"),
34
- "bbox":{"lon_min":120.0,"lon_max":180.0,"lat_min":0.0,"lat_max":60.0},
35
- },
36
- {
37
- "name":"西北太平洋中纬度",
38
- "aliases":("西北太平洋中纬度","northwest pacific mid-latitude"),
39
- "bbox":{"lon_min":120.0,"lon_max":160.0,"lat_min":20.0,"lat_max":50.0},
40
- },
41
- )
42
-
43
  def _daterange(start:date,end:date):
44
  cur=start
45
  while cur<=end:
@@ -62,30 +49,14 @@ def _parse_date_range(prompt:str):
62
  return None
63
 
64
  def _parse_bbox(prompt:str):
65
- raw=str(prompt or "")
66
- q=raw.upper().replace("°","")
67
- # Explicit coordinates always win over named-region defaults.
68
- q=q.replace("–","-").replace("—","-").replace("至","-")
69
- mlon=re.search(r"(-?\d+(?:\.\d+)?)\s*E\s*-\s*(-?\d+(?:\.\d+)?)\s*E",q)
70
- mlat=re.search(r"(-?\d+(?:\.\d+)?)\s*N\s*-\s*(-?\d+(?:\.\d+)?)\s*N",q)
71
- if mlon and mlat:
72
- xs=sorted((float(mlon.group(1)),float(mlon.group(2))))
73
- ys=sorted((float(mlat.group(1)),float(mlat.group(2))))
74
- return {
75
- "bbox":{"lon_min":xs[0],"lon_max":xs[1],"lat_min":ys[0],"lat_max":ys[1]},
76
- "region_name":"自定义经纬度范围",
77
- "region_source":"explicit_coordinates",
78
- }
79
-
80
- low=raw.lower()
81
- for item in NAMED_OCEAN_REGIONS:
82
- if any(alias.lower() in low for alias in item["aliases"]):
83
- return {
84
- "bbox":dict(item["bbox"]),
85
- "region_name":item["name"],
86
- "region_source":"named_region_default",
87
- }
88
- return None
89
 
90
  def _parse_variables(prompt:str):
91
  low=str(prompt or "").lower()
 
12
  from typing import Any
13
  from urllib.parse import urlparse
14
 
15
+ from services.region_registry import resolve_region
16
+
17
  import httpx
18
 
19
  VARIABLE_MAP={
 
27
  "vo":{"source":"cmems_physics","variable":"vo","label":"VO"},
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  def _daterange(start:date,end:date):
31
  cur=start
32
  while cur<=end:
 
49
  return None
50
 
51
  def _parse_bbox(prompt:str):
52
+ resolved=resolve_region(prompt)
53
+ if not resolved:
54
+ return None
55
+ return {
56
+ "bbox":dict(resolved["bbox"]),
57
+ "region_name":resolved["name"],
58
+ "region_source":resolved["source"],
59
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  def _parse_variables(prompt:str):
62
  low=str(prompt or "").lower()
services/region_registry.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ # Platform-standard named regions.
7
+ # These are deterministic UI/query defaults, not claims that a fishery
8
+ # convention boundary is a simple rectangle. Explicit user coordinates always
9
+ # override these defaults. Each mapping is surfaced to the user for review.
10
+ REGIONS = (
11
+ {
12
+ "name": "西北太平洋",
13
+ "aliases": ("西北太平洋", "northwest pacific", "north-west pacific", "nw pacific"),
14
+ "bbox": {"lon_min": 120.0, "lon_max": 180.0, "lat_min": 0.0, "lat_max": 60.0},
15
+ "scope": "platform_standard",
16
+ },
17
+ {
18
+ "name": "西太平洋",
19
+ "aliases": ("西太平洋", "western pacific", "west pacific"),
20
+ "bbox": {"lon_min": 120.0, "lon_max": 180.0, "lat_min": -30.0, "lat_max": 30.0},
21
+ "scope": "platform_standard",
22
+ },
23
+ {
24
+ "name": "西北太平洋中纬度",
25
+ "aliases": ("西北太平洋中纬度", "northwest pacific mid-latitude"),
26
+ "bbox": {"lon_min": 120.0, "lon_max": 160.0, "lat_min": 20.0, "lat_max": 50.0},
27
+ "scope": "platform_standard",
28
+ },
29
+ {
30
+ "name": "南海",
31
+ "aliases": ("南海", "south china sea"),
32
+ "bbox": {"lon_min": 105.0, "lon_max": 121.0, "lat_min": 3.0, "lat_max": 25.0},
33
+ "scope": "platform_standard",
34
+ },
35
+ {
36
+ "name": "东海",
37
+ "aliases": ("东海", "east china sea"),
38
+ "bbox": {"lon_min": 117.0, "lon_max": 131.0, "lat_min": 23.0, "lat_max": 34.0},
39
+ "scope": "platform_standard",
40
+ },
41
+ )
42
+
43
+ def _explicit_bbox(text: str) -> dict[str, Any] | None:
44
+ raw = str(text or "")
45
+ q = raw.upper().replace("°", "").replace("–", "-").replace("—", "-").replace("至", "-")
46
+ mlon = re.search(r"(-?\d+(?:\.\d+)?)\s*E\s*-\s*(-?\d+(?:\.\d+)?)\s*E", q)
47
+ mlat = re.search(r"(-?\d+(?:\.\d+)?)\s*N\s*-\s*(-?\d+(?:\.\d+)?)\s*N", q)
48
+ if not (mlon and mlat):
49
+ return None
50
+ xs = sorted((float(mlon.group(1)), float(mlon.group(2))))
51
+ ys = sorted((float(mlat.group(1)), float(mlat.group(2))))
52
+ return {
53
+ "name": "自定义经纬度范围",
54
+ "bbox": {"lon_min": xs[0], "lon_max": xs[1], "lat_min": ys[0], "lat_max": ys[1]},
55
+ "source": "explicit_coordinates",
56
+ }
57
+
58
+ def resolve_region(text: str) -> dict[str, Any] | None:
59
+ explicit = _explicit_bbox(text)
60
+ if explicit:
61
+ return explicit
62
+ low = str(text or "").lower()
63
+ # Longest alias first prevents “西北太平洋” from being swallowed by a
64
+ # shorter future alias such as “太平洋”.
65
+ matches = []
66
+ for item in REGIONS:
67
+ for alias in item["aliases"]:
68
+ if alias.lower() in low:
69
+ matches.append((len(alias), item))
70
+ if not matches:
71
+ return None
72
+ item = sorted(matches, key=lambda x: -x[0])[0][1]
73
+ return {
74
+ "name": item["name"],
75
+ "bbox": dict(item["bbox"]),
76
+ "source": item.get("scope") or "platform_standard",
77
+ }
78
+
79
+ def format_bbox(bbox: dict[str, Any] | None) -> str:
80
+ bbox = bbox or {}
81
+ if not all(k in bbox for k in ("lon_min","lon_max","lat_min","lat_max")):
82
+ return ""
83
+ return (
84
+ f"{bbox['lon_min']}°E–{bbox['lon_max']}°E, "
85
+ f"{bbox['lat_min']}°N–{bbox['lat_max']}°N"
86
+ )
static/js/app.js CHANGED
@@ -755,8 +755,17 @@ const DOMAIN_PROMPTS={
755
  let DOMAIN_CACHE=null;
756
  function domainSources(data,domain){
757
  if(domain==="ocean")return (data.ocean?.sources||[]).map(x=>({...x,group:"Ocean"}));
758
- const cat=domain==="tuna"?"tuna":"squid";
759
- return (data.fisheries?.sources||[]).filter(x=>x.category===cat||String(x.query_prompt||"").toLowerCase().includes(domain));
 
 
 
 
 
 
 
 
 
760
  }
761
  async function refreshHomeDomains(force=false){
762
  try{
@@ -781,12 +790,14 @@ async function openDomainPanel(domain){
781
  overlay.classList.remove("hidden");panel.innerHTML='<div class="loading">正在读取实时数据目录…</div>';
782
  try{
783
  const data=DOMAIN_CACHE||await refreshHomeDomains(false),sources=domainSources(data,domain),available=sources.filter(x=>datasetAvailable(x.status));
784
- const files=domain==="ocean"?"—":sources.reduce((a,x)=>a+Number(x.file_count||0),0);
785
- const size=domain==="ocean"?"实时接口":formatBytes(sources.reduce((a,x)=>a+Number(x.size_bytes||0),0));
 
 
786
  const repoKey=domain==="tuna"?data.fisheries?.repositories?.tuna:(domain==="squid"?data.fisheries?.repositories?.squid:"");
787
  const repoError=repoKey?(data.fisheries?.repository_errors?.[repoKey]||""):"";
788
  const sourceHtml=(repoError?('<div class="detail-note"><b>仓库读取失败:</b>'+esc(repoError)+'</div>'):"")+ (sources.slice(0,12).map(x=>'<div class="domain-source"><b>'+esc(x.name)+(x.name_zh?' · '+esc(x.name_zh):'')+'</b><small>'+esc(datasetStatusLabel(x.status))+(x.file_count!==undefined?' · '+esc(x.file_count)+' 个文件':'')+(x.variables?.length?' · '+esc(x.variables.length)+' 个变量':'')+'</small></div>').join("")||'<div class="empty-view">当前没有可显示的数据源。</div>');
789
- $("domainPanel").innerHTML='<div class="domain-head"><div><h2>'+names[domain][0]+'</h2><p>'+names[domain][1]+' · 实时状态,不再使用静态占位数字</p></div><button class="domain-close" aria-label="关闭">×</button></div><div class="domain-summary"><div class="domain-stat"><strong>'+sources.length+'</strong><span>数据来源</span></div><div class="domain-stat"><strong>'+available.length+'</strong><span>当前已接入</span></div><div class="domain-stat"><strong>'+esc(domain==="ocean"?size:String(files)+" 文件")+'</strong><span>'+(domain==="ocean"?'数据服务':'实时文件')+'</span></div></div><div class="domain-sources">'+sourceHtml+'</div><div class="domain-actions"><button id="domainBrowse" class="view-btn primary">打开完整数据集目录</button><button id="domainQuery" class="view-btn">在对话中查询</button><button id="domainRefresh" class="view-btn">↻ 刷新实时数据</button></div>';
790
  $("domainPanel").querySelector(".domain-close").onclick=closeDomainPanel;
791
  $("domainBrowse").onclick=()=>{closeDomainPanel();openDatasetsForDomain(domain)};
792
  $("domainQuery").onclick=()=>{closeDomainPanel();goChat(DOMAIN_PROMPTS[domain])};
@@ -1523,8 +1534,11 @@ function attachOceanSingleExportCard(A,prompt,answer){
1523
  '<div class="ocean-batch-counts"><span>✓ 完成 <b>1</b></span><span>↻ 执行中 <b>0</b></span><span>… 等待 <b>0</b></span><span>! 失败 <b>0</b></span></div>'+
1524
  '<div class="ocean-batch-meta">'+esc(label)+' · '+esc(tag)+' · '+esc(name)+'</div>'+
1525
  '<div class="ocean-batch-message">'+esc(request||"文件已生成,可开始下载。")+'</div>'+
1526
- '<div class="ocean-batch-actions"><a class="view-btn primary download" data-download="1" href="'+esc(urls[0])+'" download="'+esc(name)+'" data-filename="'+esc(name)+'">↓ 下载文件</a></div>';
 
1527
  A.q.parentNode.insertBefore(card,A.m);
 
 
1528
  return true;
1529
  }
1530
 
@@ -1533,18 +1547,23 @@ function parseParallelTasks(prompt){
1533
  const text=String(prompt||"").trim();
1534
  if(!SETTINGS.multiTaskAuto)return null;
1535
  if(/先.{0,18}(再|然后|之后)|基于(前|上)一个|完成.*后再/i.test(text))return null;
1536
- if(!/(同时|分别|并行|多个任务|以下任务|这几项)/i.test(text))return null;
1537
- const lines=text.split(/\r?\n/);
1538
  const tasks=[];
 
1539
  for(const line of lines){
1540
- const m=line.match(/^\s*(?:任务\s*)?(?:\d+[\.\\)]|[-*])\s*(.+?)\s*$/);
1541
- if(m&&m[1].length>=3)tasks.push(m[1]);
 
1542
  }
1543
  if(tasks.length<2){
1544
- const parts=text.split(/[;;]/).map(x=>x.trim()).filter(Boolean);
1545
- if(parts.length>=2&&parts.length<=6)return parts.slice(0,6);
1546
- return null;
 
 
 
1547
  }
 
1548
  return tasks.slice(0,6);
1549
  }
1550
  function newMultitaskRequestId(){
@@ -1557,13 +1576,13 @@ function createParallelTaskBoard(tasks,requestId){
1557
  const box=document.createElement("div");box.className="parallel-board";
1558
  box.dataset.requestId=requestId;
1559
  box.innerHTML='<div class="parallel-head"><div><b>多任务并行执行</b><small>'+tasks.length+' 个独立子任务 · 独立会话并行 · '+esc(requestId)+'</small></div><strong>0 / '+tasks.length+'</strong></div>'+
1560
- '<div class="parallel-overview"><span>执行策略 <b>parallel / child-session</b></span><span>总耗时 <b data-parallel-wall>0.0s</b></span><span>串行估算 <b data-parallel-serial>—</b></span></div>'+
1561
  '<div class="parallel-grid">'+tasks.map((t,i)=>'<div class="parallel-task" data-parallel-index="'+i+'"><div class="parallel-task-title"><span>'+(i+1)+'</span><b>'+esc(t)+'</b><em data-task-elapsed>0.0s</em></div><div class="parallel-task-state">等待启动</div><div class="parallel-task-result"></div></div>').join("")+'</div>';
1562
  body.appendChild(box);r.appendChild(body);$("msgs").appendChild(r);
1563
  $("chat").scrollTop=$("chat").scrollHeight;
1564
- return{root:r,box,body,requestId,started:performance.now()};
1565
  }
1566
- async function runParallelChild(prompt,index,board){
1567
  const card=board.box.querySelector('[data-parallel-index="'+index+'"]');
1568
  const state=card.querySelector(".parallel-task-state"),resultEl=card.querySelector(".parallel-task-result");
1569
  const elapsedEl=card.querySelector("[data-task-elapsed]");
@@ -1571,6 +1590,9 @@ async function runParallelChild(prompt,index,board){
1571
  const started=performance.now();
1572
  let timer=setInterval(()=>{elapsedEl.textContent=((performance.now()-started)/1000).toFixed(1)+"s"},250);
1573
  state.textContent="正在创建子会话…";card.classList.add("running");
 
 
 
1574
  try{
1575
  const tx=await j("/api/threads",{method:"POST",body:JSON.stringify({user_id:USER_ID})});
1576
  state.textContent="Agent 正在处理…";
@@ -1578,7 +1600,14 @@ async function runParallelChild(prompt,index,board){
1578
  headers["X-Multitask-Request-ID"]=board.requestId;
1579
  headers["X-Multitask-Task-ID"]=taskId;
1580
  headers["X-Multitask-Mode"]="parallel-child-session";
1581
- const r=await fetch("/api/chat",{method:"POST",headers,body:JSON.stringify({thread_id:tx.thread_id,prompt:promptWithDisplayPreferences(prompt),user_id:USER_ID,upload_ids:[]})});
 
 
 
 
 
 
 
1582
  if(!r.ok)throw Error(await r.text());
1583
  const rd=r.body.getReader(),dec=new TextDecoder();let buf="",answer="",failed="";
1584
  while(true){
@@ -1605,6 +1634,9 @@ async function runParallelChild(prompt,index,board){
1605
  }finally{
1606
  clearInterval(timer);timer=null;
1607
  elapsedEl.textContent=((performance.now()-started)/1000).toFixed(1)+"s";
 
 
 
1608
  }
1609
  }
1610
  async function sendParallelTasks(originalPrompt,tasks){
@@ -1617,7 +1649,7 @@ async function sendParallelTasks(originalPrompt,tasks){
1617
  const serialEl=board.box.querySelector("[data-parallel-serial]");
1618
  const wallTimer=setInterval(()=>{wallEl.textContent=((performance.now()-board.started)/1000).toFixed(1)+"s"},250);
1619
  let finished=0;
1620
- const promises=tasks.map((task,i)=>runParallelChild(task,i,board).then(x=>{finished++;counter.textContent=finished+" / "+tasks.length;return x}));
1621
  const results=await Promise.all(promises);
1622
  clearInterval(wallTimer);
1623
  const wall=(performance.now()-board.started)/1000;
 
755
  let DOMAIN_CACHE=null;
756
  function domainSources(data,domain){
757
  if(domain==="ocean")return (data.ocean?.sources||[]).map(x=>({...x,group:"Ocean"}));
758
+ const sources=data.fisheries?.sources||[];
759
+ // Dataset-center membership follows the actual live repository inventory,
760
+ // not only the semantic category. This keeps FAO / Sea Around Us / RAM /
761
+ // GFW / VIIRS visible when those sources physically exist in squid_dataset.
762
+ return sources.filter(x=>{
763
+ const domains=Array.isArray(x.repository_domains)?x.repository_domains:[];
764
+ if(domains.includes(domain))return true;
765
+ if(domain==="tuna"&&x.category==="tuna")return true;
766
+ if(domain==="squid"&&x.category==="squid")return true;
767
+ return false;
768
+ });
769
  }
770
  async function refreshHomeDomains(force=false){
771
  try{
 
790
  overlay.classList.remove("hidden");panel.innerHTML='<div class="loading">正在读取实时数据目录…</div>';
791
  try{
792
  const data=DOMAIN_CACHE||await refreshHomeDomains(false),sources=domainSources(data,domain),available=sources.filter(x=>datasetAvailable(x.status));
793
+ const files=domain==="ocean"?"—":Number(domain==="tuna"?data.fisheries?.tuna_file_count:data.fisheries?.squid_file_count)||0;
794
+ const size=domain==="ocean"?"实时接口":formatBytes(
795
+ sources.reduce((a,x)=>a+Number((x.file_count_by_repository||{})[domain]?x.size_bytes||0:0),0)
796
+ );
797
  const repoKey=domain==="tuna"?data.fisheries?.repositories?.tuna:(domain==="squid"?data.fisheries?.repositories?.squid:"");
798
  const repoError=repoKey?(data.fisheries?.repository_errors?.[repoKey]||""):"";
799
  const sourceHtml=(repoError?('<div class="detail-note"><b>仓库读取失败:</b>'+esc(repoError)+'</div>'):"")+ (sources.slice(0,12).map(x=>'<div class="domain-source"><b>'+esc(x.name)+(x.name_zh?' · '+esc(x.name_zh):'')+'</b><small>'+esc(datasetStatusLabel(x.status))+(x.file_count!==undefined?' · '+esc(x.file_count)+' 个文件':'')+(x.variables?.length?' · '+esc(x.variables.length)+' 个变量':'')+'</small></div>').join("")||'<div class="empty-view">当前没有可显示的数据源。</div>');
800
+ $("domainPanel").innerHTML='<div class="domain-head"><div><h2>'+names[domain][0]+'</h2><p>'+names[domain][1]+' · 实时仓库文件树口径;综合统计/观测来源只要实际入库也会显示</p></div><button class="domain-close" aria-label="关闭">×</button></div><div class="domain-summary"><div class="domain-stat"><strong>'+sources.length+'</strong><span>数据来源</span></div><div class="domain-stat"><strong>'+available.length+'</strong><span>当前已接入</span></div><div class="domain-stat"><strong>'+esc(domain==="ocean"?size:String(files)+" 文件")+'</strong><span>'+(domain==="ocean"?'数据服务':'实时文件')+'</span></div></div><div class="domain-sources">'+sourceHtml+'</div><div class="domain-actions"><button id="domainBrowse" class="view-btn primary">打开完整数据集目录</button><button id="domainQuery" class="view-btn">在对话中查询</button><button id="domainRefresh" class="view-btn">↻ 刷新实时数据</button></div>';
801
  $("domainPanel").querySelector(".domain-close").onclick=closeDomainPanel;
802
  $("domainBrowse").onclick=()=>{closeDomainPanel();openDatasetsForDomain(domain)};
803
  $("domainQuery").onclick=()=>{closeDomainPanel();goChat(DOMAIN_PROMPTS[domain])};
 
1534
  '<div class="ocean-batch-counts"><span>✓ 完成 <b>1</b></span><span>↻ 执行中 <b>0</b></span><span>… 等待 <b>0</b></span><span>! 失败 <b>0</b></span></div>'+
1535
  '<div class="ocean-batch-meta">'+esc(label)+' · '+esc(tag)+' · '+esc(name)+'</div>'+
1536
  '<div class="ocean-batch-message">'+esc(request||"文件已生成,可开始下载。")+'</div>'+
1537
+ '<div class="ocean-batch-actions"><button class="view-btn primary" data-ocean-managed-download>↓ 通过平台下载</button></div>'+
1538
+ '<div class="detail-note">下载会统一经过平台代理任务,浏览器不直接访问学校/ngrok 临时地址;支持进度、鉴权与 Range 能力检测。</div>';
1539
  A.q.parentNode.insertBefore(card,A.m);
1540
+ const btn=card.querySelector("[data-ocean-managed-download]");
1541
+ if(btn)btn.onclick=()=>downloadWithProgress(urls[0],name,{title:"正在下载 "+name});
1542
  return true;
1543
  }
1544
 
 
1547
  const text=String(prompt||"").trim();
1548
  if(!SETTINGS.multiTaskAuto)return null;
1549
  if(/先.{0,18}(再|然后|之后)|基于(前|上)一个|完成.*后再/i.test(text))return null;
1550
+ if(!/(同时|分别|并行|多个任务|以下\s*\d*\s*个?.*任务|这几项)/i.test(text))return null;
 
1551
  const tasks=[];
1552
+ const lines=text.split(/\r?\n/);
1553
  for(const line of lines){
1554
+ // Supports “任务1:...”, “任务 1: ...”, “1. ...”, “1、...”.
1555
+ const m=line.match(/^\s*(?:任务\s*)?(\d+)\s*(?:[\.、\)::)]|\-\s*)\s*(.+?)\s*$/);
1556
+ if(m&&m[2].length>=3)tasks.push(m[2]);
1557
  }
1558
  if(tasks.length<2){
1559
+ const rx=/(?:^|[\n;;])\s*(?:任务\s*)?\d+\s*(?:[\.、\)::)])\s*([\s\S]*?)(?=(?:\n|[;;])\s*(?:任务\s*)?\d+\s*(?:[\.、\)::)])|$)/g;
1560
+ let match;
1561
+ while((match=rx.exec(text))!==null){
1562
+ const task=String(match[1]||"").trim();
1563
+ if(task.length>=3)tasks.push(task);
1564
+ }
1565
  }
1566
+ if(tasks.length<2)return null;
1567
  return tasks.slice(0,6);
1568
  }
1569
  function newMultitaskRequestId(){
 
1576
  const box=document.createElement("div");box.className="parallel-board";
1577
  box.dataset.requestId=requestId;
1578
  box.innerHTML='<div class="parallel-head"><div><b>多任务并行执行</b><small>'+tasks.length+' 个独立子任务 · 独立会话并行 · '+esc(requestId)+'</small></div><strong>0 / '+tasks.length+'</strong></div>'+
1579
+ '<div class="parallel-overview"><span>执行策略 <b>parallel / child-session</b></span><span>当前并发 <b data-parallel-active>0 / '+tasks.length+'</b></span><span>总耗时 <b data-parallel-wall>0.0s</b></span><span>串行估算 <b data-parallel-serial>—</b></span></div>'+
1580
  '<div class="parallel-grid">'+tasks.map((t,i)=>'<div class="parallel-task" data-parallel-index="'+i+'"><div class="parallel-task-title"><span>'+(i+1)+'</span><b>'+esc(t)+'</b><em data-task-elapsed>0.0s</em></div><div class="parallel-task-state">等待启动</div><div class="parallel-task-result"></div></div>').join("")+'</div>';
1581
  body.appendChild(box);r.appendChild(body);$("msgs").appendChild(r);
1582
  $("chat").scrollTop=$("chat").scrollHeight;
1583
+ return{root:r,box,body,requestId,started:performance.now(),active:0,total:tasks.length};
1584
  }
1585
+ async function runParallelChild(prompt,index,board,originalPrompt){
1586
  const card=board.box.querySelector('[data-parallel-index="'+index+'"]');
1587
  const state=card.querySelector(".parallel-task-state"),resultEl=card.querySelector(".parallel-task-result");
1588
  const elapsedEl=card.querySelector("[data-task-elapsed]");
 
1590
  const started=performance.now();
1591
  let timer=setInterval(()=>{elapsedEl.textContent=((performance.now()-started)/1000).toFixed(1)+"s"},250);
1592
  state.textContent="正在创建子会话…";card.classList.add("running");
1593
+ board.active=(board.active||0)+1;
1594
+ const activeEl=board.box.querySelector("[data-parallel-active]");
1595
+ if(activeEl)activeEl.textContent=board.active+" / "+board.total;
1596
  try{
1597
  const tx=await j("/api/threads",{method:"POST",body:JSON.stringify({user_id:USER_ID})});
1598
  state.textContent="Agent 正在处理…";
 
1600
  headers["X-Multitask-Request-ID"]=board.requestId;
1601
  headers["X-Multitask-Task-ID"]=taskId;
1602
  headers["X-Multitask-Mode"]="parallel-child-session";
1603
+ const childPrompt=
1604
+ "[MULTITASK_CHILD]\n"+
1605
+ "你只执行下面指定的一个子任务。兄弟任务的结果不能作为本任务上下文,也不要汇总其他任务。\n"+
1606
+ "原始多任务请求仅用于解析‘同一区域/同一月份/上述条件’等共享指代;若目标子任务已经给出明确参数,以目标子任务为准。\n"+
1607
+ "[ORIGINAL_MULTI_REQUEST]\n"+originalPrompt+"\n[/ORIGINAL_MULTI_REQUEST]\n"+
1608
+ "[TARGET_CHILD_TASK]\n"+prompt+"\n[/TARGET_CHILD_TASK]\n"+
1609
+ "[/MULTITASK_CHILD]";
1610
+ const r=await fetch("/api/chat",{method:"POST",headers,body:JSON.stringify({thread_id:tx.thread_id,prompt:promptWithDisplayPreferences(childPrompt),user_id:USER_ID,upload_ids:[],history:[]})});
1611
  if(!r.ok)throw Error(await r.text());
1612
  const rd=r.body.getReader(),dec=new TextDecoder();let buf="",answer="",failed="";
1613
  while(true){
 
1634
  }finally{
1635
  clearInterval(timer);timer=null;
1636
  elapsedEl.textContent=((performance.now()-started)/1000).toFixed(1)+"s";
1637
+ board.active=Math.max(0,(board.active||1)-1);
1638
+ const activeEl=board.box.querySelector("[data-parallel-active]");
1639
+ if(activeEl)activeEl.textContent=board.active+" / "+board.total;
1640
  }
1641
  }
1642
  async function sendParallelTasks(originalPrompt,tasks){
 
1649
  const serialEl=board.box.querySelector("[data-parallel-serial]");
1650
  const wallTimer=setInterval(()=>{wallEl.textContent=((performance.now()-board.started)/1000).toFixed(1)+"s"},250);
1651
  let finished=0;
1652
+ const promises=tasks.map((task,i)=>runParallelChild(task,i,board,originalPrompt).then(x=>{finished++;counter.textContent=finished+" / "+tasks.length;return x}));
1653
  const results=await Promise.all(promises);
1654
  clearInterval(wallTimer);
1655
  const wall=(performance.now()-board.started)/1000;
tests/test_chat_lifecycle_progress_v393.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_393():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_frontend_lifecycle_progress():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_393():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_frontend_lifecycle_progress():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_download_progress_v391.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_391():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_p1_label_removed():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_391():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_p1_label_removed():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_download_resilience_v422.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_refresh_route_registered():
9
  t=(ROOT/"routes"/"ocean_batch.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_refresh_route_registered():
9
  t=(ROOT/"routes"/"ocean_batch.py").read_text(encoding="utf-8")
tests/test_history_sidebar_v373.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_373():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_project_history_backend():
9
  route=(ROOT/"routes"/"project_package.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_373():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_project_history_backend():
9
  route=(ROOT/"routes"/"project_package.py").read_text(encoding="utf-8")
tests/test_large_download_jobs_v400.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_400():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_download_job_backend():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_400():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_download_job_backend():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
tests/test_merged_health_v433.py CHANGED
@@ -3,7 +3,7 @@ from services.ocean_batch import parse_ocean_batch_request
3
  ROOT=Path(__file__).resolve().parents[1]
4
 
5
  def test_merged_version():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_ocean_month_batch_survives_merge():
9
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
 
3
  ROOT=Path(__file__).resolve().parents[1]
4
 
5
  def test_merged_version():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_ocean_month_batch_survives_merge():
9
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
tests/test_multitask_observability_v442.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
6
 
7
  def test_frontend_sends_multitask_trace_headers():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
6
 
7
  def test_frontend_sends_multitask_trace_headers():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_ocean_batch_downloads_v420.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_420():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_manifest_has_filename_and_sizes():
9
  svc=(ROOT/"services"/"ocean_batch.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_420():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_manifest_has_filename_and_sizes():
9
  svc=(ROOT/"services"/"ocean_batch.py").read_text(encoding="utf-8")
tests/test_ocean_batch_routing_v421.py CHANGED
@@ -4,7 +4,7 @@ from services.ocean_batch import parse_ocean_batch_request
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_421():
7
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
8
 
9
  def test_exact_user_month_query_routes_to_batch():
10
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
@@ -16,7 +16,7 @@ def test_exact_user_month_query_routes_to_batch():
16
  assert p["variables"]==["sst"]
17
  assert p["format"]=="csv"
18
  assert p["region_name"]=="西北太平洋"
19
- assert p["region_source"]=="named_region_default"
20
  assert p["bbox"]=={"lon_min":120.0,"lon_max":180.0,"lat_min":0.0,"lat_max":60.0}
21
 
22
  def test_explicit_coordinates_override_named_region():
 
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_421():
7
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
8
 
9
  def test_exact_user_month_query_routes_to_batch():
10
  p=parse_ocean_batch_request("查询 2002 年 8 月西北太平洋的 SST 数据,并导出为 CSV 文件供我下载")
 
16
  assert p["variables"]==["sst"]
17
  assert p["format"]=="csv"
18
  assert p["region_name"]=="西北太平洋"
19
+ assert p["region_source"]=="platform_standard"
20
  assert p["bbox"]=={"lon_min":120.0,"lon_max":180.0,"lat_min":0.0,"lat_max":60.0}
21
 
22
  def test_explicit_coordinates_override_named_region():
tests/test_ocean_batch_v410.py CHANGED
@@ -4,7 +4,7 @@ from services.ocean_batch import parse_ocean_batch_request
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_410():
7
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
8
 
9
  def test_month_three_variable_batch_plan():
10
  p=parse_ocean_batch_request("查询并导出 2002 年 8 月西北太平洋 120°E–140°E、20°N–40°N 的 SST、SSH、CHL 数据,并打包下载")
 
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_410():
7
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
8
 
9
  def test_month_three_variable_batch_plan():
10
  p=parse_ocean_batch_request("查询并导出 2002 年 8 月西北太平洋 120°E–140°E、20°N–40°N 的 SST、SSH、CHL 数据,并打包下载")
tests/test_p0_finish_v380.py CHANGED
@@ -3,7 +3,7 @@ import pathlib, re, subprocess, shutil
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_380():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_chat_runtime_extracted():
9
  ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_380():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_chat_runtime_extracted():
9
  ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
tests/test_p0_phase2_v371.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_frontend_feature_modules_exist():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_frontend_feature_modules_exist():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
tests/test_p0_phase3_v372.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_372():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_route_modules_exist():
9
  for name in ("datasets.py","user_state.py","project_package.py","data_health.py"):
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_372():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_route_modules_exist():
9
  for name in ("datasets.py","user_state.py","project_package.py","data_health.py"):
tests/test_platform_reliability_v450.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from services.context_compressor import extract_request_spec, merge_request_spec, render_request_spec
3
+ from services.region_registry import resolve_region
4
+ from services.ocean_batch import parse_ocean_batch_request
5
+
6
+ ROOT=Path(__file__).resolve().parents[1]
7
+
8
+ def test_version():
9
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
10
+
11
+ def test_squid_center_uses_repository_membership():
12
+ js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
13
+ api=(ROOT/"routes"/"datasets.py").read_text(encoding="utf-8")
14
+ assert "repository_domains" in api
15
+ assert "file_count_by_repository" in api
16
+ assert "domains.includes(domain)" in js
17
+ assert "squid_file_count" in js
18
+ assert "实时仓库文件树口径" in js
19
+
20
+ def test_single_ocean_export_forces_managed_proxy():
21
+ js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
22
+ assert "data-ocean-managed-download" in js
23
+ assert "downloadWithProgress(urls[0],name" in js
24
+ assert "浏览器不直接访问学校/ngrok" in js
25
+
26
+ def test_followup_depth_layer_context():
27
+ base=merge_request_spec(None,"查询2002年8月西北太平洋 SST,导出 CSV",revision=False)
28
+ updated=merge_request_spec(base,"其中200米层的",revision=True)
29
+ assert updated.get("depth")
30
+ rendered=render_request_spec(updated)
31
+ assert "200" in rendered
32
+ assert "SST" in rendered or "sst" in rendered.lower()
33
+ assert "2002" in rendered
34
+ assert "西北太平洋" in rendered
35
+
36
+ def test_layer_is_structured():
37
+ p=extract_request_spec("还是这个区域,第5层,换成SSH")
38
+ assert p["layer"]=="5"
39
+ assert "zos" in p["variables"]
40
+
41
+ def test_region_is_deterministic():
42
+ a=resolve_region("查询西太平洋 SST")
43
+ b=resolve_region("再查询一次西太平洋 SSH")
44
+ assert a==b
45
+ assert a["bbox"]=={"lon_min":120.0,"lon_max":180.0,"lat_min":-30.0,"lat_max":30.0}
46
+
47
+ def test_explicit_bbox_overrides_registry():
48
+ x=resolve_region("西太平洋 130°E-140°E 20°N-30°N")
49
+ assert x["source"]=="explicit_coordinates"
50
+ assert x["bbox"]["lon_min"]==130.0
51
+
52
+ def test_batch_uses_registry():
53
+ p=parse_ocean_batch_request("查询2002年8月西北太平洋 SST 并导出 CSV")
54
+ assert p is not None
55
+ assert p["region_name"]=="西北太平洋"
56
+ assert p["bbox"]["lat_max"]==60.0
57
+
58
+ def test_multitask_parser_accepts_task_colon_and_isolates_context():
59
+ js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
60
+ assert "任务\\s*)?(\\d+)" in js
61
+ assert "[MULTITASK_CHILD]" in js
62
+ assert "[TARGET_CHILD_TASK]" in js
63
+ assert "history:[]" in js
64
+ assert "data-parallel-active" in js
65
+ assert "runParallelChild(task,i,board,originalPrompt)" in js
tests/test_quality_center_v362.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT = pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_371():
6
- assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "4.4.2"
7
 
8
  def test_native_health_backend():
9
  text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
 
3
  ROOT = pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_371():
6
+ assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "4.5.0"
7
 
8
  def test_native_health_backend():
9
  text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
tests/test_quality_trust_v361.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version_current():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
6
 
7
  def test_quality_confidence_is_now_native_api_metric():
8
  text=(ROOT/"routes"/"data_health.py").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version_current():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
6
 
7
  def test_quality_confidence_is_now_native_api_metric():
8
  text=(ROOT/"routes"/"data_health.py").read_text(encoding="utf-8")
tests/test_release_stability.py CHANGED
@@ -7,7 +7,7 @@ def test_version_is_single_source():
7
  version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
8
  ui = (ROOT / "ui_server.py").read_text(encoding="utf-8")
9
  html = (ROOT / "app.html").read_text(encoding="utf-8")
10
- assert version == "4.4.2"
11
  assert 'with_name("VERSION")' in ui
12
  assert "__APP_VERSION__" in html
13
 
 
7
  version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
8
  ui = (ROOT / "ui_server.py").read_text(encoding="utf-8")
9
  html = (ROOT / "app.html").read_text(encoding="utf-8")
10
+ assert version == "4.5.0"
11
  assert 'with_name("VERSION")' in ui
12
  assert "__APP_VERSION__" in html
13
 
tests/test_runtime_hotfix_v376.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_376():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_no_stray_async_before_sidebar_toggle():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_376():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_no_stray_async_before_sidebar_toggle():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_sidebar_collapse_v374.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_374():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_sidebar_toggle_markup():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_374():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_sidebar_toggle_markup():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
tests/test_sidebar_partial_collapse_v375.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_375():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_sidebar_order_and_groups():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_375():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_sidebar_order_and_groups():
9
  html=(ROOT/"app.html").read_text(encoding="utf-8")
tests/test_smart_progress_avatar_sync_v441.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
6
 
7
  def test_simple_chat_hides_progress_until_needed():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
6
 
7
  def test_simple_chat_hides_progress_until_needed():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_sse_heartbeat_v401.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_401():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_chat_has_independent_heartbeat_wrapper():
9
  route=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_401():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_chat_has_independent_heartbeat_wrapper():
9
  route=(ROOT/"routes"/"chat.py").read_text(encoding="utf-8")
tests/test_ui_avatar_multitask_v440.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
6
 
7
  def test_assistant_avatar_removed_user_avatar_customizable():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
2
  ROOT=Path(__file__).resolve().parents[1]
3
 
4
  def test_version():
5
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
6
 
7
  def test_assistant_avatar_removed_user_avatar_customizable():
8
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
tests/test_unified_download_manager_v392.py CHANGED
@@ -3,7 +3,7 @@ import pathlib
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_392():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_download_proxy_route_registered():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_392():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_download_proxy_route_registered():
9
  route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
tests/test_unified_query_v390.py CHANGED
@@ -4,7 +4,7 @@ from services.unified_query import plan_unified_query
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_390():
7
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
8
 
9
  def test_joint_squid_environment_plan():
10
  p=plan_unified_query("查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL")
 
4
  ROOT=pathlib.Path(__file__).resolve().parents[1]
5
 
6
  def test_version_390():
7
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
8
 
9
  def test_joint_squid_environment_plan():
10
  p=plan_unified_query("查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL")
tests/test_v381_navigation_ocean_intent.py CHANGED
@@ -3,7 +3,7 @@ import pathlib, ast
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_381():
6
- assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.4.2"
7
 
8
  def test_recent_conversation_switches_to_chat():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
 
3
  ROOT=pathlib.Path(__file__).resolve().parents[1]
4
 
5
  def test_version_381():
6
+ assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="4.5.0"
7
 
8
  def test_recent_conversation_switches_to_chat():
9
  js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
ui_server.py CHANGED
@@ -1765,7 +1765,8 @@ def _looks_like_data_parameter(prompt: str) -> bool:
1765
  r"(?:19|20)\d{2}年|\d{1,2}月|\d{1,2}日|"
1766
  r"(?:19|20)\d{2}-\d{2}-\d{2}|"
1767
  r"[0-9.]+(?:°|[ew])|[0-9.]+[ewns]|"
1768
- r"经度|纬度|海域|区域|范围|变量|数据源|输出格式|日期|时间|年份|月份",
 
1769
  q,
1770
  ):
1771
  return True
@@ -1776,7 +1777,7 @@ def _looks_like_data_parameter(prompt: str) -> bool:
1776
  "ssrd","strd","tp","thetao","mlotst","zos","spco2","era5","cmems",
1777
  "oisst","occci","netcdf","geotiff","tiff","csv","xlsx","json",
1778
  "柔鱼","鱿鱼","squid","金枪鱼","tuna","捕捞","努力量","cpue",
1779
- "biomass","下载","导出","数据",
1780
  )
1781
  )
1782
 
 
1765
  r"(?:19|20)\d{2}年|\d{1,2}月|\d{1,2}日|"
1766
  r"(?:19|20)\d{2}-\d{2}-\d{2}|"
1767
  r"[0-9.]+(?:°|[ew])|[0-9.]+[ewns]|"
1768
+ r"经度|纬度|海域|区域|范围|变量|数据源|输出格式|日期|时间|年份|月份|"
1769
+ r"深度|水深|层位|图层|第\d+层|\d+(?:\.\d+)?米层|surface|layer",
1770
  q,
1771
  ):
1772
  return True
 
1777
  "ssrd","strd","tp","thetao","mlotst","zos","spco2","era5","cmems",
1778
  "oisst","occci","netcdf","geotiff","tiff","csv","xlsx","json",
1779
  "柔鱼","鱿鱼","squid","金枪鱼","tuna","捕捞","努力量","cpue",
1780
+ "biomass","下载","导出","数据","深度","水深","米层","图层","层位","surface","layer",
1781
  )
1782
  )
1783