diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 39c85ad..66cfc41 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,27 +1,27 @@ # LLM Intelligence Hub - 部署指南 -> 版本: v1.0 -> 日期: 2026-05-10 -> 适用版本: Phase 1 +> 版本: v1.1 +> 日期: 2026-05-21 +> 适用版本: Phase 1 / Phase 2 基础部署 --- ## 环境要求 ### 硬件 -- CPU: 1核+ -- 内存: 512MB+ -- 磁盘: 5GB+ +- CPU: 1 核+ +- 内存: 512 MB+ +- 磁盘: 5 GB+ ### 软件 - Go 1.22+ - Node.js 20+ - PostgreSQL 16+ -- Docker 或 Podman (可选) +- Docker / Docker Compose --- -## 快速开始 +## 本地开发启动 ### 1. 克隆仓库 ```bash @@ -29,13 +29,14 @@ git clone llm-intelligence cd llm-intelligence ``` -### 2. 配置数据库 +### 2. 初始化数据库 ```bash -# 创建数据库 createdb llm_intelligence - -# 运行迁移 psql llm_intelligence < db/migrations/001_phase1_core_tables.sql +psql llm_intelligence < db/migrations/002_sprint1_complete_schema.sql +psql llm_intelligence < db/migrations/003_phase2_region_pricing_metadata.sql +psql llm_intelligence < db/migrations/004_backfill_models_batch_id.sql +psql llm_intelligence < db/migrations/005_subscription_plan.sql ``` ### 3. 配置环境变量 @@ -50,29 +51,40 @@ export FEISHU_WEBHOOK="your-webhook-url" # 可选 go run cmd/server/main.go ``` -### 5. 启动前端 (开发) +### 5. 启动前端开发服务 ```bash cd frontend npm install npm run dev ``` -### 6. 配置定时任务 -```bash -crontab -e -# 添加: 0 8 * * * cd /path/to/llm-intelligence && bash scripts/run_daily.sh -``` - --- ## Docker 部署 -```bash -# 构建 -docker build -t llm-hub . +当前容器镜像已经内置前端静态资源,`app` 服务会同时提供页面和 API。 -# 或 docker-compose -docker-compose up -d +### 使用 compose 启动完整环境 +```bash +docker-compose up -d --build +``` + +启动后访问: +- Web UI: `http://localhost:8080/` +- Health: `http://localhost:8080/health` +- API: `http://localhost:8080/api/v1/models` + +### 只构建镜像 +```bash +docker build -t llm-hub . +``` + +运行示例: +```bash +docker run --rm -p 8080:8080 \ + -e DATABASE_URL="postgres://llm_hub:changeme@host.docker.internal:5432/llm_intelligence?sslmode=disable" \ + -e OPENROUTER_API_KEY="your-api-key" \ + llm-hub ``` --- @@ -81,42 +93,63 @@ docker-compose up -d | 变量 | 必填 | 说明 | |------|------|------| -| DATABASE_URL | ✅ | PostgreSQL 连接串 | -| OPENROUTER_API_KEY | ✅ | OpenRouter API Key | -| FEISHU_WEBHOOK | ❌ | 飞书告警 Webhook | -| API_PORT | ❌ | 默认 8080 | +| `DATABASE_URL` | 是 | PostgreSQL 连接串 | +| `OPENROUTER_API_KEY` | 是 | OpenRouter API Key | +| `FEISHU_WEBHOOK` | 否 | 飞书告警 Webhook | +| `PORT` | 否 | 服务端监听端口,默认 `8080` | +| `FRONTEND_DIST_DIR` | 否 | 自定义静态资源目录,默认自动查找 `frontend/dist` | --- ## 验证安装 ```bash -# 数据库连接 curl http://localhost:8080/health +curl http://localhost:8080/api/v1/models +``` -# 采集器测试 -go run scripts/fetch_openrouter.go +前端构建校验: +```bash +cd frontend +npm run build +``` -# 日报生成 -go run scripts/generate_daily_report.go +Go 测试校验: +```bash +go test ./... ``` --- ## 常见问题 -### Q: 数据库迁移失败? -确保 PostgreSQL 已启动,且用户有创建表的权限。 +### Q: 前端构建失败? +确认: +- Node.js >= 20 +- `frontend/package-lock.json` 与 `npm ci` 一致 +- 本地没有依赖已删除的 `frontend/src/data/latest_models.json` -### Q: 前端构建失败? -检查 Node.js 版本 >= 20,npm 版本 >= 10。 +### Q: `docker-compose up -d` 后页面空白? +先执行: +```bash +docker-compose up -d --build +``` -### Q: 采集器返回模拟数据? -未提供 OPENROUTER_API_KEY 时使用模拟数据,提供 Key 后获取真实数据。 +然后检查: +```bash +docker-compose logs -f app +curl http://localhost:8080/ +``` + +### Q: API 返回 `database not configured`? +说明 `DATABASE_URL` 未注入或格式不正确,先执行: +```bash +echo "$DATABASE_URL" +``` --- ## 升级路径 - Phase 2: 告警订阅 / 用户系统 / 付费分析 -- Phase 3: 多数据源 / 自动发现 / ELO评分 +- Phase 3: 多数据源 / 自动发现 / ELO 评分 diff --git a/RUNBOOK.md b/RUNBOOK.md index b37ec16..2a59a27 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -1,8 +1,7 @@ # LLM Intelligence Hub - 运维手册 -> 版本: v1.0 -> 日期: 2026-05-10 -> 适用版本: Phase 1 +> 版本: v1.1 +> 日期: 2026-05-21 --- @@ -10,7 +9,7 @@ ### 启动全部服务 ```bash -docker-compose up -d +docker-compose up -d --build ``` ### 停止服务 @@ -28,6 +27,12 @@ docker-compose logs -f db ## 日常巡检 +### 应用健康 +```bash +curl http://localhost:8080/health +curl http://localhost:8080/api/v1/models +``` + ### 数据库健康 ```bash psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM models WHERE deleted_at IS NULL" @@ -61,13 +66,19 @@ df -h /tmp ### 日报未生成 1. 检查 cron: `crontab -l | grep llm-intelligence` -2. 手动运行: `bash scripts/run_daily.sh` -3. 检查降级报告: `ls reports/daily/*.md | tail -1` +2. 手动执行: `bash scripts/run_daily.sh` +3. 检查最近日报: `ls reports/daily/*.md | tail -1` ### 前端无法访问 -1. 检查 Nginx: `docker-compose ps nginx` -2. 检查 dist: `ls frontend/dist/` -3. 检查端口: `netstat -tlnp | grep 80` +1. 检查应用容器: `docker-compose ps app` +2. 检查首页响应: `curl -I http://localhost:8080/` +3. 检查 API 响应: `curl http://localhost:8080/api/v1/models` +4. 查看应用日志: `docker-compose logs -f app` + +### 静态资源 404 +1. 重新构建镜像: `docker-compose up -d --build` +2. 本地校验前端构建: `cd frontend && npm run build` +3. 确认容器内含有前端产物: `docker-compose exec app ls /app/frontend/dist` --- @@ -83,7 +94,7 @@ bash scripts/backup.sh gunzip < backup_file.sql.gz | psql "$DATABASE_URL" ``` -### 定时备份 (cron) +### 定时备份 ```bash 0 2 * * * cd /path/to/llm-intelligence && bash scripts/backup.sh >> /tmp/backup.log 2>&1 ``` @@ -94,24 +105,14 @@ gunzip < backup_file.sql.gz | psql "$DATABASE_URL" | 指标 | 告警阈值 | 检查命令 | |------|----------|----------| -| 模型数 | < 300 | `SELECT COUNT(*) FROM models` | -| 采集成功率 | < 95% | `SELECT success_rate FROM collector_stats` | +| 模型数 | `< 300` | `SELECT COUNT(*) FROM models` | +| 采集成功率 | `< 95%` | `SELECT success_rate FROM collector_stats` | | 数据库连接 | 失败 | `pg_isready` | -| 磁盘空间 | > 80% | `df -h` | - ---- - -## 扩容指南 - -### 垂直扩容 -增加 PostgreSQL 内存和 CPU。 - -### 水平扩容 -使用读写分离或分片(Phase 2+)。 +| 磁盘空间 | `> 80%` | `df -h` | --- ## 联系信息 -- 维护者: 宰相 -- 项目路径: /home/long/project/llm-intelligence +- 维护者: 宅相 +- 项目路径: `D:\project\llm-intelligence` diff --git a/cmd/server/main.go b/cmd/server/main.go index 298b546..746a776 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,9 @@ import ( "log" "net/http" "os" + "path" + "path/filepath" + "strings" "time" _ "github.com/lib/pq" @@ -75,7 +78,7 @@ func main() { } } - mux := newMux(db, fetchModels, fetchSubscriptionPlans) + mux := newMux(db, fetchModels, fetchSubscriptionPlans, resolveFrontendDistDir()) log.Printf("server listening on :%s", addr) if err := http.ListenAndServe(":"+addr, mux); err != nil { @@ -83,7 +86,7 @@ func main() { } } -func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher) *http.ServeMux { +func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher, frontendDistDir string) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if db == nil { @@ -122,9 +125,65 @@ func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPla } writeJSON(w, http.StatusOK, apiEnvelope{Data: plans}) }) + if frontendDistDir != "" { + mux.Handle("/", frontendHandler(frontendDistDir)) + } return mux } +func resolveFrontendDistDir() string { + candidates := []string{} + if custom := os.Getenv("FRONTEND_DIST_DIR"); custom != "" { + candidates = append(candidates, custom) + } + + candidates = append(candidates, + filepath.Join("frontend", "dist"), + filepath.Join(filepath.Dir(os.Args[0]), "frontend", "dist"), + ) + + for _, candidate := range candidates { + indexPath := filepath.Join(candidate, "index.html") + info, err := os.Stat(indexPath) + if err == nil && !info.IsDir() { + return candidate + } + } + + return "" +} + +func frontendHandler(frontendDistDir string) http.Handler { + indexPath := filepath.Join(frontendDistDir, "index.html") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + + cleanPath := path.Clean("/" + r.URL.Path) + if cleanPath == "/" { + http.ServeFile(w, r, indexPath) + return + } + + relativePath := strings.TrimPrefix(cleanPath, "/") + assetPath := filepath.Join(frontendDistDir, filepath.FromSlash(relativePath)) + if info, err := os.Stat(assetPath); err == nil && !info.IsDir() { + http.ServeFile(w, r, assetPath) + return + } + + if filepath.Ext(relativePath) != "" { + http.NotFound(w, r) + return + } + + http.ServeFile(w, r, indexPath) + }) +} + func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { rows, err := db.QueryContext(ctx, ` WITH latest_prices AS ( diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index dba91bb..5c495c9 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -6,6 +6,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) @@ -20,7 +23,7 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { { PlanFamily: "token_plan", PlanCode: "token-plan-lite", - PlanName: "通用 Token Plan Lite", + PlanName: "General Token Plan Lite", Tier: "Lite", Provider: "Tencent", ProviderCN: "腾讯", @@ -38,6 +41,7 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { }, }, nil }, + "", ) req := httptest.NewRequest(http.MethodGet, "/api/v1/subscription-plans", nil) @@ -76,3 +80,89 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { t.Fatalf("unexpected model scope length: %d", len(got.ModelScope)) } } + +func TestFrontendHandlerServesIndexAssetsAndSpaFallback(t *testing.T) { + distDir := t.TempDir() + writeTestFile(t, filepath.Join(distDir, "index.html"), "dashboard") + writeTestFile(t, filepath.Join(distDir, "assets", "app.js"), "console.log('ok');") + + mux := newMux(&sql.DB{}, noOpModelsFetcher, noOpPlansFetcher, distDir) + + t.Run("root serves index", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "dashboard") { + t.Fatalf("expected index response, got %q", rec.Body.String()) + } + }) + + t.Run("asset serves file", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "console.log") { + t.Fatalf("expected asset response, got %q", rec.Body.String()) + } + }) + + t.Run("spa route falls back to index", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/explorer/detail", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "dashboard") { + t.Fatalf("expected SPA fallback, got %q", rec.Body.String()) + } + }) + + t.Run("missing asset returns not found", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + }) + + t.Run("api routes keep precedence", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/models", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + }) +} + +func noOpModelsFetcher(context.Context, *sql.DB) ([]modelResponse, error) { + return []modelResponse{}, nil +} + +func noOpPlansFetcher(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) { + return []subscriptionPlanResponse{}, nil +} + +func writeTestFile(t *testing.T, path string, contents string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 7d2b35e..407200b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,15 +27,5 @@ services: ports: - "8080:8080" - nginx: - image: nginx:alpine - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - - ./frontend/dist:/usr/share/nginx/html:ro - ports: - - "80:80" - depends_on: - - app - volumes: postgres_data: diff --git a/frontend/src/lib/models.ts b/frontend/src/lib/models.ts index 455d948..c9ac1cf 100644 --- a/frontend/src/lib/models.ts +++ b/frontend/src/lib/models.ts @@ -110,31 +110,35 @@ export function normalizeModel(raw: any): Model | null { } } -export async function loadFallbackModels() { - // latest_models.json is a local runtime snapshot when present. - // models.json is the committed fixture fallback kept in the repo. - const sources = [ - () => import('../data/latest_models.json'), - () => import('../data/models.json'), - ] +function normalizeModelList(raw: any) { + const arr: any[] = Array.isArray(raw) ? raw : (raw?.models || []) + return arr + .map(normalizeModel) + .filter((model: Model | null): model is Model => model !== null) +} - for (const load of sources) { - try { - const module = await load() - const raw = module.default as any - const arr: any[] = Array.isArray(raw) ? raw : (raw.models || []) - const normalized = arr - .map(normalizeModel) - .filter((model: Model | null): model is Model => model !== null) - if (normalized.length > 0) { - return normalized - } - } catch { - // 继续尝试下一个回退源 +async function loadRuntimeSnapshot() { + try { + const response = await fetch('/latest_models.json', { cache: 'no-store' }) + if (!response.ok) { + return [] } + + const raw = await response.json() + return normalizeModelList(raw) + } catch { + return [] + } +} + +export async function loadFallbackModels() { + const snapshot = await loadRuntimeSnapshot() + if (snapshot.length > 0) { + return snapshot } - return [] + const module = await import('../data/models.json') + return normalizeModelList(module.default) } export function formatPrice(model: Model, kind: 'input' | 'output') { diff --git a/reports/review/2026-05-20-hermes-project-review.md b/reports/review/2026-05-20-hermes-project-review.md new file mode 100644 index 0000000..06ff4a0 --- /dev/null +++ b/reports/review/2026-05-20-hermes-project-review.md @@ -0,0 +1,235 @@ +# LLM Intelligence 项目 Review 报告 + +- 审查时间:2026-05-20 +- 审查人:Hermes Agent +- 审查对象:D:\project\llm-intelligence +- 审查方式:仓库结构盘点 + 关键代码抽样 + 配置/验证链路审查 + counter-evidence/calibration + +## 1. 结论摘要 + +总体判断:这是一个“文档/规划活跃,但工程闭环和验证闭环明显不足”的项目。 + +成熟度判断: +- 当前级别:demo-grade +- 不建议给出 production-candidate 或“可稳定上线”的结论 + +主导问题: +1. 基线不稳定 +2. 运行/验证环境不自洽 +3. 文档声称的完成度高于当前可复现度 +4. 前后端/脚本/部署链路存在多处断裂 + +## 2. 审查范围与限制 + +已检查: +- git 基线状态 +- 顶层文档与 truth-map 候选 +- Go 服务端主入口与主要查询逻辑 +- 前端 Explorer / Dashboard / models 辅助库 +- docker-compose.yml / Dockerfile / nginx.conf / healthcheck.sh +- verify 脚本与 verification_executor.go +- 前端测试执行结果 + +受限项: +- 当前环境中 go 不存在,因此 Go 测试未能实际跑通 +- 数据库验证未完整复现,因为 verify shell 脚本先被行尾格式问题拦住 + +## 3. 基线稳定性 + +git status 显示当前工作区存在大面积修改,覆盖: +- 顶层文档 +- Go 服务端 +- migration +- frontend +- scripts +- tests + +这意味着: +- 任何历史“验证通过”“Phase 1 完成”的说法,都不能直接当作当前真相 +- 当前 review 只能对当前工作区快照负责,不能继承旧报告的高置信结论 + +判定:P1 级问题。 + +## 4. Truth Map / Source of Truth + +仓库顶层没有 README.md。 + +当前 truth candidates 主要包括: +- PRD.md +- TECHNICAL_DESIGN.md +- IMPLEMENTATION_PLAN.md +- IMPLEMENTATION_PLAN_v1.1.md +- RUNBOOK.md +- DEPLOYMENT.md +- TASKS.md +- GOALS.md +- VERIFICATION_REPORT_Sprint1-3.md + +判断: +- PRD.md / TECHNICAL_DESIGN.md:更像 target design + 部分当前叙述混合体 +- RUNBOOK.md / DEPLOYMENT.md:试图充当 current ops truth,但可信度不足 +- VERIFICATION_REPORT_Sprint1-3.md:更像历史验证叙事,不足以代表当前 truth +- 代码与当前可执行环境,优先级高于历史报告 + +问题:source-of-truth fragmented。 + +## 5. 五层成熟度判断 + +### 5.1 文档成熟度 + +优点: +- 文档密度高,主题覆盖广 +- 技术设计、产品需求、部署、运维、验收、验证报告较齐全 + +问题: +- current truth 与 target design 混杂 +- 顶层缺少统一入口文档 +- 文档中仍有明显历史/Linux 路径痕迹,如 /home/long/project/llm-intelligence + +结论: +- 文档本身:中上 +- 文档作为当前真相载体:中下 + +### 5.2 执行成熟度 + +后端锚点:cmd/server/main.go + +优点: +- API 入口清晰:/health、/api/v1/models、/api/v1/subscription-plans +- 查询结构整体直白 + +问题: +- 健康检查把“进程活着”和“数据库可用”混在一起 +- 数据库未配置时整个 API 直接 503 +- 与前端 fallback 的产品语义不统一 +- 服务端缺少更完整的超时与边界处理 + +前端锚点: +- frontend/src/pages/Explorer.tsx +- frontend/src/pages/Dashboard.tsx +- frontend/src/lib/models.ts + +优点: +- Explorer 支持筛选/排序/分页 +- Dashboard 对模型和套餐做了分开展示 +- 有静态 fallback 数据方案 + +问题: +- Explorer 对 fetch 未先检查 response.ok +- modality 筛选口径与设计不一致 +- Dashboard 的“国内厂商”文案与真实统计口径不一致 + +结论:执行成熟度中下。 + +### 5.3 验证成熟度 + +反证非常明显: + +1. Go 测试不可复现 +- 实测:go test ./... +- 结果:go: command not found + +2. 前端测试当前失败 +- 实测:npm test -- --run +- 结果:缺失 @rollup/rollup-linux-x64-gnu + +3. verify shell 脚本当前直接失败 +- 实测:bash scripts/verify_phase1.sh +- 结果:$'\r': command not found、pipefail\r: invalid option name + +结论: +- 验证设计意图:中上 +- 当前可复现性:低 +- 不能给出“验证闭环成熟”的结论 + +### 5.4 运维成熟度 + +检查文件: +- docker-compose.yml +- Dockerfile +- nginx.conf +- healthcheck.sh +- RUNBOOK.md + +问题: +- docker-compose.yml 中 DATABASE_URL 看起来像遮罩占位值,不像真实可运行配置 +- Dockerfile 中前端产物与 compose/nginx 实际消费路径脱节 +- healthcheck.sh 将“日报存在”混入基础健康判定 +- RUNBOOK.md 仍带个人化/历史路径 + +结论:有雏形,但未形成可信部署闭环。 + +### 5.5 生产成熟度 + +综合结论: +- 文档成熟度:中上 +- review/治理成熟度:中 +- 执行成熟度:中下 +- 验证成熟度:低 +- 生产成熟度:低 + +最终成熟度带:demo-grade + +主导 drift 类型: +- validation drift +- execution drift +- source-of-truth drift + +## 6. 最高风险的假成熟信号 + +1. 文档很多、报告很多,但当前环境下基础验证链路并不稳 +2. 前端 fallback 可能掩盖后端/数据库不可用问题 +3. RUNBOOK / DEPLOYMENT / compose / healthcheck 存在,但没有形成可一键复现的统一现实 +4. verification_executor 看起来成熟,但底层 shell 验证资产自身未持续通过 + +## 7. 问题清单 + +### P1 +1. 工作区大面积脏修改,导致历史验证/完成度结论失去当前高置信度 +2. 验证链路不可复现:当前环境无 go,前端测试失败,verify shell 脚本 CRLF 不兼容 +3. docker-compose.yml 中 app 的 DATABASE_URL 形态可疑,像占位值,不像可运行配置 +4. Dockerfile 产物路径与 compose/nginx 消费路径脱节,前端部署闭环不完整 +5. 顶层缺 README,source-of-truth 分散,文档与代码现实存在漂移 +6. 健康检查、前端 fallback、后端 503 策略未形成一致服务语义 + +### P2 +1. Explorer 未显式检查 response.ok +2. modality 筛选与设计模型不一致 +3. Dashboard 文案“国内厂商”与真实统计口径不符 +4. writeJSON 错误处理不干净 +5. 服务端缺少更完整的超时配置 +6. RUNBOOK.md 中路径/环境信息陈旧 + +### P3 +1. 上下文窗口展示粗糙 +2. 部分前端/文案细节仍有占位感 + +## 8. 建议整改顺序 + +第一阶段:先修真相和验证,不要先补新功能 +1. 补顶层 README.md +2. 统一 shell 脚本为 LF,并增加环境 preflight +3. 前端依赖重装并跑通 npm test / npm build +4. 修复 compose 的数据库配置 +5. 打通前端构建/运行链路 + +第二阶段:修服务语义 +6. 拆分 liveness / readiness +7. 统一“API 不可用时前端是否允许 fallback”的产品语义 +8. 明确“无 DB 时系统是否仍算部分可用” + +第三阶段:再继续扩展功能 +9. 修正 modality / 搜索 /指标口径等一致性问题 +10. 再扩展多源采集与更复杂报告能力 + +## 9. 最终 plain-language verdict + +一句话评价: + +这是一个“文档和治理意图明显超前于工程闭环”的项目。 + +更直白地说: +- 它不像一堆随手拼的代码,说明作者有产品化和治理意识; +- 但它还没有进入“可以被高置信度地认定为稳定可运行、稳定可验证、稳定可部署”的阶段。 + +最终评级:demo-grade