Zin299 commited on
Commit
8acc853
·
1 Parent(s): 6724416

Deploy v3.7.1 P0 architecture refactor phase 2

Browse files
README.md CHANGED
@@ -9,7 +9,7 @@ app_port: 7860
9
 
10
  # Global Marine Foundation Data Agent
11
 
12
- Current UI release: **v3.7.0**.
13
 
14
 
15
  ## v3.4.0 稳定性重构(第一阶段)
 
9
 
10
  # Global Marine Foundation Data Agent
11
 
12
+ Current UI release: **v3.7.1**.
13
 
14
 
15
  ## v3.4.0 稳定性重构(第一阶段)
VERSION CHANGED
@@ -1 +1 @@
1
- 3.7.0
 
1
+ 3.7.1
app.html CHANGED
@@ -132,6 +132,8 @@ window.__SQUID_CONFIG__ = {
132
  };
133
  </script>
134
  <script src="/static/js/app.js?v=__APP_VERSION__"></script>
 
 
135
 
136
 
137
  </body>
 
132
  };
133
  </script>
134
  <script src="/static/js/app.js?v=__APP_VERSION__"></script>
135
+ <script src="/static/js/data-health.js?v=__APP_VERSION__"></script>
136
+ <script src="/static/js/project-package.js?v=__APP_VERSION__"></script>
137
 
138
 
139
  </body>
