Deploy v3.7.1 P0 architecture refactor phase 2
Browse files- README.md +1 -1
- VERSION +1 -1
- app.html +2 -0
- services/user_state.py +143 -0
- static/js/app.js +0 -161
- static/js/data-health.js +64 -0
- static/js/project-package.js +96 -0
- tests/test_p0_phase2_v371.py +27 -0
- tests/test_quality_center_v362.py +3 -3
- tests/test_release_stability.py +1 -1
- ui_server.py +30 -113
README.md
CHANGED
|
@@ -9,7 +9,7 @@ app_port: 7860
|
|
| 9 |
|
| 10 |
# Global Marine Foundation Data Agent
|
| 11 |
|
| 12 |
-
Current UI release: **v3.7.
|
| 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.
|
|
|
|
| 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
|
| 6 |
-
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "3.7.
|
| 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" /
|
| 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.
|
| 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 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|