services/user_state.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json, time
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Callable
6
+
7
+ class UserStateStore:
8
+ """Server-side persistence for workspace/settings/favorites.
9
+
10
+ Keeps file persistence concerns out of the FastAPI assembly module.
11
+ """
12
+
13
+ def __init__(self, root: Path, validator: Callable[[str], bool], max_favorites: int = 300):
14
+ self.root = Path(root)
15
+ self.validator = validator
16
+ self.max_favorites = int(max_favorites)
17
+
18
+ def _require_user(self, user_id: str) -> str:
19
+ uid = str(user_id or "").strip()
20
+ if not self.validator(uid):
21
+ raise ValueError("invalid user_id")
22
+ return uid
23
+
24
+ def workspace_state_file(self, user_id: str) -> Path:
25
+ return self.root / f"{self._require_user(user_id)}.workspace.json"
26
+
27
+ def sanitize_workspace_sessions(self, items: Any) -> list[dict[str, Any]]:
28
+ out=[]
29
+ for x in items if isinstance(items,list) else []:
30
+ if not isinstance(x,dict): continue
31
+ thread_id=str(x.get("thread_id") or "")[:200]
32
+ if not thread_id: continue
33
+ messages=[]
34
+ for m in (x.get("messages") if isinstance(x.get("messages"),list) else [])[-80:]:
35
+ if not isinstance(m,dict): continue
36
+ messages.append({
37
+ "role":"assistant" if m.get("role")=="assistant" else "user",
38
+ "text":str(m.get("text") or "")[:50000],
39
+ "meta":str(m.get("meta") or "")[:1000],
40
+ "time":int(m.get("time") or int(time.time()*1000)),
41
+ })
42
+ out.append({
43
+ "thread_id":thread_id,
44
+ "title":str(x.get("title") or "")[:500],
45
+ "messages":messages,
46
+ "created_at":int(x.get("created_at") or int(time.time()*1000)),
47
+ "updated_at":int(x.get("updated_at") or int(time.time()*1000)),
48
+ })
49
+ out.sort(key=lambda x:int(x.get("updated_at") or 0), reverse=True)
50
+ return out[:100]
51
+
52
+ def sanitize_workspace_settings(self, value: Any) -> dict[str, Any]:
53
+ if not isinstance(value,dict): return {}
54
+ allowed={"fontSize","enterToSend","refreshSeconds","accent","defaultView","compact","reduceMotion","longitudeRange","timeDisplay","exportFormat","answerDetail","historyLimit","confirmDownload","messageActionTrigger"}
55
+ return {str(k):v for k,v in value.items() if k in allowed}
56
+
57
+ def read_workspace(self, user_id: str) -> dict[str, Any]:
58
+ path=self.workspace_state_file(user_id)
59
+ if not path.exists(): return {"sessions":[],"settings":{},"updated_at":""}
60
+ try: raw=json.loads(path.read_text(encoding="utf-8"))
61
+ except Exception: return {"sessions":[],"settings":{},"updated_at":""}
62
+ return {
63
+ "sessions":self.sanitize_workspace_sessions(raw.get("sessions",[])),
64
+ "settings":self.sanitize_workspace_settings(raw.get("settings",{})),
65
+ "updated_at":str(raw.get("updated_at") or ""),
66
+ }
67
+
68
+ def write_workspace(self, user_id: str, sessions: Any, settings: Any) -> dict[str, Any]:
69
+ clean_sessions=self.sanitize_workspace_sessions(sessions)
70
+ clean_settings=self.sanitize_workspace_settings(settings)
71
+ payload={
72
+ "schema":"squid-server-workspace",
73
+ "schema_version":1,
74
+ "updated_at":datetime.now().astimezone().isoformat(timespec="seconds"),
75
+ "sessions":clean_sessions,
76
+ "settings":clean_settings,
77
+ }
78
+ path=self.workspace_state_file(user_id)
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ tmp=path.with_suffix(path.suffix+".tmp")
81
+ tmp.write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding="utf-8")
82
+ tmp.replace(path)
83
+ return payload
84
+
85
+ def favorite_state_file(self, user_id: str) -> Path:
86
+ return self.root / f"{self._require_user(user_id)}.favorites.json"
87
+
88
+ def sanitize_favorite(self, item: Any) -> dict[str, Any] | None:
89
+ if not isinstance(item, dict): return None
90
+ kind=str(item.get("kind") or "answer")
91
+ if kind not in {"dataset","file","answer"}: kind="answer"
92
+ def cut(key: str, n: int) -> str: return str(item.get(key) or "")[:n]
93
+ return {
94
+ "kind":kind,
95
+ "id":cut("id",240),
96
+ "key":cut("key",800),
97
+ "title":cut("title",400),
98
+ "content":cut("content",60000),
99
+ "prompt":cut("prompt",10000),
100
+ "repository":cut("repository",500),
101
+ "path":cut("path",2000),
102
+ "created_at":int(item.get("created_at") or int(time.time()*1000)),
103
+ }
104
+
105
+ def read_favorites(self, user_id: str) -> list[dict[str, Any]]:
106
+ path=self.favorite_state_file(user_id)
107
+ if not path.exists(): return []
108
+ try: raw=json.loads(path.read_text(encoding="utf-8"))
109
+ except Exception: return []
110
+ items=raw.get("favorites",[]) if isinstance(raw,dict) else raw
111
+ out=[]
112
+ for item in items if isinstance(items,list) else []:
113
+ clean=self.sanitize_favorite(item)
114
+ if clean: out.append(clean)
115
+ return out[:self.max_favorites]
116
+
117
+ def write_favorites(self, user_id: str, favorites: list[dict[str, Any]]) -> list[dict[str, Any]]:
118
+ cleaned=[]; seen=set()
119
+ for item in favorites:
120
+ clean=self.sanitize_favorite(item)
121
+ if not clean: continue
122
+ ident=clean.get("id") or f"{clean.get('kind')}:{clean.get('key')}:{clean.get('title')}"
123
+ if ident in seen: continue
124
+ seen.add(ident); cleaned.append(clean)
125
+ if len(cleaned)>=self.max_favorites: break
126
+ path=self.favorite_state_file(user_id)
127
+ path.parent.mkdir(parents=True, exist_ok=True)
128
+ tmp=path.with_suffix(path.suffix+".tmp")
129
+ payload={
130
+ "schema":"squid-server-favorites",
131
+ "schema_version":1,
132
+ "updated_at":datetime.now().astimezone().isoformat(timespec="seconds"),
133
+ "favorites":cleaned,
134
+ }
135
+ tmp.write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding="utf-8")
136
+ tmp.replace(path)
137
+ return cleaned
138
+
139
+ def storage_mode(self) -> str:
140
+ try:
141
+ return "persistent" if str(self.root.resolve()).startswith("/data/") else "server_session"
142
+ except Exception:
143
+ return "server_session"
static/js/app.js CHANGED
@@ -903,167 +903,6 @@ function openServiceDiagnostics(data,appState){
903
  wrap.querySelector(".service-download-report").onclick=()=>{const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="marine-service-diagnostic-"+new Date().toISOString().replace(/[:.]/g,"-")+".json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)};
904
  }
905
 
906
- function projectPlanHtml(plan){
907
- const ocean=plan.ocean||[],fish=plan.fisheries||[],profile=plan.project_profile||{},reqs=plan.requirements||[],psum=plan.priority_summary||{};
908
- const pbadge=p=>'<span class="tag">'+esc(p||'推荐')+'</span>';
909
- const alt=x=>(x.alternatives?.length?'<div class="detail-note" style="margin-top:6px">替代:'+x.alternatives.map(esc).join(' / ')+'</div>':'');
910
- const oceanCard=x=>'<label class="package-rec selectable"><input class="pkg-source-check" type="checkbox" data-kind="ocean" data-key="'+esc(x.key)+'" checked><div class="package-rec-main"><div><b>'+esc(x.name_zh||x.name)+'</b><span>Ocean</span>'+pbadge(x.priority)+'</div><p>'+esc(x.reason||'')+'</p>'+(x.variables?.length?'<div class="tags">'+x.variables.slice(0,8).map(v=>'<span class="tag">'+esc(v)+'</span>').join('')+'</div>':'')+alt(x)+'</div></label>';
911
- const fishCard=x=>'<label class="package-rec selectable"><input class="pkg-source-check" type="checkbox" data-kind="fish" data-key="'+esc(x.name)+'" checked><div class="package-rec-main"><div><b>'+esc(x.name_zh||x.name)+'</b><span>'+esc(x.database||'Fisheries')+'</span>'+pbadge(x.priority)+'</div><p>'+esc(x.reason||'')+'</p>'+alt(x)+'</div></label>';
912
- const missing=plan.missing_conditions||[];
913
- const reqHtml=reqs.length?'<div class="detail-section"><h3>项目需要什么数据</h3><div class="package-estimate-grid">'+reqs.map(x=>'<div class="package-estimate-card"><b>'+esc(x.name)+' '+pbadge(x.priority)+'</b><span>'+esc(x.why||'')+'</span></div>').join('')+'</div></div>':'';
914
- return '<div class="package-plan-head"><div><span>任务类型</span><b>'+esc(profile.task_type||'综合分析')+'</b></div><div><span>研究领域</span><b>'+esc(profile.research_domain||'海洋综合')+'</b></div><div><span>研究对象</span><b>'+esc(profile.research_object||(profile.species||[]).join('、')||'未识别')+'</b></div><div><span>区域</span><b>'+esc(plan.region_name||'未识别')+'</b></div><div><span>时间</span><b>'+esc(plan.time_range||plan.date||'未识别')+'</b></div><div><span>推荐分级</span><b>必需 '+esc(psum.required||0)+' · 推荐 '+esc(psum.recommended||0)+' · 可选 '+esc(psum.optional||0)+'</b></div><div><span>Ocean 自动导出</span><b>'+(plan.ocean_export_ready?'可执行':'需补充精确日期/区域')+'</b></div></div>'+
915
- (missing.length?'<div class="package-warning">为了提高数据方案准确度,建议补充:<b>'+esc(missing.join('、'))+'</b>。当前仍可继续预估和打包。</div>':'')+
916
- reqHtml+
917
- '<div class="package-review-toolbar"><div><b>确认要打包的数据</b><span id="pkgSelectionSummary"></span></div><div><button id="pkgSelectAll" class="view-btn compact">全选</button><button id="pkgClearAll" class="view-btn compact">清空</button></div></div>'+
918
- '<div class="detail-section"><h3>Ocean 环境数据</h3>'+(ocean.length?ocean.map(oceanCard).join(''):'<div class="detail-note">当前项目描述未识别到需要的 Ocean 数据。</div>')+'</div>'+
919
- '<div class="detail-section"><h3>渔业数据</h3>'+(fish.length?fish.map(fishCard).join(''):'<div class="detail-note">当前项目描述未识别到需要的渔业数据。</div>')+'</div>'+
920
- '<div class="detail-note">'+esc(plan.note||'')+'</div>';
921
- }
922
-
923
-
924
- function openQualityDrilldown(title,rows,type){
925
- document.querySelector(".drawer-backdrop")?.remove();
926
- const wrap=document.createElement("div");wrap.className="drawer-backdrop";
927
- const table=(rows||[]).length?(
928
- '<div class="quality-drill-scroll"><table class="quality-drill-table"><thead><tr>'+
929
- (type==='duplicate_groups'
930
- ?'<th>同名文件</th><th>数量</th><th>路径</th>'
931
- :type==='unmapped_files'||type==='zero_byte_files'
932
- ?'<th>文件路径</th><th>原因</th>'
933
- :'<th>领域</th><th>数据源</th><th>字段</th><th>可信度</th><th>建议</th>')+
934
- '</tr></thead><tbody>'+
935
- (rows||[]).map(x=>{
936
- if(type==='duplicate_groups')return '<tr><td>'+esc(x.basename||'')+'</td><td>'+esc(x.count||0)+'</td><td>'+esc((x.paths||[]).join(';'))+'</td></tr>';
937
- if(type==='unmapped_files'||type==='zero_byte_files')return '<tr><td>'+esc(x.path||'')+'</td><td>'+esc(x.reason||'')+'</td></tr>';
938
- return '<tr><td>'+esc(x.group||'')+'</td><td>'+esc(x.source||'')+'</td><td>'+esc(x.field||'')+'</td><td>'+esc(x.confidence??'-')+'%</td><td>'+esc(x.action||'')+'</td></tr>';
939
- }).join('')+'</tbody></table></div>'
940
- ):'<div class="quality-empty">当前没有此类问题。</div>';
941
- wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true"><div class="drawer-head"><div><h2>'+esc(title)+'</h2><p>���接定位到具体数据源、字段或仓库文件。</p></div><button class="drawer-close" aria-label="关闭">×</button></div>'+table+'</aside>';
942
- document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
943
- }
944
-
945
- async function renderDataHealth(refresh=false){
946
- const root=$("workspaceInner");
947
- root.innerHTML='<div class="view-head"><div><h1>数据质量健康中心</h1><p>原生质量中心:完整度、可信度、仓库卫生度分开计算,并可下钻到具体问题。</p></div><div class="view-actions"><button id="healthRepoDetail" class="view-btn">仓库体检</button><button id="healthMetaDetail" class="view-btn">元数据审计</button><button id="healthExport" class="view-btn" disabled>↓ 导出报告</button><button id="healthRefresh" class="view-btn">↻ 重新检查</button></div></div><div class="loading">正在读取实时目录并计算健康度…</div>';
948
- $("healthRefresh").onclick=()=>renderDataHealth(true);$("healthRepoDetail").onclick=openDatasetQuality;$("healthMetaDetail").onclick=openMetadataAudit;
949
- try{
950
- const d=await j('/api/sidebar/data-health'+(refresh?'?refresh=true':'')),s=d.summary||{},domains=d.domains||[],recs=d.recommendations||[],issues=d.issue_details||{};
951
- const gradeText='等级 '+esc(d.grade||'-')+' · '+esc(d.label||'');
952
- const detailBtn=(type,label,count)=>'<button class="health-link health-detail-btn" data-detail-type="'+esc(type)+'">'+esc(label)+' '+esc(count)+' →</button>';
953
- const domainHtml=domains.map(x=>{
954
- const missing=Number(x.missing_field_count||0),pending=Number(x.pending_field_count||0);
955
- return '<div class="health-domain"><div class="health-domain-head"><div><b>'+esc(x.group)+'</b><div class="row-meta">已接入 '+esc(x.available_count)+' / '+esc(x.dataset_count)+' 个来源</div></div></div>'+
956
- '<div class="health-domain-metrics"><div class="health-domain-metric"><span>完整度</span><b>'+esc(x.metadata_score)+'%</b></div><div class="health-domain-metric"><span>可信度</span><b>'+esc(x.confidence_score)+'%</b></div></div>'+
957
- '<div class="health-domain-links">'+
958
- (missing?detailBtn('missing_fields','查看缺失字段',missing):'<span class="row-meta">缺失字段 0</span>')+
959
- (pending?detailBtn('pending_checks','查看待核验字段',pending):'<span class="row-meta">待文件级核验 0</span>')+
960
- '</div></div>'
961
- }).join('');
962
- const recHtml=recs.map(x=>'<div class="health-rec-item '+esc(x.severity||'info')+'"><b>'+esc(x.title||'')+'</b><p>'+esc(x.detail||'')+'</p>'+(x.detail_type?'<button class="health-link health-detail-btn" data-detail-type="'+esc(x.detail_type)+'">查看明细 →</button>':'')+'</div>').join('');
963
- root.innerHTML='<div class="view-head"><div><h1>数据质量健康中心</h1><p>检查时间:'+esc(formatTime(d.checked_at))+'。完整度与可信度分开统计;健康分用于数据资产治理,不等同于科学适用性评分。</p></div><div class="view-actions"><button id="healthRepoDetail" class="view-btn">仓库体检</button><button id="healthMetaDetail" class="view-btn">元数据审计</button><button id="healthExport" class="view-btn">↓ 导出报告</button><button id="healthRefresh" class="view-btn">↻ 重新检查</button></div></div>'+
964
- '<div class="health-hero"><div class="health-score-card"><div class="health-score">'+esc(d.health_score)+'</div><div class="health-grade">'+gradeText+'</div><div class="row-meta" style="margin-top:6px">综合健康度 / 100</div></div><div class="health-panel"><div class="health-subgrid">'+
965
- '<div class="health-kpi"><span>元数据完整度</span><b>'+esc(d.metadata_score)+'%</b></div>'+
966
- '<div class="health-kpi"><span>数据可信度</span><b>'+esc(d.confidence_score)+'%</b></div>'+
967
- '<div class="health-kpi"><span>仓库卫生度</span><b>'+esc(d.hygiene_score)+'%</b></div>'+
968
- '<div class="health-kpi"><span>实时文件</span><b>'+esc(s.file_count||0)+'</b></div>'+
969
- '<div class="health-kpi"><span>数据总量</span><b>'+esc(formatBytes(s.total_size_bytes||0))+'</b></div>'+
970
- '<div class="health-kpi"><span>零字节文件</span><b>'+esc(s.zero_byte_count||0)+'</b></div>'+
971
- '<div class="health-kpi actionable health-detail-btn" data-detail-type="unmapped_files"><span>未归类文件</span><b>'+esc(s.unmapped_file_count||0)+'</b><span class="row-meta">点击查看</span></div>'+
972
- '<div class="health-kpi actionable health-detail-btn" data-detail-type="duplicate_groups"><span>同名文件组</span><b>'+esc(s.duplicate_group_count||0)+'</b><span class="row-meta">点击查看</span></div>'+
973
- '</div></div></div>'+
974
- '<div class="detail-section"><h3>分领域质量</h3><div class="health-domain-grid">'+domainHtml+'</div></div>'+
975
- '<div class="detail-section"><h3>整改建议</h3><div class="health-rec">'+recHtml+'</div></div>'+
976
- '<div class="health-method"><b>评分方法:</b>'+esc(d.method?.formula||'')+'。'+esc(d.method?.note||'')+'<br><b>解释:</b>完整度回答“字段齐不齐”;可信度回答“这些字段是否经过接口/文件读取核验”。因此 Fisheries 即使仓库级元数据完整,也会因为内容字段待核验而降低可信度。</div>';
977
- $("healthRefresh").onclick=()=>renderDataHealth(true);$("healthRepoDetail").onclick=openDatasetQuality;$("healthMetaDetail").onclick=openMetadataAudit;
978
- $("healthExport").onclick=()=>{const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='marine_data_health_'+new Date().toISOString().slice(0,10)+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('数据质量报告已导出')};
979
- root.querySelectorAll('.health-detail-btn').forEach(btn=>btn.onclick=()=>{
980
- const type=btn.dataset.detailType;
981
- const titles={unmapped_files:'未归类文件',zero_byte_files:'零字节文件',duplicate_groups:'同名文件组',missing_fields:'缺失字段',pending_checks:'待文件级核验'};
982
- openQualityDrilldown(titles[type]||'质量问题明细',issues[type]||[],type);
983
- });
984
- const badge=$("navHealthBadge");if(badge){badge.textContent=String(d.grade||'-');badge.classList.toggle('warn',Number(d.health_score||0)<80)}
985
- }catch(err){root.innerHTML='<div class="empty-view"><b>数据质量检查失败</b>'+esc(err.message||String(err))+'</div>'}
986
- }
987
-
988
- async function renderProjectPackage(){
989
- const root=$("workspaceInner");
990
- root.innerHTML='<div class="package-chat"><div class="package-hero"><div class="package-icon">📦</div><h1>需要什么数据? <span style="font-size:10px;opacity:.48">v'+esc(APP_VERSION)+'</span></h1><p>描述项目目标,我先给出数据方案;你确认需要哪些数据源后,再生成按数据库分类的 ZIP。</p></div>'+
991
- '<div class="package-composer"><textarea id="projectPackageText" class="package-text" rows="4" placeholder="例如:研究 2002-08-15 西北太平洋柔鱼适生区,需要 SST、叶绿素、海流和历史捕捞努力量……"></textarea>'+
992
- '<div class="package-composer-foot"><div class="package-options"><label><input id="pkgOcean" type="checkbox" checked> Ocean</label><label><input id="pkgTuna" type="checkbox" checked> Tuna</label><label><input id="pkgSquid" type="checkbox" checked> Squid</label><label>ZIP <select id="pkgSize"><option value="100">100 MB</option><option value="300" selected>300 MB</option><option value="600">600 MB</option><option value="1200">1.2 GB</option></select></label></div><button id="pkgAnalyze" class="view-btn primary package-send">分析数据 →</button></div></div>'+
993
- '<div class="package-hint">推荐流程:描述项目 → 分析数据 → 确认数据源 → 预估大小 → 生成 ZIP</div><div id="packageResult"></div></div>';
994
- let lastPlan=null;
995
- const selected=()=>{
996
- if(!lastPlan)return {ocean:null,fish:null};
997
- const checks=[...document.querySelectorAll('.pkg-source-check')];
998
- return {
999
- ocean:checks.filter(x=>x.dataset.kind==='ocean'&&x.checked).map(x=>x.dataset.key),
1000
- fish:checks.filter(x=>x.dataset.kind==='fish'&&x.checked).map(x=>x.dataset.key)
1001
- };
1002
- };
1003
- const payload=()=>{
1004
- const pick=selected();
1005
- return {project:$("projectPackageText").value.trim(),max_package_mb:Number($("pkgSize").value),include_ocean:$("pkgOcean").checked,include_tuna:$("pkgTuna").checked,include_squid:$("pkgSquid").checked,selected_ocean_keys:pick.ocean,selected_fisheries_names:pick.fish};
1006
- };
1007
- const updateSelectionSummary=()=>{
1008
- const el=$("pkgSelectionSummary"); if(!el)return;
1009
- const pick=selected();
1010
- el.textContent='已选 '+pick.ocean.length+' 个 Ocean + '+pick.fish.length+' 个 Fisheries 数据源';
1011
- };
1012
- const bindPlanControls=()=>{
1013
- document.querySelectorAll('.pkg-source-check').forEach(x=>x.onchange=updateSelectionSummary);
1014
- $("pkgSelectAll")?.addEventListener('click',()=>{document.querySelectorAll('.pkg-source-check').forEach(x=>x.checked=true);updateSelectionSummary()});
1015
- $("pkgClearAll")?.addEventListener('click',()=>{document.querySelectorAll('.pkg-source-check').forEach(x=>x.checked=false);updateSelectionSummary()});
1016
- updateSelectionSummary();
1017
- };
1018
- const estimate=async()=>{
1019
- const b=$("pkgEstimate"),box=$("packageEstimateResult"),body=payload();
1020
- const chosen=(body.selected_ocean_keys?.length||0)+(body.selected_fisheries_names?.length||0);
1021
- if(!lastPlan||!chosen){toast("请先分析并至少选择一个数据源");return}
1022
- b.disabled=true;b.textContent="预估中…";box.innerHTML='<div class="loading">正在读取实时文件树并计算预计内容…</div>';
1023
- try{
1024
- const r=await j("/api/sidebar/project-package/estimate",{method:"POST",body:JSON.stringify(body)}),db=r.per_database||{};
1025
- const cards=Object.entries(db).map(([name,x])=>'<div class="package-estimate-card"><b>'+esc(name)+'</b><span>'+esc(x.file_count||0)+' 个候选文件 · '+esc(formatBytes(x.size_bytes||0))+'</span></div>').join('');
1026
- const ocean=r.ocean||{};
1027
- box.innerHTML='<div class="package-estimate"><b>预计数据包</b><div class="row-meta">已知大小 '+esc(formatBytes(r.estimated_known_bytes||0))+' / 上限 '+esc(formatBytes(r.package_limit_bytes||0))+' · '+esc(r.estimated_known_file_count||0)+' 个渔业候选文件'+(ocean.request_count?' · '+esc(ocean.request_count)+' 个 Ocean 请求':'')+'</div><div class="package-estimate-grid">'+cards+'</div><div class="detail-note" style="margin-top:8px">'+esc(r.note||'')+'</div></div>';
1028
- }catch(err){box.innerHTML='<div class="detail-note">预估失败:'+esc(err.message||String(err))+'</div>'}
1029
- finally{b.disabled=false;b.textContent="预估数据包"}
1030
- };
1031
- const build=async()=>{
1032
- const b=$("pkgBuild"),out=$("packageResult"),body=payload();
1033
- if(!lastPlan){toast("请先分析数据需求");return}
1034
- const chosen=(body.selected_ocean_keys?.length||0)+(body.selected_fisheries_names?.length||0);
1035
- if(!chosen){toast("请至少选择一个数据源");return}
1036
- b.disabled=true;b.textContent="正在打包…";
1037
- out.insertAdjacentHTML("afterbegin",'<div id="packageBuildStatus" class="loading">正在读取所选真实文件并按数据库分类打包…</div>');
1038
- try{
1039
- const r=await j("/api/sidebar/project-package/build",{method:"POST",body:JSON.stringify(body)}),status=$("packageBuildStatus");
1040
- if(status)status.outerHTML='<div class="package-ready"><b>✓ ZIP 已生成</b><span>'+esc(r.included_file_count)+' 个真实数据文件 · '+formatBytes(r.size_bytes)+' · '+esc(r.skipped_count)+' 项未加入</span><button id="packageManifest" class="view-btn">查看清单</button><button id="packageDownload" class="view-btn primary">↓ 下载 ZIP</button></div><div id="packageManifestBox"></div>';
1041
- $("packageManifest").onclick=()=>{
1042
- const box=$("packageManifestBox"),files=r.included_files||[],skipped=r.skipped||[];
1043
- if(box.innerHTML){box.innerHTML="";return}
1044
- const rows=files.map(x=>'<div class="package-manifest-row"><b>'+esc(x.database||'')+'</b><span title="'+esc(x.path||'')+'">'+esc(x.source||'')+' · '+esc(x.path||'')+'</span><span>'+esc(formatBytes(x.size_bytes||0))+'</span></div>').join('');
1045
- const skip=skipped.length?'<div class="detail-note" style="margin-top:8px">另有 '+esc(skipped.length)+' 项未加入,可在 ZIP 的 manifest.json 查看原因。</div>':'';
1046
- box.innerHTML='<div class="package-manifest">'+(rows||'<div class="detail-note">没有已加入文件。</div>')+'</div>'+skip;
1047
- };
1048
- $("packageDownload").onclick=async()=>{const btn=$("packageDownload");btn.disabled=true;btn.textContent="下载中…";try{const resp=await fetch(r.download_url,{headers:authHeaders()});if(!resp.ok)throw Error(await resp.text());const blob=await resp.blob(),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="project_data_package.zip";a.click();setTimeout(()=>URL.revokeObjectURL(url),2000);toast("项目数据包已下载")}catch(err){toast("下载失败:"+(err.message||String(err)))}finally{btn.disabled=false;btn.textContent="↓ 下载 ZIP"}};
1049
- }catch(err){const status=$("packageBuildStatus");if(status)status.outerHTML='<div class="empty-view"><b>打包失败</b>'+esc(err.message||String(err))+'</div>'}
1050
- finally{b.disabled=false;b.textContent="生成所选 ZIP"}
1051
- };
1052
- $("pkgAnalyze").onclick=async()=>{
1053
- const b=$("pkgAnalyze"),out=$("packageResult"),body=payload();
1054
- body.selected_ocean_keys=null;body.selected_fisheries_names=null;
1055
- if(!body.project){toast("请先描述项目");return}
1056
- b.disabled=true;b.textContent="分析中…";out.innerHTML='<div class="loading">正在理解项目目标并规划必需 / 推荐 / 可选数据…</div>';
1057
- try{
1058
- lastPlan=await j("/api/sidebar/project-package/analyze",{method:"POST",body:JSON.stringify(body)});
1059
- out.innerHTML='<div class="package-result"><div class="view-head"><div><h2>Agent 数据方案</h2><p>先理解研究任务,再按“必需 / 推荐 / 可选”映射到真实数据源;你可以在打包前调整。</p></div></div>'+projectPlanHtml(lastPlan)+'<div class="package-actions"><button id="pkgEstimate" class="view-btn">预估数据包</button><button id="pkgBuild" class="view-btn primary">生成所选 ZIP</button></div><div id="packageEstimateResult"></div></div>';
1060
- bindPlanControls();$("pkgEstimate").onclick=estimate;$("pkgBuild").onclick=build;
1061
- }catch(err){out.innerHTML='<div class="empty-view"><b>分析失败</b>'+esc(err.message||String(err))+'</div>'}
1062
- finally{b.disabled=false;b.textContent="分析数据 →"}
1063
- };
1064
- $("projectPackageText").addEventListener("keydown",e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey)){e.preventDefault();$("pkgAnalyze").click()}});
1065
- }
1066
-
1067
  async function renderServices(){
1068
  const root=$("workspaceInner");
1069
  root.innerHTML='<div class="view-head"><div><h1>数据服务</h1><p>实时检查模型运行时、Marine API、Ocean 与 Hugging Face 数据平面,并给出响应时延和诊断建议。</p></div><button id="refreshServices" class="view-btn">↻ 立即检测</button></div><div class="loading">正在检测服务…</div>';
 
903
  wrap.querySelector(".service-download-report").onclick=()=>{const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="marine-service-diagnostic-"+new Date().toISOString().replace(/[:.]/g,"-")+".json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)};
904
  }
905
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906
  async function renderServices(){
907
  const root=$("workspaceInner");
908
  root.innerHTML='<div class="view-head"><div><h1>数据服务</h1><p>实时检查模型运行时、Marine API、Ocean 与 Hugging Face 数据平面,并给出响应时延和诊断建议。</p></div><button id="refreshServices" class="view-btn">↻ 立即检测</button></div><div class="loading">正在检测服务…</div>';
static/js/data-health.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v3.7.1 P0 phase 2: data quality UI module */
2
+ function openQualityDrilldown(title,rows,type){
3
+ document.querySelector(".drawer-backdrop")?.remove();
4
+ const wrap=document.createElement("div");wrap.className="drawer-backdrop";
5
+ const table=(rows||[]).length?(
6
+ '<div class="quality-drill-scroll"><table class="quality-drill-table"><thead><tr>'+
7
+ (type==='duplicate_groups'
8
+ ?'<th>同名文件</th><th>数量</th><th>路径</th>'
9
+ :type==='unmapped_files'||type==='zero_byte_files'
10
+ ?'<th>文件路径</th><th>原因</th>'
11
+ :'<th>领域</th><th>数据源</th><th>字段</th><th>可信度</th><th>建议</th>')+
12
+ '</tr></thead><tbody>'+
13
+ (rows||[]).map(x=>{
14
+ if(type==='duplicate_groups')return '<tr><td>'+esc(x.basename||'')+'</td><td>'+esc(x.count||0)+'</td><td>'+esc((x.paths||[]).join(';'))+'</td></tr>';
15
+ if(type==='unmapped_files'||type==='zero_byte_files')return '<tr><td>'+esc(x.path||'')+'</td><td>'+esc(x.reason||'')+'</td></tr>';
16
+ return '<tr><td>'+esc(x.group||'')+'</td><td>'+esc(x.source||'')+'</td><td>'+esc(x.field||'')+'</td><td>'+esc(x.confidence??'-')+'%</td><td>'+esc(x.action||'')+'</td></tr>';
17
+ }).join('')+'</tbody></table></div>'
18
+ ):'<div class="quality-empty">当前没有此类问题。</div>';
19
+ wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true"><div class="drawer-head"><div><h2>'+esc(title)+'</h2><p>直接定位到具体数据源、字段或仓库文件。</p></div><button class="drawer-close" aria-label="关闭">×</button></div>'+table+'</aside>';
20
+ document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
21
+ }
22
+
23
+ async function renderDataHealth(refresh=false){
24
+ const root=$("workspaceInner");
25
+ root.innerHTML='<div class="view-head"><div><h1>数据质量健康中心</h1><p>原生质量中心:完整度、可信度、仓库卫生度分开计算,并可下钻到具体问题。</p></div><div class="view-actions"><button id="healthRepoDetail" class="view-btn">仓库体检</button><button id="healthMetaDetail" class="view-btn">元数据审计</button><button id="healthExport" class="view-btn" disabled>↓ 导出报告</button><button id="healthRefresh" class="view-btn">↻ 重新检查</button></div></div><div class="loading">正在读取实时目录并计算健康度…</div>';
26
+ $("healthRefresh").onclick=()=>renderDataHealth(true);$("healthRepoDetail").onclick=openDatasetQuality;$("healthMetaDetail").onclick=openMetadataAudit;
27
+ try{
28
+ const d=await j('/api/sidebar/data-health'+(refresh?'?refresh=true':'')),s=d.summary||{},domains=d.domains||[],recs=d.recommendations||[],issues=d.issue_details||{};
29
+ const gradeText='等级 '+esc(d.grade||'-')+' · '+esc(d.label||'');
30
+ const detailBtn=(type,label,count)=>'<button class="health-link health-detail-btn" data-detail-type="'+esc(type)+'">'+esc(label)+' '+esc(count)+' →</button>';
31
+ const domainHtml=domains.map(x=>{
32
+ const missing=Number(x.missing_field_count||0),pending=Number(x.pending_field_count||0);
33
+ return '<div class="health-domain"><div class="health-domain-head"><div><b>'+esc(x.group)+'</b><div class="row-meta">已接入 '+esc(x.available_count)+' / '+esc(x.dataset_count)+' 个来源</div></div></div>'+
34
+ '<div class="health-domain-metrics"><div class="health-domain-metric"><span>完整度</span><b>'+esc(x.metadata_score)+'%</b></div><div class="health-domain-metric"><span>可信度</span><b>'+esc(x.confidence_score)+'%</b></div></div>'+
35
+ '<div class="health-domain-links">'+
36
+ (missing?detailBtn('missing_fields','查看缺失字段',missing):'<span class="row-meta">缺失字段 0</span>')+
37
+ (pending?detailBtn('pending_checks','查看待核验字段',pending):'<span class="row-meta">待文件级核验 0</span>')+
38
+ '</div></div>'
39
+ }).join('');
40
+ const recHtml=recs.map(x=>'<div class="health-rec-item '+esc(x.severity||'info')+'"><b>'+esc(x.title||'')+'</b><p>'+esc(x.detail||'')+'</p>'+(x.detail_type?'<button class="health-link health-detail-btn" data-detail-type="'+esc(x.detail_type)+'">查看明细 →</button>':'')+'</div>').join('');
41
+ root.innerHTML='<div class="view-head"><div><h1>数据质量健康中心</h1><p>检查时间:'+esc(formatTime(d.checked_at))+'。完整度与可信度分开统计;健康分用于数据资产治理,不等同于科学适用性评分。</p></div><div class="view-actions"><button id="healthRepoDetail" class="view-btn">仓库体检</button><button id="healthMetaDetail" class="view-btn">元数据审计</button><button id="healthExport" class="view-btn">↓ 导出报告</button><button id="healthRefresh" class="view-btn">↻ 重新检查</button></div></div>'+
42
+ '<div class="health-hero"><div class="health-score-card"><div class="health-score">'+esc(d.health_score)+'</div><div class="health-grade">'+gradeText+'</div><div class="row-meta" style="margin-top:6px">综合健康度 / 100</div></div><div class="health-panel"><div class="health-subgrid">'+
43
+ '<div class="health-kpi"><span>元数据完整度</span><b>'+esc(d.metadata_score)+'%</b></div>'+
44
+ '<div class="health-kpi"><span>数据可信度</span><b>'+esc(d.confidence_score)+'%</b></div>'+
45
+ '<div class="health-kpi"><span>仓库卫生度</span><b>'+esc(d.hygiene_score)+'%</b></div>'+
46
+ '<div class="health-kpi"><span>实时文件</span><b>'+esc(s.file_count||0)+'</b></div>'+
47
+ '<div class="health-kpi"><span>数据总量</span><b>'+esc(formatBytes(s.total_size_bytes||0))+'</b></div>'+
48
+ '<div class="health-kpi"><span>零字节文件</span><b>'+esc(s.zero_byte_count||0)+'</b></div>'+
49
+ '<div class="health-kpi actionable health-detail-btn" data-detail-type="unmapped_files"><span>未归类文件</span><b>'+esc(s.unmapped_file_count||0)+'</b><span class="row-meta">点击查看</span></div>'+
50
+ '<div class="health-kpi actionable health-detail-btn" data-detail-type="duplicate_groups"><span>同名文件组</span><b>'+esc(s.duplicate_group_count||0)+'</b><span class="row-meta">点击查看</span></div>'+
51
+ '</div></div></div>'+
52
+ '<div class="detail-section"><h3>分领域质量</h3><div class="health-domain-grid">'+domainHtml+'</div></div>'+
53
+ '<div class="detail-section"><h3>整改建议</h3><div class="health-rec">'+recHtml+'</div></div>'+
54
+ '<div class="health-method"><b>评分方法:</b>'+esc(d.method?.formula||'')+'。'+esc(d.method?.note||'')+'<br><b>解释:</b>完整度回答“字段齐不齐”;可信度回答“这些字段是否经过接口/文件读取核验”。因此 Fisheries 即使仓库级元数据完整,也会因为内容字段待核验而降低可信度。</div>';
55
+ $("healthRefresh").onclick=()=>renderDataHealth(true);$("healthRepoDetail").onclick=openDatasetQuality;$("healthMetaDetail").onclick=openMetadataAudit;
56
+ $("healthExport").onclick=()=>{const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='marine_data_health_'+new Date().toISOString().slice(0,10)+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('数据质量报告已导出')};
57
+ root.querySelectorAll('.health-detail-btn').forEach(btn=>btn.onclick=()=>{
58
+ const type=btn.dataset.detailType;
59
+ const titles={unmapped_files:'未归类文件',zero_byte_files:'零字节文件',duplicate_groups:'同名文件组',missing_fields:'缺失字段',pending_checks:'待文件级核验'};
60
+ openQualityDrilldown(titles[type]||'质量问题明细',issues[type]||[],type);
61
+ });
62
+ const badge=$("navHealthBadge");if(badge){badge.textContent=String(d.grade||'-');badge.classList.toggle('warn',Number(d.health_score||0)<80)}
63
+ }catch(err){root.innerHTML='<div class="empty-view"><b>数据质量检查失败</b>'+esc(err.message||String(err))+'</div>'}
64
+ }
static/js/project-package.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v3.7.1 P0 phase 2: project data package UI module */
2
+ function projectPlanHtml(plan){
3
+ const ocean=plan.ocean||[],fish=plan.fisheries||[],profile=plan.project_profile||{},reqs=plan.requirements||[],psum=plan.priority_summary||{};
4
+ const pbadge=p=>'<span class="tag">'+esc(p||'推荐')+'</span>';
5
+ const alt=x=>(x.alternatives?.length?'<div class="detail-note" style="margin-top:6px">替代:'+x.alternatives.map(esc).join(' / ')+'</div>':'');
6
+ const oceanCard=x=>'<label class="package-rec selectable"><input class="pkg-source-check" type="checkbox" data-kind="ocean" data-key="'+esc(x.key)+'" checked><div class="package-rec-main"><div><b>'+esc(x.name_zh||x.name)+'</b><span>Ocean</span>'+pbadge(x.priority)+'</div><p>'+esc(x.reason||'')+'</p>'+(x.variables?.length?'<div class="tags">'+x.variables.slice(0,8).map(v=>'<span class="tag">'+esc(v)+'</span>').join('')+'</div>':'')+alt(x)+'</div></label>';
7
+ const fishCard=x=>'<label class="package-rec selectable"><input class="pkg-source-check" type="checkbox" data-kind="fish" data-key="'+esc(x.name)+'" checked><div class="package-rec-main"><div><b>'+esc(x.name_zh||x.name)+'</b><span>'+esc(x.database||'Fisheries')+'</span>'+pbadge(x.priority)+'</div><p>'+esc(x.reason||'')+'</p>'+alt(x)+'</div></label>';
8
+ const missing=plan.missing_conditions||[];
9
+ const reqHtml=reqs.length?'<div class="detail-section"><h3>项目需要什么数据</h3><div class="package-estimate-grid">'+reqs.map(x=>'<div class="package-estimate-card"><b>'+esc(x.name)+' '+pbadge(x.priority)+'</b><span>'+esc(x.why||'')+'</span></div>').join('')+'</div></div>':'';
10
+ return '<div class="package-plan-head"><div><span>任务类型</span><b>'+esc(profile.task_type||'综合分析')+'</b></div><div><span>研究领域</span><b>'+esc(profile.research_domain||'海洋综合')+'</b></div><div><span>研究对象</span><b>'+esc(profile.research_object||(profile.species||[]).join('、')||'未识别')+'</b></div><div><span>区域</span><b>'+esc(plan.region_name||'未识别')+'</b></div><div><span>时间</span><b>'+esc(plan.time_range||plan.date||'未识别')+'</b></div><div><span>推荐分级</span><b>必需 '+esc(psum.required||0)+' · 推荐 '+esc(psum.recommended||0)+' · 可选 '+esc(psum.optional||0)+'</b></div><div><span>Ocean 自动导出</span><b>'+(plan.ocean_export_ready?'可执行':'需补充精确日期/区域')+'</b></div></div>'+
11
+ (missing.length?'<div class="package-warning">为了提高数据方案准确度,建议补充:<b>'+esc(missing.join('、'))+'</b>。当前仍可继续预估和打包。</div>':'')+
12
+ reqHtml+
13
+ '<div class="package-review-toolbar"><div><b>确认要打包的数据</b><span id="pkgSelectionSummary"></span></div><div><button id="pkgSelectAll" class="view-btn compact">全选</button><button id="pkgClearAll" class="view-btn compact">清空</button></div></div>'+
14
+ '<div class="detail-section"><h3>Ocean 环境数据</h3>'+(ocean.length?ocean.map(oceanCard).join(''):'<div class="detail-note">当前项目描述未识别到需要的 Ocean 数据。</div>')+'</div>'+
15
+ '<div class="detail-section"><h3>渔业数据</h3>'+(fish.length?fish.map(fishCard).join(''):'<div class="detail-note">当前项目描述未识别到需要的渔业数据。</div>')+'</div>'+
16
+ '<div class="detail-note">'+esc(plan.note||'')+'</div>';
17
+ }
18
+
19
+ async function renderProjectPackage(){
20
+ const root=$("workspaceInner");
21
+ root.innerHTML='<div class="package-chat"><div class="package-hero"><div class="package-icon">📦</div><h1>需要什么数据? <span style="font-size:10px;opacity:.48">v'+esc(APP_VERSION)+'</span></h1><p>描述项目目标,我先给出数据方案;你确认需要哪些数据源后,再生成按数据库分类的 ZIP。</p></div>'+
22
+ '<div class="package-composer"><textarea id="projectPackageText" class="package-text" rows="4" placeholder="例如:研究 2002-08-15 西北太平洋柔鱼适生区,需要 SST、叶绿素、海流和历史捕捞努力量……"></textarea>'+
23
+ '<div class="package-composer-foot"><div class="package-options"><label><input id="pkgOcean" type="checkbox" checked> Ocean</label><label><input id="pkgTuna" type="checkbox" checked> Tuna</label><label><input id="pkgSquid" type="checkbox" checked> Squid</label><label>ZIP <select id="pkgSize"><option value="100">100 MB</option><option value="300" selected>300 MB</option><option value="600">600 MB</option><option value="1200">1.2 GB</option></select></label></div><button id="pkgAnalyze" class="view-btn primary package-send">分析数据 →</button></div></div>'+
24
+ '<div class="package-hint">推荐流程:描述项目 → 分析数据 → 确认数据源 → 预估大小 → 生成 ZIP</div><div id="packageResult"></div></div>';
25
+ let lastPlan=null;
26
+ const selected=()=>{
27
+ if(!lastPlan)return {ocean:null,fish:null};
28
+ const checks=[...document.querySelectorAll('.pkg-source-check')];
29
+ return {
30
+ ocean:checks.filter(x=>x.dataset.kind==='ocean'&&x.checked).map(x=>x.dataset.key),
31
+ fish:checks.filter(x=>x.dataset.kind==='fish'&&x.checked).map(x=>x.dataset.key)
32
+ };
33
+ };
34
+ const payload=()=>{
35
+ const pick=selected();
36
+ return {project:$("projectPackageText").value.trim(),max_package_mb:Number($("pkgSize").value),include_ocean:$("pkgOcean").checked,include_tuna:$("pkgTuna").checked,include_squid:$("pkgSquid").checked,selected_ocean_keys:pick.ocean,selected_fisheries_names:pick.fish};
37
+ };
38
+ const updateSelectionSummary=()=>{
39
+ const el=$("pkgSelectionSummary"); if(!el)return;
40
+ const pick=selected();
41
+ el.textContent='已选 '+pick.ocean.length+' 个 Ocean + '+pick.fish.length+' 个 Fisheries 数据源';
42
+ };
43
+ const bindPlanControls=()=>{
44
+ document.querySelectorAll('.pkg-source-check').forEach(x=>x.onchange=updateSelectionSummary);
45
+ $("pkgSelectAll")?.addEventListener('click',()=>{document.querySelectorAll('.pkg-source-check').forEach(x=>x.checked=true);updateSelectionSummary()});
46
+ $("pkgClearAll")?.addEventListener('click',()=>{document.querySelectorAll('.pkg-source-check').forEach(x=>x.checked=false);updateSelectionSummary()});
47
+ updateSelectionSummary();
48
+ };
49
+ const estimate=async()=>{
50
+ const b=$("pkgEstimate"),box=$("packageEstimateResult"),body=payload();
51
+ const chosen=(body.selected_ocean_keys?.length||0)+(body.selected_fisheries_names?.length||0);
52
+ if(!lastPlan||!chosen){toast("请先分析并至少选择一个数据源");return}
53
+ b.disabled=true;b.textContent="预估中…";box.innerHTML='<div class="loading">正在读取实时文件树并计算预计内容…</div>';
54
+ try{
55
+ const r=await j("/api/sidebar/project-package/estimate",{method:"POST",body:JSON.stringify(body)}),db=r.per_database||{};
56
+ const cards=Object.entries(db).map(([name,x])=>'<div class="package-estimate-card"><b>'+esc(name)+'</b><span>'+esc(x.file_count||0)+' 个候选文件 · '+esc(formatBytes(x.size_bytes||0))+'</span></div>').join('');
57
+ const ocean=r.ocean||{};
58
+ box.innerHTML='<div class="package-estimate"><b>预计数据包</b><div class="row-meta">已知大小 '+esc(formatBytes(r.estimated_known_bytes||0))+' / 上限 '+esc(formatBytes(r.package_limit_bytes||0))+' · '+esc(r.estimated_known_file_count||0)+' 个渔业候选文件'+(ocean.request_count?' · '+esc(ocean.request_count)+' 个 Ocean 请求':'')+'</div><div class="package-estimate-grid">'+cards+'</div><div class="detail-note" style="margin-top:8px">'+esc(r.note||'')+'</div></div>';
59
+ }catch(err){box.innerHTML='<div class="detail-note">预估失败:'+esc(err.message||String(err))+'</div>'}
60
+ finally{b.disabled=false;b.textContent="预估数据包"}
61
+ };
62
+ const build=async()=>{
63
+ const b=$("pkgBuild"),out=$("packageResult"),body=payload();
64
+ if(!lastPlan){toast("请先分析数据需求");return}
65
+ const chosen=(body.selected_ocean_keys?.length||0)+(body.selected_fisheries_names?.length||0);
66
+ if(!chosen){toast("请至少选择一个数据源");return}
67
+ b.disabled=true;b.textContent="正在打包…";
68
+ out.insertAdjacentHTML("afterbegin",'<div id="packageBuildStatus" class="loading">正在读取所选真实文件并按数据库分类打包…</div>');
69
+ try{
70
+ const r=await j("/api/sidebar/project-package/build",{method:"POST",body:JSON.stringify(body)}),status=$("packageBuildStatus");
71
+ if(status)status.outerHTML='<div class="package-ready"><b>✓ ZIP 已生成</b><span>'+esc(r.included_file_count)+' 个真实数据文件 · '+formatBytes(r.size_bytes)+' · '+esc(r.skipped_count)+' 项未加入</span><button id="packageManifest" class="view-btn">查看清单</button><button id="packageDownload" class="view-btn primary">↓ 下载 ZIP</button></div><div id="packageManifestBox"></div>';
72
+ $("packageManifest").onclick=()=>{
73
+ const box=$("packageManifestBox"),files=r.included_files||[],skipped=r.skipped||[];
74
+ if(box.innerHTML){box.innerHTML="";return}
75
+ const rows=files.map(x=>'<div class="package-manifest-row"><b>'+esc(x.database||'')+'</b><span title="'+esc(x.path||'')+'">'+esc(x.source||'')+' · '+esc(x.path||'')+'</span><span>'+esc(formatBytes(x.size_bytes||0))+'</span></div>').join('');
76
+ const skip=skipped.length?'<div class="detail-note" style="margin-top:8px">另有 '+esc(skipped.length)+' 项未加入,可在 ZIP 的 manifest.json 查看原因。</div>':'';
77
+ box.innerHTML='<div class="package-manifest">'+(rows||'<div class="detail-note">没有已加入文件。</div>')+'</div>'+skip;
78
+ };
79
+ $("packageDownload").onclick=async()=>{const btn=$("packageDownload");btn.disabled=true;btn.textContent="下载中…";try{const resp=await fetch(r.download_url,{headers:authHeaders()});if(!resp.ok)throw Error(await resp.text());const blob=await resp.blob(),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="project_data_package.zip";a.click();setTimeout(()=>URL.revokeObjectURL(url),2000);toast("项目数据包已下载")}catch(err){toast("下载失败:"+(err.message||String(err)))}finally{btn.disabled=false;btn.textContent="↓ 下载 ZIP"}};
80
+ }catch(err){const status=$("packageBuildStatus");if(status)status.outerHTML='<div class="empty-view"><b>打包失败</b>'+esc(err.message||String(err))+'</div>'}
81
+ finally{b.disabled=false;b.textContent="生成所选 ZIP"}
82
+ };
83
+ $("pkgAnalyze").onclick=async()=>{
84
+ const b=$("pkgAnalyze"),out=$("packageResult"),body=payload();
85
+ body.selected_ocean_keys=null;body.selected_fisheries_names=null;
86
+ if(!body.project){toast("请先描述项目");return}
87
+ b.disabled=true;b.textContent="分析中…";out.innerHTML='<div class="loading">正在理解项目目标并规划必需 / 推荐 / 可选数据…</div>';
88
+ try{
89
+ lastPlan=await j("/api/sidebar/project-package/analyze",{method:"POST",body:JSON.stringify(body)});
90
+ out.innerHTML='<div class="package-result"><div class="view-head"><div><h2>Agent 数据方案</h2><p>先理解研究任务,再按“必需 / 推荐 / 可选”映射到真实数据源;你可以在打包前调整。</p></div></div>'+projectPlanHtml(lastPlan)+'<div class="package-actions"><button id="pkgEstimate" class="view-btn">预估数据包</button><button id="pkgBuild" class="view-btn primary">生成所选 ZIP</button></div><div id="packageEstimateResult"></div></div>';
91
+ bindPlanControls();$("pkgEstimate").onclick=estimate;$("pkgBuild").onclick=build;
92
+ }catch(err){out.innerHTML='<div class="empty-view"><b>分析失败</b>'+esc(err.message||String(err))+'</div>'}
93
+ finally{b.disabled=false;b.textContent="分析数据 →"}
94
+ };
95
+ $("projectPackageText").addEventListener("keydown",e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey)){e.preventDefault();$("pkgAnalyze").click()}});
96
+ }
tests/test_p0_phase2_v371.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ 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()=="3.7.1"
7
+
8
+ def test_frontend_feature_modules_exist():
9
+ html=(ROOT/"app.html").read_text(encoding="utf-8")
10
+ assert "/static/js/data-health.js" in html
11
+ assert "/static/js/project-package.js" in html
12
+ assert (ROOT/"static/js/data-health.js").exists()
13
+ assert (ROOT/"static/js/project-package.js").exists()
14
+
15
+ def test_core_js_no_longer_owns_extracted_views():
16
+ core=(ROOT/"static/js/app.js").read_text(encoding="utf-8")
17
+ assert "async function renderDataHealth" not in core
18
+ assert "async function renderProjectPackage" not in core
19
+ assert "function projectPlanHtml" not in core
20
+
21
+ def test_user_state_service_extracted():
22
+ ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
23
+ svc=(ROOT/"services/user_state.py").read_text(encoding="utf-8")
24
+ assert "from services.user_state import UserStateStore" in ui
25
+ assert "class UserStateStore" in svc
26
+ assert "def read_workspace" in svc
27
+ assert "def write_favorites" in svc
tests/test_quality_center_v362.py CHANGED
@@ -2,8 +2,8 @@
2
  import pathlib
3
  ROOT = pathlib.Path(__file__).resolve().parents[1]
4
 
5
- def test_version_370():
6
- assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "3.7.0"
7
 
8
  def test_native_health_backend():
9
  text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
@@ -12,7 +12,7 @@ def test_native_health_backend():
12
  assert "40% metadata completeness + 35% evidence confidence + 25% repository hygiene" in text
13
 
14
  def test_native_health_frontend():
15
- text = (ROOT / "static" / "js" / "app.js").read_text(encoding="utf-8")
16
  assert "function openQualityDrilldown" in text
17
  assert "数据可信度" in text
18
  assert "health-detail-btn" in text
 
2
  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() == "3.7.1"
7
 
8
  def test_native_health_backend():
9
  text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
 
12
  assert "40% metadata completeness + 35% evidence confidence + 25% repository hygiene" in text
13
 
14
  def test_native_health_frontend():
15
+ text = "\n".join((ROOT / "static" / "js" / name).read_text(encoding="utf-8") for name in ("app.js","data-health.js","project-package.js"))
16
  assert "function openQualityDrilldown" in text
17
  assert "数据可信度" in text
18
  assert "health-detail-btn" in text
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 == "3.7.0"
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 == "3.7.1"
11
  assert 'with_name("VERSION")' in ui
12
  assert "__APP_VERSION__" in html
13
 
ui_server.py CHANGED
@@ -376,122 +376,39 @@ class WorkspaceSyncRequest(BaseModel):
376
  sessions: list[dict[str, Any]] = Field(default_factory=list)
377
  settings: dict[str, Any] = Field(default_factory=dict)
378
 
379
- def _workspace_state_file(user_id: str) -> Path:
380
- if not valid_user_id(user_id):
381
- raise HTTPException(400, "invalid user_id")
382
- return USER_STATE_ROOT / f"{user_id}.workspace.json"
383
-
384
- def _sanitize_workspace_sessions(items: Any) -> list[dict[str, Any]]:
385
- out=[]
386
- for x in items if isinstance(items,list) else []:
387
- if not isinstance(x,dict): continue
388
- thread_id=str(x.get("thread_id") or "")[:200]
389
- if not thread_id: continue
390
- messages=[]
391
- for m in (x.get("messages") if isinstance(x.get("messages"),list) else [])[-80:]:
392
- if not isinstance(m,dict): continue
393
- messages.append({
394
- "role":"assistant" if m.get("role")=="assistant" else "user",
395
- "text":str(m.get("text") or "")[:50000],
396
- "meta":str(m.get("meta") or "")[:1000],
397
- "time":int(m.get("time") or int(time.time()*1000)),
398
- })
399
- out.append({"thread_id":thread_id,"title":str(x.get("title") or "新对话")[:200],"updated":int(x.get("updated") or int(time.time()*1000)),"messages":messages})
400
- if len(out)>=50: break
401
- return out
402
-
403
- def _sanitize_workspace_settings(value: Any) -> dict[str, Any]:
404
- if not isinstance(value,dict): return {}
405
- allowed={"fontSize","enterToSend","refreshSeconds","accent","defaultView","compact","reduceMotion","longitudeRange","timeDisplay","exportFormat","answerDetail","historyLimit","confirmDownload","messageActionTrigger"}
406
- return {str(k):v for k,v in value.items() if k in allowed}
407
-
408
- def _read_server_workspace(user_id: str) -> dict[str, Any]:
409
- path=_workspace_state_file(user_id)
410
- if not path.exists(): return {"sessions":[],"settings":{},"updated_at":""}
411
- try: raw=json.loads(path.read_text(encoding="utf-8"))
412
- except Exception: return {"sessions":[],"settings":{},"updated_at":""}
413
- return {"sessions":_sanitize_workspace_sessions(raw.get("sessions",[])),"settings":_sanitize_workspace_settings(raw.get("settings",{})),"updated_at":str(raw.get("updated_at") or "")}
414
-
415
- def _write_server_workspace(user_id: str, sessions: Any, settings: Any) -> dict[str, Any]:
416
- clean_sessions=_sanitize_workspace_sessions(sessions)
417
- clean_settings=_sanitize_workspace_settings(settings)
418
- payload={"schema":"squid-server-workspace","schema_version":1,"updated_at":datetime.now().astimezone().isoformat(timespec="seconds"),"sessions":clean_sessions,"settings":clean_settings}
419
- path=_workspace_state_file(user_id); tmp=path.with_suffix(path.suffix+".tmp")
420
- tmp.write_text(json.dumps(payload,ensure_ascii=False,indent=2),encoding="utf-8"); tmp.replace(path)
421
- return payload
422
-
423
- def _favorite_state_file(user_id: str) -> Path:
424
- if not valid_user_id(user_id):
425
- raise HTTPException(400, "invalid user_id")
426
- return USER_STATE_ROOT / f"{user_id}.favorites.json"
427
 
428
- def _sanitize_favorite(item: Any) -> dict[str, Any] | None:
429
- if not isinstance(item, dict):
430
- return None
431
- kind = str(item.get("kind") or "answer")
432
- if kind not in {"dataset", "file", "answer"}:
433
- kind = "answer"
434
- def cut(key: str, n: int) -> str:
435
- return str(item.get(key) or "")[:n]
436
- return {
437
- "kind": kind,
438
- "id": cut("id", 240),
439
- "key": cut("key", 800),
440
- "title": cut("title", 400),
441
- "content": cut("content", 60000),
442
- "prompt": cut("prompt", 10000),
443
- "repository": cut("repository", 500),
444
- "path": cut("path", 2000),
445
- "created_at": int(item.get("created_at") or int(time.time() * 1000)),
446
- }
447
 
448
- def _read_server_favorites(user_id: str) -> list[dict[str, Any]]:
449
- path = _favorite_state_file(user_id)
450
- if not path.exists():
451
- return []
452
- try:
453
- raw = json.loads(path.read_text(encoding="utf-8"))
454
- except Exception:
455
- return []
456
- items = raw.get("favorites", []) if isinstance(raw, dict) else raw
457
- out = []
458
- for item in items if isinstance(items, list) else []:
459
- clean = _sanitize_favorite(item)
460
- if clean:
461
- out.append(clean)
462
- return out[:USER_STATE_MAX_FAVORITES]
463
-
464
- def _write_server_favorites(user_id: str, favorites: list[dict[str, Any]]) -> list[dict[str, Any]]:
465
- cleaned = []
466
- seen = set()
467
- for item in favorites:
468
- clean = _sanitize_favorite(item)
469
- if not clean:
470
- continue
471
- ident = clean.get("id") or f"{clean.get('kind')}:{clean.get('key')}:{clean.get('title')}"
472
- if ident in seen:
473
- continue
474
- seen.add(ident)
475
- cleaned.append(clean)
476
- if len(cleaned) >= USER_STATE_MAX_FAVORITES:
477
- break
478
- path = _favorite_state_file(user_id)
479
- tmp = path.with_suffix(path.suffix + ".tmp")
480
- payload = {
481
- "schema": "squid-server-favorites",
482
- "schema_version": 1,
483
- "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
484
- "favorites": cleaned,
485
- }
486
- tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
487
- tmp.replace(path)
488
- return cleaned
489
 
490
- def _favorites_storage_mode() -> str:
491
- try:
492
- return "persistent" if str(USER_STATE_ROOT.resolve()).startswith("/data/") else "server_session"
493
- except Exception:
494
- return "server_session"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
 
496
 
497
  def _request_bearer_token(request:Request):
 
376
  sessions: list[dict[str, Any]] = Field(default_factory=list)
377
  settings: dict[str, Any] = Field(default_factory=dict)
378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
+
383
+
384
+
385
+
386
+
387
+
388
+
389
+
390
+
391
+
392
+
393
+
394
+
395
+
396
+
397
+
398
+
399
+ from services.user_state import UserStateStore
400
+
401
+ _USER_STATE = UserStateStore(USER_STATE_ROOT, valid_user_id, USER_STATE_MAX_FAVORITES)
402
+ _workspace_state_file = _USER_STATE.workspace_state_file
403
+ _sanitize_workspace_sessions = _USER_STATE.sanitize_workspace_sessions
404
+ _sanitize_workspace_settings = _USER_STATE.sanitize_workspace_settings
405
+ _read_server_workspace = _USER_STATE.read_workspace
406
+ _write_server_workspace = _USER_STATE.write_workspace
407
+ _favorite_state_file = _USER_STATE.favorite_state_file
408
+ _sanitize_favorite = _USER_STATE.sanitize_favorite
409
+ _read_server_favorites = _USER_STATE.read_favorites
410
+ _write_server_favorites = _USER_STATE.write_favorites
411
+ _favorites_storage_mode = _USER_STATE.storage_mode
412
 
413
 
414
  def _request_bearer_token(request:Request):