From 5ed79adecaee6290530576a1d6023386efed88de Mon Sep 17 00:00:00 2001 From: atoncooper Date: Thu, 5 Nov 2026 17:41:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(cloud):=20=E4=BA=91=E7=9B=98=20AI=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E4=B8=8E=20Agent=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=97=AD=E7=8E=AF\n\n-=20=E6=96=87=E6=A1=A3/=E5=9B=BE=E7=89=87?= =?UTF-8?q?/=E6=89=AB=E6=8F=8F=20PDF=20OCR=EF=BC=88Higress=20vision=20+=20?= =?UTF-8?q?pdfcpu=EF=BC=89=EF=BC=8C=E6=91=98=E8=A6=81=E3=80=81=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E6=A3=80=E7=B4=A2=E3=80=81=E7=9B=B8=E4=BC=BC=E6=96=87?= =?UTF-8?q?=E4=BB=B6\n-=20=E4=BA=91=E7=9B=98=20Agent=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=9Acloud=5Fdoc=5Fsummary=20/=20cloud=5Fsemantic=5Fsearch?= =?UTF-8?q?=20/=20cloud=5Fsimilar=5Ffiles\n-=20=E5=89=8D=E7=AB=AF=20AI=20?= =?UTF-8?q?=E6=91=98=E8=A6=81/=E6=A0=87=E7=AD=BE/=E5=BE=85=E5=8A=9E/?= =?UTF-8?q?=E7=9B=B8=E4=BC=BC=E6=96=87=E4=BB=B6=E3=80=81=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E3=80=81=E8=81=8A=E5=A4=A9=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=B7=B1=E9=93=BE=E4=B8=8E=E5=85=A5=E5=BA=93=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8F=90=E7=A4=BA\n-=20=E5=85=A5=E5=BA=93=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E9=98=B2=E9=87=8D=EF=BC=88=E5=89=8D=E7=AB=AF=E9=98=B2=E6=8A=96?= =?UTF-8?q?=20+=20=E5=90=8E=E7=AB=AF=E5=8E=9F=E5=AD=90=E6=8A=A2=E5=8D=A0?= =?UTF-8?q?=20+=20=E9=87=8D=E5=85=A5=E5=BA=93=E5=86=B7=E5=8D=B4=EF=BC=89\n?= =?UTF-8?q?-=20=E5=85=A8=E9=83=A8=20JSON=20=E8=B7=AF=E7=94=B1=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=20DTO=EF=BC=9Bmiddleware=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=8C=85=EF=BC=9Butils=20=E5=85=B1=E4=BA=AB=E5=8C=85=EF=BC=9BG?= =?UTF-8?q?o=20=E6=B5=8B=E8=AF=95=E8=BF=81=E5=85=A5=20tests/=20=E9=BB=91?= =?UTF-8?q?=E7=9B=92=E5=8C=85\n-=20app-ts=20=E5=87=BA=E7=AB=99=20client=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=A7=BB=E5=88=B0=20auth/=EF=BC=8Cservices?= =?UTF-8?q?=20=E5=8F=AA=E4=BF=9D=E7=95=99=E4=B8=9A=E5=8A=A1=E7=BC=96?= =?UTF-8?q?=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 + app-go/app-cloud/README.md | 8 +- app-go/app-cloud/default.yaml | 33 +- app-go/app-cloud/go.mod | 7 + app-go/app-cloud/go.sum | 15 + app-go/app-cloud/internal/config/config.go | 74 +++ .../app-cloud/internal/config/config_test.go | 104 +++- app-go/app-cloud/internal/dto/cloud_ai.go | 54 ++ app-go/app-cloud/internal/dto/common.go | 17 + app-go/app-cloud/internal/dto/file.go | 175 ++++++ app-go/app-cloud/internal/dto/folder.go | 83 +++ app-go/app-cloud/internal/dto/internal.go | 16 + app-go/app-cloud/internal/dto/json.go | 35 ++ app-go/app-cloud/internal/dto/quota.go | 11 + app-go/app-cloud/internal/dto/ref.go | 54 ++ app-go/app-cloud/internal/dto/share.go | 135 ++++ app-go/app-cloud/internal/dto/trash.go | 36 ++ app-go/app-cloud/internal/dto/upload.go | 71 +++ app-go/app-cloud/internal/dto/workspace.go | 67 ++ .../internal/infra/milvusstore/milvus.go | 77 ++- .../internal/infra/mongostore/mongostore.go | 40 ++ app-go/app-cloud/internal/metrics/metrics.go | 5 + .../internal/middleware/capability.go | 20 + .../app-cloud/internal/middleware/context.go | 73 +++ .../app-cloud/internal/middleware/internal.go | 38 ++ .../app-cloud/internal/middleware/metrics.go | 26 + .../app-cloud/internal/middleware/security.go | 45 ++ app-go/app-cloud/internal/model/model.go | 11 + app-go/app-cloud/internal/pipeline/embed.go | 2 +- .../app-cloud/internal/pipeline/embed_test.go | 2 +- app-go/app-cloud/internal/pipeline/engine.go | 145 ++++- app-go/app-cloud/internal/pipeline/ocr.go | 436 +++++++++++++ .../app-cloud/internal/pipeline/pdfimages.go | 82 +++ app-go/app-cloud/internal/repo/file.go | 42 ++ app-go/app-cloud/internal/repo/vector_task.go | 49 +- .../internal/repo/vector_task_test.go | 8 +- app-go/app-cloud/internal/router/cloud.go | 586 +++++++----------- app-go/app-cloud/internal/router/cloud_ai.go | 215 +++++++ app-go/app-cloud/internal/router/ref.go | 72 ++- app-go/app-cloud/internal/router/router.go | 85 +-- app-go/app-cloud/internal/router/share.go | 256 ++++---- app-go/app-cloud/internal/router/workspace.go | 159 ++--- .../internal/service/content_review_test.go | 22 - app-go/app-cloud/internal/service/purge.go | 4 +- app-go/app-cloud/internal/service/ref.go | 95 +-- app-go/app-cloud/internal/service/semantic.go | 370 +++++++++++ app-go/app-cloud/internal/service/share.go | 95 ++- .../app-cloud/internal/service/summarizer.go | 2 + app-go/app-cloud/internal/service/summary.go | 137 ++++ app-go/app-cloud/internal/service/upload.go | 151 +++-- app-go/app-cloud/internal/service/util.go | 91 ++- .../app-cloud/internal/service/util_test.go | 66 -- .../app-cloud/internal/service/workerpool.go | 10 +- app-go/app-cloud/internal/utils/domain.go | 84 +++ app-go/app-cloud/internal/utils/strings.go | 53 ++ app-go/app-cloud/main.go | 29 +- .../service => tests}/attest_test.go | 29 +- app-go/app-cloud/tests/content_review_test.go | 26 + app-go/app-cloud/tests/document_parse_test.go | 176 ++++++ app-go/app-cloud/tests/fixtures_test.go | 217 +++++++ .../service => tests}/kgclient_test.go | 11 +- app-go/app-cloud/tests/middleware_test.go | 81 +++ app-go/app-cloud/tests/ocr_test.go | 251 ++++++++ .../{internal/service => tests}/quota_test.go | 7 +- app-go/app-cloud/tests/router_ai_test.go | 224 +++++++ app-go/app-cloud/tests/semantic_test.go | 243 ++++++++ .../service => tests}/share_folder_test.go | 39 +- app-go/app-cloud/tests/status_test.go | 25 + .../service => tests}/summarizer_test.go | 46 +- app-go/app-cloud/tests/summary_test.go | 183 ++++++ app-go/app-cloud/tests/tier_status_test.go | 31 + .../service => tests}/upload_test.go | 8 +- app-go/app-cloud/tests/utils_test.go | 50 ++ app-go/app-cloud/tests/wire_dto_test.go | 293 +++++++++ .../service => tests}/workerpool_test.go | 22 +- app-ts/config/default.yaml | 7 + app-ts/prompts/agents/main.md | 3 + app-ts/src/agent/main/agent.ts | 11 +- .../src/{services => auth}/app-auth-client.ts | 0 .../src/{services => auth}/app-bili-client.ts | 0 app-ts/src/auth/app-cloud-client.ts | 225 +++++++ .../src/{services => auth}/app-quiz-client.ts | 0 .../src/{services => auth}/app-task-client.ts | 0 .../{services => auth}/literature-client.ts | 0 app-ts/src/bootstrap/container.ts | 35 +- app-ts/src/config/index.ts | 6 + app-ts/src/services/academic.ts | 2 +- app-ts/src/services/blindspot.ts | 4 +- app-ts/src/services/quiz-generation.ts | 2 +- app-ts/src/services/quiz-grading.ts | 4 +- app-ts/src/services/task-quiz-agent.ts | 2 +- app-ts/src/services/task-quiz-generation.ts | 4 +- app-ts/src/tools/academic/literature.ts | 2 +- app-ts/src/tools/cloud/index.ts | 99 +++ app-ts/src/tools/cloud/search.ts | 159 +++++ app-ts/src/transport/http/routes/blindspot.ts | 2 +- app-ts/src/transport/http/routes/knowledge.ts | 2 +- app-ts/src/transport/http/routes/quiz.ts | 2 +- app-ts/src/transport/http/routes/task-quiz.ts | 10 +- app-ts/src/transport/http/server.ts | 6 +- app-ts/test/academic-routes.test.ts | 2 +- app-ts/test/academic-service.test.ts | 2 +- app-ts/test/academic-tools.test.ts | 2 +- app-ts/test/cloud-doc-summary.test.ts | 259 ++++++++ app-ts/test/cloud-semantic-tools.test.ts | 390 ++++++++++++ app-ts/test/literature-client.test.ts | 2 +- app-ts/test/quiz-generation.test.ts | 2 +- app-ts/test/task-quiz.test.ts | 6 +- app-ts/test/video-vectorizer.test.ts | 2 +- deploy/docker/34-app-cloud.yml | 2 + deploy/docker/43-app-ts.yml | 2 + deploy/k8s/30-apps/app-cloud.yaml | 4 + deploy/k8s/30-apps/app-ts.yaml | 2 + deploy/swarm/docker-stack.yml | 2 + docker-compose.client.yml | 2 + docker-compose.yml | 4 + docs/academic-agent.md | 8 +- docs/api.md | 2 +- docs/app-cloud.md | 28 +- docs/features.md | 5 +- frontendv2/app/chat/page.tsx | 20 +- frontendv2/components/chat/chat-message.tsx | 44 +- frontendv2/components/chat/chat-view.tsx | 42 +- .../cloud-drive/cloud-drive-view.tsx | 240 ++++++- .../components/cloud-drive/file-ai-panel.tsx | 379 +++++++++++ .../components/cloud-drive/file-inspector.tsx | 21 +- .../components/cloud-drive/file-toolbar.tsx | 124 +++- .../components/cloud-drive/file-viewer.tsx | 6 + frontendv2/components/cloud-drive/helpers.ts | 7 + .../cloud-drive/semantic-results.tsx | 128 ++++ .../components/cloud-drive/status-badges.tsx | 2 + frontendv2/lib/api/chat.ts | 13 +- frontendv2/lib/api/cloud.ts | 97 +++ frontendv2/lib/chat-stream.ts | 3 + 134 files changed, 7861 insertions(+), 1230 deletions(-) create mode 100644 app-go/app-cloud/internal/dto/cloud_ai.go create mode 100644 app-go/app-cloud/internal/dto/common.go create mode 100644 app-go/app-cloud/internal/dto/file.go create mode 100644 app-go/app-cloud/internal/dto/folder.go create mode 100644 app-go/app-cloud/internal/dto/internal.go create mode 100644 app-go/app-cloud/internal/dto/json.go create mode 100644 app-go/app-cloud/internal/dto/quota.go create mode 100644 app-go/app-cloud/internal/dto/ref.go create mode 100644 app-go/app-cloud/internal/dto/share.go create mode 100644 app-go/app-cloud/internal/dto/trash.go create mode 100644 app-go/app-cloud/internal/dto/upload.go create mode 100644 app-go/app-cloud/internal/dto/workspace.go create mode 100644 app-go/app-cloud/internal/middleware/capability.go create mode 100644 app-go/app-cloud/internal/middleware/context.go create mode 100644 app-go/app-cloud/internal/middleware/internal.go create mode 100644 app-go/app-cloud/internal/middleware/metrics.go create mode 100644 app-go/app-cloud/internal/middleware/security.go create mode 100644 app-go/app-cloud/internal/pipeline/ocr.go create mode 100644 app-go/app-cloud/internal/pipeline/pdfimages.go create mode 100644 app-go/app-cloud/internal/router/cloud_ai.go delete mode 100644 app-go/app-cloud/internal/service/content_review_test.go create mode 100644 app-go/app-cloud/internal/service/semantic.go create mode 100644 app-go/app-cloud/internal/service/summary.go delete mode 100644 app-go/app-cloud/internal/service/util_test.go create mode 100644 app-go/app-cloud/internal/utils/domain.go create mode 100644 app-go/app-cloud/internal/utils/strings.go rename app-go/app-cloud/{internal/service => tests}/attest_test.go (62%) create mode 100644 app-go/app-cloud/tests/content_review_test.go create mode 100644 app-go/app-cloud/tests/document_parse_test.go create mode 100644 app-go/app-cloud/tests/fixtures_test.go rename app-go/app-cloud/{internal/service => tests}/kgclient_test.go (88%) create mode 100644 app-go/app-cloud/tests/middleware_test.go create mode 100644 app-go/app-cloud/tests/ocr_test.go rename app-go/app-cloud/{internal/service => tests}/quota_test.go (95%) create mode 100644 app-go/app-cloud/tests/router_ai_test.go create mode 100644 app-go/app-cloud/tests/semantic_test.go rename app-go/app-cloud/{internal/service => tests}/share_folder_test.go (59%) create mode 100644 app-go/app-cloud/tests/status_test.go rename app-go/app-cloud/{internal/service => tests}/summarizer_test.go (75%) create mode 100644 app-go/app-cloud/tests/summary_test.go create mode 100644 app-go/app-cloud/tests/tier_status_test.go rename app-go/app-cloud/{internal/service => tests}/upload_test.go (89%) create mode 100644 app-go/app-cloud/tests/utils_test.go create mode 100644 app-go/app-cloud/tests/wire_dto_test.go rename app-go/app-cloud/{internal/service => tests}/workerpool_test.go (54%) rename app-ts/src/{services => auth}/app-auth-client.ts (100%) rename app-ts/src/{services => auth}/app-bili-client.ts (100%) create mode 100644 app-ts/src/auth/app-cloud-client.ts rename app-ts/src/{services => auth}/app-quiz-client.ts (100%) rename app-ts/src/{services => auth}/app-task-client.ts (100%) rename app-ts/src/{services => auth}/literature-client.ts (100%) create mode 100644 app-ts/src/tools/cloud/index.ts create mode 100644 app-ts/src/tools/cloud/search.ts create mode 100644 app-ts/test/cloud-doc-summary.test.ts create mode 100644 app-ts/test/cloud-semantic-tools.test.ts create mode 100644 frontendv2/components/cloud-drive/file-ai-panel.tsx create mode 100644 frontendv2/components/cloud-drive/semantic-results.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index dddedfb7..21ff7757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 本文件记录 MindBase 各版本的显著变更。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [Unreleased] — 2026-11-04 + +### Added + +- **云盘智能化 P0**:图片/截图/扫描 PDF 走 vision OCR 入库(pdfcpu 提取页图,`pipeline.ocr.*` 可配);文档 L1 摘要新增可执行待办并通过 `GET /cloud/video/{uuid}/summary` 暴露;新增文件夹范围的 `GET /cloud/search/semantic` 与 `GET /cloud/video/{uuid}/similar`;云盘详情页展示 AI 摘要/标签/关键点/待办/可回答问题与相似文件,支持「问这份文件」(`/chat?upload_uuid=`);聊天来源支持云盘文件深链;app-ts 新增 `cloud_doc_summary` 工具(经 `/internal/cloud/summary`,uid 由会话注入)。摘要模型默认 `z-ai/glm-5.3-flash`,OCR/摘要均复用 Higress embedding 网关连接。 + +### Changed + +- 非音视频云盘文件初始 `asr_status` 改为 `not_supported`(不再永远显示转写中);OCR/摘要/语义检索失败按可重试与永久不支持分流。 + ## [Unreleased] — 2026-11-03 ### Changed diff --git a/app-go/app-cloud/README.md b/app-go/app-cloud/README.md index 487b4bd5..2c9120dd 100644 --- a/app-go/app-cloud/README.md +++ b/app-go/app-cloud/README.md @@ -2,16 +2,18 @@ > 云盘域唯一权威:分块上传(10MB 分片 presigned multipart + 心跳 + 断点续传)、文件夹树、 > 存储配额、百度式分享(提取码/有效期/下载上限)、回收站、搜索、RAG 工作区, -> 以及 Go 原生文档解析向量化管线(PDF/Office/md/html/text → 语义分块 → Higress embedding -> → Milvus cloud_drive + Mongo 全文)。**多实例就绪**(2026-10-15 完成 ①–⑤ 分布式强化)。 +> Go 原生文档解析/OCR 向量化管线(PDF/Office/md/html/text + 图片/截图/扫描 PDF OCR → 语义分块 +> → Higress embedding → Milvus cloud_drive + Mongo 全文),以及 AI 摘要、语义检索、相似文件。 +> **多实例就绪**(2026-10-15 完成 ①–⑤ 分布式强化)。 ## 端口与路由(:8007) | 路由 | 说明 | |------|------| -| `/cloud/*` | 用户面:上传 init/heartbeat/complete/resume、文件夹、文件详情/预览/处理、配额、搜索、回收站、分享 owner 面(nginx /cloud/ 直连,auth_request 注入 X-Uid) | +| `/cloud/*` | 用户面:上传 init/heartbeat/complete/resume、文件夹、文件详情/预览/处理、配额、文件名/语义搜索、AI 摘要、相似文件、回收站、分享 owner 面(nginx /cloud/ 直连,auth_request 注入 X-Uid) | | `/cloud/s/{token}` | 公开分享页数据面(免登录;attest 设备证明 + access 提取码校验) | | `/workspaces/*` | RAG 工作区 CRUD/绑定/文件展开(APISIX 兜底路由) | +| `/internal/cloud/{summary,similar}` | app-ts M2M(`internal.api_key` = APISIX_CONSUMER_KEY;uid 显式传入) | | `/internal/cloud/reconcile?uid=` | 对账(loopback,仅运维) | | `/metrics` | Prometheus 指标(HTTP 黄金信号 / 上传完成结果 / 配额预留结果 / 管线队列 / 租约竞争;集群内可达,nginx 不路由) | | `/health` | 探活 | diff --git a/app-go/app-cloud/default.yaml b/app-go/app-cloud/default.yaml index a20b5207..de1caa78 100644 --- a/app-go/app-cloud/default.yaml +++ b/app-go/app-cloud/default.yaml @@ -87,6 +87,15 @@ admin_review: # env: APPCLOUD__ADMIN_REVIEW__TOKEN token: "" +internal: + # Shared secret for the machine-to-machine document endpoints + # (/internal/cloud/summary, /internal/cloud/similar) consumed by app-ts. + # Requests must carry it in the apikey header (Authorization: Bearer is also + # accepted). Empty = those endpoints answer 503 (fail closed); the legacy + # /internal/cloud/reconcile endpoint is unaffected. + # env: APPCLOUD__INTERNAL__API_KEY (compose passes APISIX_CONSUMER_KEY) + api_key: "" + quota: # tier → storage quota (bytes). Lookup: app-pay /internal/pay/membership/{uid} # via APISIX key-auth; fail-closed → free tier when unreachable. @@ -112,8 +121,8 @@ pipeline: max_workers: 16 # elastic ceiling: workers grow toward this when the queue backs up autoscale: true # grow workers (never shrink) while pending depth is high admission: - # Capacity layer (plan 1.0.15): protect the downstream LLM/embedding - # gateways from unbounded trigger bursts. 0 disables the check. + # Capacity layer: protect the downstream LLM/embedding gateways from + # unbounded trigger bursts. 0 disables the check. max_per_uid: 3 # max in-flight vectorize tasks per user max_queue_depth: 50000 # global pending+processing hard cap (429 beyond) embedding: @@ -129,15 +138,29 @@ pipeline: collection: cloud_drive # existing collection (schema owned by the pipeline, guarded) analyzer: chinese # BM25 analyzer; must match the existing collection summary: - # L1 routing payload (plan 1.0.15): a small-model structured summary + # L1 routing payload: a small-model structured summary # stored with the parsed document (Mongo) for doc-level retrieval. # BEST-EFFORT: failures are logged and skipped — vectorization (L2) never # blocks on it. Credentials/base URL are shared with embedding (same # Higress gateway); the model MUST be routable there. enabled: true - model: "" # empty => stage skipped; env APPCLOUD__PIPELINE__SUMMARY__MODEL + model: "z-ai/glm-5.3-flash" # empty => stage skipped; env APPCLOUD__PIPELINE__SUMMARY__MODEL max_input_chars: 60000 # full text truncated before the summary call - max_output_tokens: 1500 + max_output_tokens: 3000 + ocr: + # Vision OCR for images / screenshots / image-only (scanned) PDFs. OCR MUST + # go through the same OpenAI-compatible Higress gateway as embedding/summary; + # there is deliberately no provider base_url/api_key here (one + # AI_GATEWAY__* pair wires every AI call). Empty model disables the stage + # (images then stay not_supported). + # env: APPCLOUD__PIPELINE__OCR__{ENABLED,MODEL,MAX_IMAGE_BYTES, + # MAX_PDF_PAGES,MAX_IMAGES,TIMEOUT_SECONDS} + enabled: true + model: "z-ai/glm-5.3-flash" + max_image_bytes: 10485760 # per image fed to the vision model (10 MiB) + max_pdf_pages: 20 # scanned-PDF pages inspected for embedded images + max_images: 40 # extracted images OCR'd per scanned PDF + timeout_seconds: 90 # per OCR call kg: # app-kg internal cloud-doc routing endpoints (health-mux JSON; L1 graph diff --git a/app-go/app-cloud/go.mod b/app-go/app-cloud/go.mod index df100b8b..58e52764 100644 --- a/app-go/app-cloud/go.mod +++ b/app-go/app-cloud/go.mod @@ -8,6 +8,7 @@ require ( github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 github.com/milvus-io/milvus/client/v2 v2.5.6 github.com/minio/minio-go/v7 v7.0.87 + github.com/pdfcpu/pdfcpu v0.11.0 github.com/prometheus/client_golang v1.14.0 github.com/redis/go-redis/v9 v9.14.0 go.mongodb.org/mongo-driver/v2 v2.5.0 @@ -62,6 +63,9 @@ require ( github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.0 // indirect + github.com/hhrutter/tiff v1.0.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect @@ -73,6 +77,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/milvus-io/milvus-proto/go-api/v2 v2.5.16 // indirect github.com/milvus-io/milvus/pkg/v2 v2.5.7 // indirect @@ -91,6 +96,7 @@ require ( github.com/prometheus/procfs v0.9.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/samber/lo v1.27.0 // indirect @@ -140,6 +146,7 @@ require ( golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/image v0.27.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/app-go/app-cloud/go.sum b/app-go/app-cloud/go.sum index 91e95c67..85b78398 100644 --- a/app-go/app-cloud/go.sum +++ b/app-go/app-cloud/go.sum @@ -284,6 +284,12 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= +github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= +github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= @@ -364,6 +370,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= @@ -417,6 +425,8 @@ github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFSt github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM= +github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= @@ -469,6 +479,9 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -681,6 +694,8 @@ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/app-go/app-cloud/internal/config/config.go b/app-go/app-cloud/internal/config/config.go index 34655700..89cff65d 100644 --- a/app-go/app-cloud/internal/config/config.go +++ b/app-go/app-cloud/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { Quota QuotaConfig `yaml:"quota"` Trash TrashConfig `yaml:"trash"` Pipeline PipelineConfig `yaml:"pipeline"` + Internal InternalConfig `yaml:"internal"` Kg KgConfig `yaml:"kg"` WSBridge WSBridgeConfig `yaml:"ws_bridge"` CORS CORSConfig `yaml:"cors"` @@ -180,6 +181,7 @@ type PipelineConfig struct { Embedding EmbeddingConfig `yaml:"embedding"` Milvus MilvusConfig `yaml:"milvus"` Summary SummaryConfig `yaml:"summary"` + OCR OCRConfig `yaml:"ocr"` } // AdmissionConfig — queue admission control (capacity layer): without it a @@ -203,6 +205,38 @@ type SummaryConfig struct { MaxOutputTok int `yaml:"max_output_tokens"` } +// OCRConfig — vision-model OCR for images and image-only PDFs. OCR MUST go +// through the same Higress gateway as embedding/summary: this struct deliberately +// has no provider base_url/api_key, so one AI_GATEWAY__* pair wires every AI call +// and there is no code path that could point OCR at a vendor API directly. +// Limits bound one file's OCR work (bytes fed to the model, pages inspected, +// images extracted) so a huge scan cannot monopolise a pipeline worker. +type OCRConfig struct { + Enabled bool `yaml:"enabled"` + Model string `yaml:"model"` + MaxImageBytes int64 `yaml:"max_image_bytes"` + MaxPDFPages int `yaml:"max_pdf_pages"` + MaxImages int `yaml:"max_images"` + TimeoutSecs int `yaml:"timeout_seconds"` +} + +// Default OCR limits, applied when a value is unset or non-positive. +const ( + DefaultOCRMaxImageBytes = int64(10 * 1024 * 1024) + DefaultOCRMaxPDFPages = 20 + DefaultOCRMaxImages = 40 + DefaultOCRTimeoutSecs = 90 +) + +// InternalConfig protects the newly added machine-to-machine endpoints only +// (/internal/cloud/summary and /internal/cloud/similar). The legacy +// /internal/cloud/reconcile endpoint keeps its loopback-only deployment +// posture and is deliberately unaffected. Empty key = new endpoints answer +// 503 (fail closed). +type InternalConfig struct { + APIKey string `yaml:"api_key"` +} + type WSBridgeConfig struct { Enabled bool `yaml:"enabled"` Channel string `yaml:"channel"` @@ -242,9 +276,29 @@ func Load(yamlBytes []byte) (*Config, error) { if cfg.Joblock.LeaseTTLSecs <= 0 { cfg.Joblock.LeaseTTLSecs = DefaultJoblockLeaseTTLSecs } + normalizeOCR(&cfg.Pipeline.OCR) return &cfg, nil } +// normalizeOCR fills the documented defaults for unset (zero/negative) OCR +// limits. Explicit env overrides of 0 are treated as "unset" — an OCR call +// with a zero byte cap or zero timeout could never succeed. +func normalizeOCR(cfg *OCRConfig) { + if cfg.MaxImageBytes <= 0 { + cfg.MaxImageBytes = DefaultOCRMaxImageBytes + } + if cfg.MaxPDFPages <= 0 { + cfg.MaxPDFPages = DefaultOCRMaxPDFPages + } + if cfg.MaxImages <= 0 { + cfg.MaxImages = DefaultOCRMaxImages + } + if cfg.TimeoutSecs <= 0 { + cfg.TimeoutSecs = DefaultOCRTimeoutSecs + } + cfg.Model = strings.TrimSpace(cfg.Model) +} + func applyEnvOverrides(cfg *Config) { get := func(key string) string { return strings.TrimSpace(os.Getenv(key)) } getBool := func(key string) (bool, bool) { @@ -343,6 +397,19 @@ func applyEnvOverrides(cfg *Config) { if v := get("APPCLOUD__PIPELINE__SUMMARY__MODEL"); v != "" { cfg.Pipeline.Summary.Model = v } + if b, ok := getBool("APPCLOUD__PIPELINE__OCR__ENABLED"); ok { + cfg.Pipeline.OCR.Enabled = b + } + if v := get("APPCLOUD__PIPELINE__OCR__MODEL"); v != "" { + cfg.Pipeline.OCR.Model = v + } + atol("APPCLOUD__PIPELINE__OCR__MAX_IMAGE_BYTES", &cfg.Pipeline.OCR.MaxImageBytes) + atoi("APPCLOUD__PIPELINE__OCR__MAX_PDF_PAGES", &cfg.Pipeline.OCR.MaxPDFPages) + atoi("APPCLOUD__PIPELINE__OCR__MAX_IMAGES", &cfg.Pipeline.OCR.MaxImages) + atoi("APPCLOUD__PIPELINE__OCR__TIMEOUT_SECONDS", &cfg.Pipeline.OCR.TimeoutSecs) + if v := get("APPCLOUD__INTERNAL__API_KEY"); v != "" { + cfg.Internal.APIKey = v + } if v := get("APPCLOUD__KG__BASE_URL"); v != "" { cfg.Kg.BaseURL = v } @@ -390,5 +457,12 @@ func Validate(cfg *Config) error { if cfg.Pipeline.Enabled && cfg.Pipeline.Embedding.Dimension <= 0 { return fmt.Errorf("pipeline.embedding.dimension must be positive when pipeline enabled") } + if cfg.Pipeline.OCR.Enabled && cfg.Pipeline.OCR.Model != "" { + if cfg.Pipeline.OCR.MaxImageBytes <= 0 || cfg.Pipeline.OCR.MaxPDFPages <= 0 || + cfg.Pipeline.OCR.MaxImages <= 0 || cfg.Pipeline.OCR.TimeoutSecs <= 0 { + return fmt.Errorf("pipeline.ocr limits must be positive when OCR is enabled " + + "(MAX_IMAGE_BYTES / MAX_PDF_PAGES / MAX_IMAGES / TIMEOUT_SECONDS)") + } + } return nil } diff --git a/app-go/app-cloud/internal/config/config_test.go b/app-go/app-cloud/internal/config/config_test.go index 7568e58c..f8bce1b3 100644 --- a/app-go/app-cloud/internal/config/config_test.go +++ b/app-go/app-cloud/internal/config/config_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "os" + "testing" +) // P0-2 support: the attest secret parser must accept the documented formats // and reject everything else at startup (fail loud, never silently degrade). @@ -31,3 +34,102 @@ func TestAttestKeyBytes(t *testing.T) { t.Fatal("non-hex 64 chars are accepted as raw key material (>= 32 bytes rule)") } } + +// clearOCRTestEnv keeps the assertions deterministic regardless of the host or +// project-root .env (godotenv loads it on a best-effort basis). +func clearOCRTestEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "APPCLOUD__PIPELINE__OCR__ENABLED", "APPCLOUD__PIPELINE__OCR__MODEL", + "APPCLOUD__PIPELINE__OCR__MAX_IMAGE_BYTES", "APPCLOUD__PIPELINE__OCR__MAX_PDF_PAGES", + "APPCLOUD__PIPELINE__OCR__MAX_IMAGES", "APPCLOUD__PIPELINE__OCR__TIMEOUT_SECONDS", + "APPCLOUD__PIPELINE__SUMMARY__MODEL", "APPCLOUD__INTERNAL__API_KEY", + "APPCLOUD__PIPELINE__SUMMARY__ENABLED", + } { + t.Setenv(key, "") + } +} + +func TestOCRLimitsDefaultAndEnvOverrides(t *testing.T) { + clearOCRTestEnv(t) + cfg, err := Load([]byte("app:\n name: test\n")) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Pipeline.OCR.MaxImageBytes != DefaultOCRMaxImageBytes || + cfg.Pipeline.OCR.MaxPDFPages != DefaultOCRMaxPDFPages || + cfg.Pipeline.OCR.MaxImages != DefaultOCRMaxImages || + cfg.Pipeline.OCR.TimeoutSecs != DefaultOCRTimeoutSecs { + t.Fatalf("unset OCR limits must normalize to defaults, got %+v", cfg.Pipeline.OCR) + } + + t.Setenv("APPCLOUD__PIPELINE__OCR__ENABLED", "true") + t.Setenv("APPCLOUD__PIPELINE__OCR__MODEL", "z-ai/glm-test") + t.Setenv("APPCLOUD__PIPELINE__OCR__MAX_IMAGE_BYTES", "2048") + t.Setenv("APPCLOUD__PIPELINE__OCR__MAX_PDF_PAGES", "3") + t.Setenv("APPCLOUD__PIPELINE__OCR__MAX_IMAGES", "7") + t.Setenv("APPCLOUD__PIPELINE__OCR__TIMEOUT_SECONDS", "30") + t.Setenv("APPCLOUD__INTERNAL__API_KEY", "internal-secret") + + cfg, err = Load([]byte("app:\n name: test\n")) + if err != nil { + t.Fatalf("load: %v", err) + } + ocr := cfg.Pipeline.OCR + if !ocr.Enabled || ocr.Model != "z-ai/glm-test" || ocr.MaxImageBytes != 2048 || + ocr.MaxPDFPages != 3 || ocr.MaxImages != 7 || ocr.TimeoutSecs != 30 { + t.Fatalf("env overrides not applied: %+v", ocr) + } + if cfg.Internal.APIKey != "internal-secret" { + t.Fatalf("internal api key = %q", cfg.Internal.APIKey) + } +} + +func TestValidateRejectsNonPositiveOCRLimits(t *testing.T) { + cfg := &Config{} + cfg.RDBMS.URL = "dsn" + cfg.Minio.Enabled = true + cfg.Minio.AccessKey = "a" + cfg.Minio.SecretKey = "b" + cfg.Upload.ChunkSize = 1 + cfg.Upload.MaxFileSize = 1 + cfg.Pipeline.OCR = OCRConfig{Enabled: true, Model: "m", MaxImageBytes: 0} + if err := Validate(cfg); err == nil { + t.Fatal("enabled OCR with a zero limit must fail validation") + } + cfg.Pipeline.OCR.MaxImageBytes = 1024 + cfg.Pipeline.OCR.MaxPDFPages = 1 + cfg.Pipeline.OCR.MaxImages = 1 + cfg.Pipeline.OCR.TimeoutSecs = 1 + if err := Validate(cfg); err != nil { + t.Fatalf("valid OCR config rejected: %v", err) + } +} + +// TestShippedDefaultYAMLCarriesContractDefaults pins the values the rest of +// the stack relies on: the summary stage must run with the routed model, and +// OCR must be on with bounded limits. +func TestShippedDefaultYAMLCarriesContractDefaults(t *testing.T) { + clearOCRTestEnv(t) + raw, err := os.ReadFile("../../default.yaml") + if err != nil { + t.Fatalf("read default.yaml: %v", err) + } + cfg, err := Load(raw) + if err != nil { + t.Fatalf("load default.yaml: %v", err) + } + if cfg.Pipeline.Summary.Model != "z-ai/glm-5.3-flash" || !cfg.Pipeline.Summary.Enabled { + t.Fatalf("summary defaults = %+v", cfg.Pipeline.Summary) + } + ocr := cfg.Pipeline.OCR + if !ocr.Enabled || ocr.Model != "z-ai/glm-5.3-flash" { + t.Fatalf("ocr defaults = %+v", ocr) + } + if ocr.MaxImageBytes != 10485760 || ocr.MaxPDFPages != 20 || ocr.MaxImages != 40 || ocr.TimeoutSecs != 90 { + t.Fatalf("ocr default limits = %+v", ocr) + } + if cfg.Internal.APIKey != "" { + t.Fatalf("internal api key must default empty (fail closed), got %q", cfg.Internal.APIKey) + } +} diff --git a/app-go/app-cloud/internal/dto/cloud_ai.go b/app-go/app-cloud/internal/dto/cloud_ai.go new file mode 100644 index 00000000..622c4e94 --- /dev/null +++ b/app-go/app-cloud/internal/dto/cloud_ai.go @@ -0,0 +1,54 @@ +// Package dto holds the wire representations served by app-cloud handlers. +// Domain models (model.*) and service results stay internal: handlers map them +// to these structs so JSON field names, nullability and empty-array behavior +// are defined in one place instead of ad-hoc gin.H maps. +package dto + +// Summary is the persisted AI summary as exposed over the API. List fields are +// always present (empty array, never null) so clients can render them without +// null checks. +type Summary struct { + Overview string `json:"overview"` + Topics []string `json:"topics"` + Entities []string `json:"entities"` + KeyPoints []string `json:"key_points"` + Todos []string `json:"todos"` + AnswersQuestions []string `json:"answers_questions"` + Language string `json:"language"` + SizeChars int `json:"size_chars"` +} + +// SummaryResponse is returned by the owner and M2M summary endpoints. Identity +// fields are present even when no summary exists yet. +type SummaryResponse struct { + UploadUUID string `json:"upload_uuid"` + OriginalName string `json:"original_name"` + Title string `json:"title"` + Available bool `json:"available"` + Stale bool `json:"stale"` + Model string `json:"model"` + Summary *Summary `json:"summary"` +} + +// SemanticHit is one ranked drive file; snippets are distinct matching chunks. +type SemanticHit struct { + UploadUUID string `json:"upload_uuid"` + OriginalName string `json:"original_name"` + Title string `json:"title"` + MimeType string `json:"mime_type"` + FileSize int64 `json:"file_size"` + Score float64 `json:"score"` + Snippets []string `json:"snippets"` +} + +// SemanticSearchResponse is the semantic-search endpoint payload. +type SemanticSearchResponse struct { + Mode string `json:"mode"` + Results []SemanticHit `json:"results"` + Total int `json:"total"` +} + +// SimilarResponse is the similar-files endpoint payload (owner and M2M). +type SimilarResponse struct { + Items []SemanticHit `json:"items"` +} diff --git a/app-go/app-cloud/internal/dto/common.go b/app-go/app-cloud/internal/dto/common.go new file mode 100644 index 00000000..d3278288 --- /dev/null +++ b/app-go/app-cloud/internal/dto/common.go @@ -0,0 +1,17 @@ +// Package dto holds the wire representations served by app-cloud handlers. +// Domain models (model.*) and service results stay internal: handlers map them +// to these structs so JSON field names, nullability and empty-array behavior +// are defined in one place instead of ad-hoc gin.H maps. +package dto + +// ErrorResponse is the uniform error envelope ({"detail": "..."}). Every +// non-2xx JSON response uses it. +type ErrorResponse struct { + Detail string `json:"detail"` +} + +// HealthResponse is the liveness probe payload. +type HealthResponse struct { + Status string `json:"status"` + Service string `json:"service"` +} diff --git a/app-go/app-cloud/internal/dto/file.go b/app-go/app-cloud/internal/dto/file.go new file mode 100644 index 00000000..5ff0b433 --- /dev/null +++ b/app-go/app-cloud/internal/dto/file.go @@ -0,0 +1,175 @@ +package dto + +import ( + "encoding/json" + "time" +) + +// Video/list/search items keep the legacy snake_case keys plus the camelCase +// timestamps the Python-era contract exposed. + +// VideoItem is one row of the file list / name search. +type VideoItem struct { + UploadUUID string `json:"upload_uuid"` + OriginalName string `json:"original_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Duration *int `json:"duration"` + AsrStatus string `json:"asr_status"` + VectorStatus string `json:"vector_status"` + Title *string `json:"title"` + CoverURL *string `json:"cover_url"` + CreatedAt *time.Time `json:"createdAt"` +} + +// VideoListResponse is the GET /cloud/videos payload. +type VideoListResponse struct { + Videos []VideoItem `json:"videos"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + HasMore bool `json:"hasMore"` +} + +// VideoDetailResponse is the file detail payload (GET/PATCH /cloud/video/{uuid}). +type VideoDetailResponse struct { + UploadUUID string `json:"upload_uuid"` + OriginalName string `json:"original_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Duration *int `json:"duration"` + AsrStatus string `json:"asr_status"` + VectorStatus string `json:"vector_status"` + Title *string `json:"title"` + CoverURL *string `json:"cover_url"` + CreatedAt *time.Time `json:"created_at"` + Description *string `json:"description"` + Tags []string `json:"tags"` + FolderID *int64 `json:"folder_id"` + FolderName string `json:"folderName"` + AsrPreview *string `json:"asrPreview"` + VectorChunkCount int `json:"vector_chunk_count"` +} + +// VideoUpdateRequest is the PATCH /cloud/video/{uuid} body. Absent fields keep +// their value; explicit null clears description/title/folder; a present tags +// array replaces the list. Presence and malformed values are tracked so the +// handler keeps the legacy 422 copy. +type VideoUpdateRequest struct { + Title *string + TitleSet bool + Description *string + DescriptionSet bool + Tags []string + TagsSet bool + TagsInvalid bool + FolderID *int64 + FolderSet bool + FolderInvalid bool +} + +// UnmarshalJSON records which fields the caller actually sent. +func (r *VideoUpdateRequest) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if v, ok := raw["title"]; ok { + r.TitleSet = true + if !isJSONNull(v) { + var title string + if err := json.Unmarshal(v, &title); err != nil { + return err + } + r.Title = &title + } + } + if v, ok := raw["description"]; ok { + r.DescriptionSet = true + if !isJSONNull(v) { + var desc string + if err := json.Unmarshal(v, &desc); err != nil { + return err + } + r.Description = &desc + } + } + if v, ok := raw["tags"]; ok && !isJSONNull(v) { + r.TagsSet = true + var tags []string + if err := json.Unmarshal(v, &tags); err != nil { + r.TagsInvalid = true + } else { + r.Tags = tags + } + } + if v, ok := raw["folderId"]; ok { + r.FolderSet = true + if !isJSONNull(v) { + id, ok := parseJSONInt64(v) + if !ok { + r.FolderInvalid = true + } else { + r.FolderID = &id + } + } + } + return nil +} + +// VideoDeleteResponse is the DELETE /cloud/video/{uuid} payload. +type VideoDeleteResponse struct { + Deleted bool `json:"deleted"` + UploadUUID string `json:"uploadUuid"` +} + +// VideoProcessResponse is the POST /cloud/video/{uuid}/process payload. +type VideoProcessResponse struct { + UploadUUID string `json:"uploadUuid"` +} + +// VideoReprocessResponse is the POST /cloud/video/{uuid}/reprocess payload. +type VideoReprocessResponse struct { + UploadUUID string `json:"uploadUuid"` + TaskID string `json:"taskId"` +} + +// VideoStatusResponse is the GET /cloud/video/{uuid}/status payload. +type VideoStatusResponse struct { + AsrStatus string `json:"asr_status"` + AsrProgress int `json:"asrProgress"` + VectorStatus string `json:"vector_status"` + VectorChunkCount int `json:"vectorChunkCount"` +} + +// DocumentPreviewResponse is the GET /cloud/video/{uuid}/preview payload. +type DocumentPreviewResponse struct { + UploadUUID string `json:"upload_uuid"` + FileName string `json:"fileName"` + MimeType string `json:"mime_type"` + Vectorizable bool `json:"vectorizable"` + Preview string `json:"preview"` + DocMeta map[string]any `json:"docMeta"` + Offset int `json:"offset"` + Limit int `json:"limit"` + TotalChars int `json:"totalChars"` + HasMore bool `json:"hasMore"` + NextOffset *int `json:"nextOffset"` +} + +// RawFileResponse is the GET /cloud/video/{uuid}/raw payload (presigned URL, +// optionally with inline text content for text-like files). +type RawFileResponse struct { + URL string `json:"url"` + Content *string `json:"content"` + MimeType string `json:"mimeType"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + ViewMode string `json:"viewMode"` +} + +// SearchFilesResponse is the GET /cloud/search payload (name search). +type SearchFilesResponse struct { + Results []VideoItem `json:"results"` + Total int `json:"total"` +} diff --git a/app-go/app-cloud/internal/dto/folder.go b/app-go/app-cloud/internal/dto/folder.go new file mode 100644 index 00000000..09b7fda9 --- /dev/null +++ b/app-go/app-cloud/internal/dto/folder.go @@ -0,0 +1,83 @@ +package dto + +import "encoding/json" + +// Folder endpoints keep the legacy mixed casing: request bodies and the +// tree/list payloads are snake_case, while create/update responses are the +// legacy snake_case shape clients already tolerate. + +// FolderCreateRequest is the POST /cloud/folders body. +type FolderCreateRequest struct { + ParentID *int64 `json:"parentId"` + Name string `json:"name" binding:"required"` +} + +// FolderUpdateRequest is the PATCH /cloud/folders/{id} body. Presence of +// parentId matters (absent = keep, null = move to root), so it is decoded +// manually instead of through plain pointers. +type FolderUpdateRequest struct { + Name *string + NameSet bool + + ParentID *int64 + ParentSet bool + ParentInvalid bool +} + +// UnmarshalJSON records which fields the caller actually sent. +func (r *FolderUpdateRequest) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if v, ok := raw["name"]; ok { + r.NameSet = true + if !isJSONNull(v) { + var name string + if err := json.Unmarshal(v, &name); err != nil { + return err + } + r.Name = &name + } + } + if v, ok := raw["parentId"]; ok { + r.ParentSet = true + if !isJSONNull(v) { + id, ok := parseJSONInt64(v) + if !ok { + r.ParentInvalid = true + } else { + r.ParentID = &id + } + } + } + return nil +} + +// FolderItem is one node of the folder tree. +type FolderItem struct { + ID int64 `json:"id"` + ParentID *int64 `json:"parent_id"` + Name string `json:"name"` + VideoCount int `json:"video_count"` + Children []FolderItem `json:"children"` +} + +// FolderTreeResponse is the GET /cloud/folders payload. +type FolderTreeResponse struct { + Folders []FolderItem `json:"folders"` +} + +// FolderResponse is one folder (create/update responses). +type FolderResponse struct { + ID int64 `json:"id"` + ParentID *int64 `json:"parent_id"` + Name string `json:"name"` + VideoCount int `json:"video_count"` +} + +// FolderDeleteResponse is the DELETE /cloud/folders/{id} payload. +type FolderDeleteResponse struct { + Deleted bool `json:"deleted"` + AffectedFiles int64 `json:"affectedFiles"` +} diff --git a/app-go/app-cloud/internal/dto/internal.go b/app-go/app-cloud/internal/dto/internal.go new file mode 100644 index 00000000..9564d421 --- /dev/null +++ b/app-go/app-cloud/internal/dto/internal.go @@ -0,0 +1,16 @@ +package dto + +// ReconcileResponse is the internal GET /internal/cloud/reconcile payload +// (per-user ledger vs object-storage drift report). +type ReconcileResponse struct { + UID int64 `json:"uid"` + DBActiveBytes int64 `json:"db_active_bytes"` + DBActiveCount int64 `json:"db_active_count"` + DBTrashBytes int64 `json:"db_trash_bytes"` + MinioBytes int64 `json:"minio_bytes"` + MinioCount int64 `json:"minio_count"` + OrphanObjects int `json:"orphan_objects"` + OrphanSample []string `json:"orphan_sample"` + MissingObjects int `json:"missing_objects"` + DriftBytes int64 `json:"drift_bytes"` +} diff --git a/app-go/app-cloud/internal/dto/json.go b/app-go/app-cloud/internal/dto/json.go new file mode 100644 index 00000000..4ea76379 --- /dev/null +++ b/app-go/app-cloud/internal/dto/json.go @@ -0,0 +1,35 @@ +package dto + +// JSON decoding helpers shared by the presence-tracking request DTOs. + +import ( + "bytes" + "encoding/json" + "strconv" + "strings" +) + +func isJSONNull(v json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(v), []byte("null")) +} + +// parseJSONInt64 accepts the numeric or numeric-string forms clients send for +// ids (parity with the legacy id coercion). +func parseJSONInt64(v json.RawMessage) (int64, bool) { + var anyVal any + if err := json.Unmarshal(v, &anyVal); err != nil { + return 0, false + } + switch n := anyVal.(type) { + case float64: + return int64(n), true + case string: + out, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64) + if err != nil { + return 0, false + } + return out, true + default: + return 0, false + } +} diff --git a/app-go/app-cloud/internal/dto/quota.go b/app-go/app-cloud/internal/dto/quota.go new file mode 100644 index 00000000..f4ed3b8e --- /dev/null +++ b/app-go/app-cloud/internal/dto/quota.go @@ -0,0 +1,11 @@ +package dto + +// QuotaResponse is the GET /cloud/quota payload. Error is only present when +// the usage lookup failed (the numeric fields then carry fail-closed values). +type QuotaResponse struct { + Used int64 `json:"used"` + Quota int64 `json:"quota"` + Tier string `json:"tier"` + Pending int64 `json:"pending"` + Error string `json:"error,omitempty"` +} diff --git a/app-go/app-cloud/internal/dto/ref.go b/app-go/app-cloud/internal/dto/ref.go new file mode 100644 index 00000000..a2166f17 --- /dev/null +++ b/app-go/app-cloud/internal/dto/ref.go @@ -0,0 +1,54 @@ +package dto + +import "time" + +// SaveRefRequest is the POST /cloud/refs body. +type SaveRefRequest struct { + Token string `json:"token" binding:"required"` + Code string `json:"code"` + TargetFolderID *int64 `json:"targetFolderId"` +} + +// SaveRefResponse is the POST /cloud/refs payload. RootFolderID only appears +// for folder saves that mirrored a tree. +type SaveRefResponse struct { + Saved int `json:"saved"` + RootFolderID *int64 `json:"rootFolderId,omitempty"` +} + +// RefItem is one saved reference row. +type RefItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + FolderID *int64 `json:"folderId"` + Status string `json:"status"` + ShareID int64 `json:"shareId"` + SourceUploadUUID string `json:"sourceUploadUuid"` + SourceName string `json:"sourceName"` + CreatedAt *time.Time `json:"createdAt"` + IsRef bool `json:"isRef"` +} + +// RefListResponse is the GET /cloud/refs payload. +type RefListResponse struct { + Refs []RefItem `json:"refs"` + Total int64 `json:"total"` +} + +// RefDeleteResponse is the DELETE /cloud/refs/{id} payload. +type RefDeleteResponse struct { + Deleted bool `json:"deleted"` +} + +// RefRawResponse is the GET /cloud/refs/{id}/raw payload (presigned grant +// through the original object). +type RefRawResponse struct { + URL string `json:"url"` + ViewMode string `json:"viewMode"` + MimeType string `json:"mimeType"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + ExpiresIn int `json:"expiresIn"` +} diff --git a/app-go/app-cloud/internal/dto/share.go b/app-go/app-cloud/internal/dto/share.go new file mode 100644 index 00000000..b58ff140 --- /dev/null +++ b/app-go/app-cloud/internal/dto/share.go @@ -0,0 +1,135 @@ +package dto + +import "time" + +// ── requests ──────────────────────────────────────────────────────── + +// ShareCreateRequest is the body of both owner share-create endpoints +// (file and folder). The two compliance acks are backend-enforced. +type ShareCreateRequest struct { + Code string `json:"code"` + ExpiresInDays int `json:"expiresInDays"` + MaxDownloads *int `json:"maxDownloads"` + LegalAck bool `json:"legalAck"` + ContentReviewAck bool `json:"contentReviewAck"` +} + +// ShareAttestRequest is the public device-attestation body. +type ShareAttestRequest struct { + Challenge string `json:"challenge"` + Fingerprint string `json:"fingerprint"` +} + +// ShareAccessRequest is the public share-access body. FileID selects one +// member for folder shares. +type ShareAccessRequest struct { + Code string `json:"code"` + Download bool `json:"download"` + FileID *int64 `json:"fileId"` +} + +// ShareSetCodeRequest is the owner reset-code body. +type ShareSetCodeRequest struct { + Code string `json:"code" binding:"required"` +} + +// ── owner responses ───────────────────────────────────────────────── + +// ShareView is the owner-facing share shape. Token fields are present for the +// owner list/create responses. +type ShareView struct { + ID int64 `json:"id"` + FileID int64 `json:"file_id"` + HasCode bool `json:"has_code"` + ExtractionCode string `json:"extraction_code"` + ExpiresAt *time.Time `json:"expires_at"` + MaxDownloads *int `json:"max_downloads"` + ViewCount int `json:"view_count"` + DownloadCount int `json:"download_count"` + IsRevoked bool `json:"is_revoked"` + CreatedAt *time.Time `json:"created_at"` + TargetType string `json:"target_type"` + FolderID *int64 `json:"folder_id"` + LegalAck bool `json:"legal_ack"` + ContentReviewAck bool `json:"content_review_ack"` + ContentReviewPolicyVersion *string `json:"content_review_policy_version"` + ContentReviewAckAt *time.Time `json:"content_review_ack_at"` + ModerationStatus string `json:"moderation_status"` + ModerationReason *string `json:"moderation_reason"` + ModerationTime *time.Time `json:"moderation_time"` + ModerationBy *string `json:"moderation_by"` + ShareToken *string `json:"share_token,omitempty"` + ShareURL *string `json:"share_url,omitempty"` +} + +// ShareListResponse is the owner share list payload. +type ShareListResponse struct { + Shares []ShareView `json:"shares"` + Total int `json:"total"` +} + +// ShareRevokeResponse is the DELETE /cloud/shares/{id} payload. +type ShareRevokeResponse struct { + Revoked bool `json:"revoked"` +} + +// ShareCodeResponse is the PATCH /cloud/shares/{id}/code payload. +type ShareCodeResponse struct { + Updated bool `json:"updated"` +} + +// ── public share responses ────────────────────────────────────────── + +// SharePublicFile is one member file of a folder share. +type SharePublicFile struct { + FileID int64 `json:"fileId"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + Path string `json:"path"` +} + +// ShareInfoResponse is the public GET /cloud/s/{token} payload. Target-specific +// fields are pointers so the file and folder shapes stay distinguishable. +type ShareInfoResponse struct { + TargetType string `json:"targetType"` + RequiresCode bool `json:"requiresCode"` + CreatedAt *time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt"` + Challenge string `json:"challenge"` + FolderName *string `json:"folderName,omitempty"` + Files *[]SharePublicFile `json:"files,omitempty"` + Truncated *bool `json:"truncated,omitempty"` + FileName *string `json:"fileName,omitempty"` + FileSize *int64 `json:"fileSize,omitempty"` + MimeType *string `json:"mimeType,omitempty"` +} + +// ShareAttestResponse is the public device-attestation payload. +type ShareAttestResponse struct { + Ticket string `json:"ticket"` + ExpiresIn int `json:"expiresIn"` +} + +// ShareAccessResponse is the public presigned-access payload. +type ShareAccessResponse struct { + URL string `json:"url"` + ViewMode string `json:"viewMode"` + MimeType string `json:"mimeType"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + ExpiresIn int `json:"expiresIn"` +} + +// AdminReviewAccessResponse is the app-admin review payload: an inline presigned +// grant for media, or a bounded plain-text rendition for documents. +type AdminReviewAccessResponse struct { + URL string `json:"url,omitempty"` + ViewMode string `json:"viewMode"` + MimeType string `json:"mimeType"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + ExpiresIn int `json:"expiresIn"` + Text string `json:"text,omitempty"` + Truncated *bool `json:"truncated,omitempty"` +} diff --git a/app-go/app-cloud/internal/dto/trash.go b/app-go/app-cloud/internal/dto/trash.go new file mode 100644 index 00000000..c3bd4a44 --- /dev/null +++ b/app-go/app-cloud/internal/dto/trash.go @@ -0,0 +1,36 @@ +package dto + +import "time" + +// TrashItem is one soft-deleted file. +type TrashItem struct { + UploadUUID string `json:"upload_uuid"` + OriginalName string `json:"original_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + DeletedAt *time.Time `json:"deleted_at"` + PurgeAt *time.Time `json:"purge_at"` +} + +// TrashListResponse is the GET /cloud/trash payload. +type TrashListResponse struct { + Items []TrashItem `json:"items"` + Total int `json:"total"` +} + +// TrashRestoreResponse is the POST /cloud/trash/{uuid}/restore payload. +type TrashRestoreResponse struct { + Restored bool `json:"restored"` + UploadUUID string `json:"upload_uuid"` +} + +// TrashPurgeResponse is the DELETE /cloud/trash/{uuid} payload. +type TrashPurgeResponse struct { + Purged bool `json:"purged"` + UploadUUID string `json:"upload_uuid"` +} + +// EmptyTrashResponse is the DELETE /cloud/trash payload. +type EmptyTrashResponse struct { + Purged int `json:"purged"` +} diff --git a/app-go/app-cloud/internal/dto/upload.go b/app-go/app-cloud/internal/dto/upload.go new file mode 100644 index 00000000..3f729fea --- /dev/null +++ b/app-go/app-cloud/internal/dto/upload.go @@ -0,0 +1,71 @@ +package dto + +// Upload endpoints keep the legacy camelCase wire contract clients already +// rely on (init params, presigned URLs, part identifiers). + +// UploadInitRequest is the POST /cloud/upload/init body. +type UploadInitRequest struct { + Filename string `json:"filename" binding:"required"` + FileSize int64 `json:"fileSize" binding:"required"` + MimeType string `json:"mimeType" binding:"required"` + FolderID *int64 `json:"folderId"` +} + +// UploadHeartbeatRequest is the POST /cloud/upload/heartbeat body. +type UploadHeartbeatRequest struct { + SessionUUID string `json:"sessionUuid" binding:"required"` +} + +// UploadHeartbeatResponse acknowledges a live upload session. +type UploadHeartbeatResponse struct { + Ack bool `json:"ack"` +} + +// UploadPart is one completed multipart part (S3 field names, uppercase). +type UploadPart struct { + PartNumber int `json:"PartNumber"` + ETag string `json:"ETag"` +} + +// UploadCompleteRequest is the POST /cloud/upload/{uuid}/complete body. +type UploadCompleteRequest struct { + Parts []UploadPart `json:"parts" binding:"required"` +} + +// UploadPresignedItem is one chunk's presigned PUT target. +type UploadPresignedItem struct { + ChunkIndex int64 `json:"chunkIndex"` + ChunkSize int64 `json:"chunkSize"` + URL string `json:"url"` +} + +// UploadInitResponse tells the client how to upload the object in chunks. +type UploadInitResponse struct { + UploadUUID string `json:"uploadUuid"` + SessionUUID string `json:"sessionUuid"` + MinioUploadID string `json:"minioUploadId"` + ChunkCount int64 `json:"chunkCount"` + ChunkSize int64 `json:"chunkSize"` + PresignedURLs []UploadPresignedItem `json:"presignedUrls"` +} + +// UploadCompleteResponse acknowledges a finalized multipart upload. +type UploadCompleteResponse struct { + UploadUUID string `json:"uploadUuid"` + ETag string `json:"etag"` + Status string `json:"status"` +} + +// UploadResumeChunk is one chunk still to be (re)uploaded. +type UploadResumeChunk struct { + ChunkIndex int64 `json:"chunkIndex"` + ChunkSize int64 `json:"chunkSize"` + URL string `json:"url"` +} + +// UploadResumeResponse re-issues presigned URLs for an interrupted upload. +type UploadResumeResponse struct { + UploadUUID string `json:"uploadUuid"` + MinioUploadID string `json:"minioUploadId"` + PendingChunks []UploadResumeChunk `json:"pendingChunks"` +} diff --git a/app-go/app-cloud/internal/dto/workspace.go b/app-go/app-cloud/internal/dto/workspace.go new file mode 100644 index 00000000..91cd2fd8 --- /dev/null +++ b/app-go/app-cloud/internal/dto/workspace.go @@ -0,0 +1,67 @@ +package dto + +// Workspace endpoints keep the Python-era camelCase wire contract. + +// WorkspaceCreateRequest is the POST /workspaces body. +type WorkspaceCreateRequest struct { + Name string `json:"name"` + Description *string `json:"description"` + Icon *string `json:"icon"` + Color *string `json:"color"` +} + +// WorkspaceUpdateRequest is the PATCH /workspaces/{id} body. +type WorkspaceUpdateRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Icon *string `json:"icon"` + Color *string `json:"color"` +} + +// WorkspaceBindingRequest is the POST /workspaces/{id}/bindings body. +type WorkspaceBindingRequest struct { + BindType string `json:"bindType"` + FolderID *int64 `json:"folderId"` + UploadUUID *string `json:"uploadUuid"` + IncludeSubfolders *bool `json:"includeSubfolders"` +} + +// WorkspaceBinding is one binding row. +type WorkspaceBinding struct { + ID int64 `json:"id"` + BindType string `json:"bindType"` + FolderID *int64 `json:"folderId"` + FolderName *string `json:"folderName"` + UploadUUID *string `json:"uploadUuid"` + FileName *string `json:"fileName"` + IncludeSubfolders bool `json:"includeSubfolders"` +} + +// Workspace is one workspace with its bindings. +type Workspace struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Icon *string `json:"icon"` + Color *string `json:"color"` + FileCount int `json:"fileCount"` + ChunkCount int `json:"chunkCount"` + Bindings []WorkspaceBinding `json:"bindings"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// WorkspaceDeleteResponse is the DELETE /workspaces/{id} payload. +type WorkspaceDeleteResponse struct { + Deleted bool `json:"deleted"` +} + +// WorkspaceFileItem is one expanded file of a workspace. +type WorkspaceFileItem struct { + UploadUUID string `json:"uploadUuid"` + OriginalName string `json:"originalName"` + MimeType string `json:"mimeType"` + Vectorizable bool `json:"vectorizable"` + VectorStatus string `json:"vectorStatus"` + VectorChunkCount int `json:"vectorChunkCount"` +} diff --git a/app-go/app-cloud/internal/infra/milvusstore/milvus.go b/app-go/app-cloud/internal/infra/milvusstore/milvus.go index ea9f3b38..a9406fd3 100644 --- a/app-go/app-cloud/internal/infra/milvusstore/milvus.go +++ b/app-go/app-cloud/internal/infra/milvusstore/milvus.go @@ -9,6 +9,7 @@ import ( "app-cloud/internal/config" "app-cloud/internal/model" + "app-cloud/internal/utils" "github.com/milvus-io/milvus/client/v2/column" "github.com/milvus-io/milvus/client/v2/entity" @@ -155,12 +156,12 @@ func (s *MilvusStore) InsertChunks(ctx context.Context, uploadUUID string, uid i uids[i] = uid chunkIdx[i] = int64(rows[i].ChunkIndex) chunkIDs[i] = rows[i].ChunkID - titles[i] = truncate(rows[i].Title, 512) - sources[i] = truncate(rows[i].Source, 32) - sourceTypes[i] = truncate(rows[i].SourceType, 16) - sectionTitles[i] = truncate(rows[i].SectionTitle, 256) - contentTypes[i] = truncate(rows[i].ContentType, 32) - texts[i] = truncate(rows[i].Text, 65000) + titles[i] = utils.TruncateBytes(rows[i].Title, 512) + sources[i] = utils.TruncateBytes(rows[i].Source, 32) + sourceTypes[i] = utils.TruncateBytes(rows[i].SourceType, 16) + sectionTitles[i] = utils.TruncateBytes(rows[i].SectionTitle, 256) + contentTypes[i] = utils.TruncateBytes(rows[i].ContentType, 32) + texts[i] = utils.TruncateBytes(rows[i].Text, 65000) vecs[i] = rows[i].Vector } _, err := s.cli.Insert(ctx, milvusclient.NewColumnBasedInsertOption(s.cfg.Collection, @@ -204,6 +205,57 @@ func (s *MilvusStore) CountByUploadUUID(ctx context.Context, uploadUUID string) return int(vals), nil } +// SearchChunks runs a dense ANN query against the chunk collection and +// returns the top matches in descending score order. filter is assembled by +// the caller from trusted fields only (uid / upload_uuid) — never from client +// input; nothing else is interpolated into the expression here. +func (s *MilvusStore) SearchChunks(ctx context.Context, vector []float32, + filter string, limit int) ([]model.ChunkHit, error) { + if len(vector) == 0 { + return nil, fmt.Errorf("milvus search: empty query vector") + } + if limit <= 0 { + return nil, nil + } + opt := milvusclient.NewSearchOption(s.cfg.Collection, limit, + []entity.Vector{entity.FloatVector(vector)}). + WithOutputFields("upload_uuid", "title", "section_title", "text") + if strings.TrimSpace(filter) != "" { + opt = opt.WithFilter(filter) + } + sets, err := s.cli.Search(ctx, opt) + if err != nil { + return nil, fmt.Errorf("milvus search: %w", err) + } + hits := make([]model.ChunkHit, 0, limit) + for _, rs := range sets { + uuidCol := rs.GetColumn("upload_uuid") + titleCol := rs.GetColumn("title") + sectionCol := rs.GetColumn("section_title") + textCol := rs.GetColumn("text") + for i := 0; i < rs.Len(); i++ { + hit := model.ChunkHit{} + if i < len(rs.Scores) { + hit.Score = float64(rs.Scores[i]) + } + if uuidCol != nil { + hit.UploadUUID, _ = uuidCol.GetAsString(i) + } + if titleCol != nil { + hit.Title, _ = titleCol.GetAsString(i) + } + if sectionCol != nil { + hit.SectionTitle, _ = sectionCol.GetAsString(i) + } + if textCol != nil { + hit.Text, _ = textCol.GetAsString(i) + } + hits = append(hits, hit) + } + } + return hits, nil +} + // DeleteByUploadUUID removes all vectors for an upload_uuid (idempotent). func (s *MilvusStore) DeleteByUploadUUID(ctx context.Context, uploadUUID string) error { _, err := s.cli.Delete(ctx, milvusclient.NewDeleteOption(s.cfg.Collection). @@ -214,16 +266,3 @@ func (s *MilvusStore) DeleteByUploadUUID(ctx context.Context, uploadUUID string) func escapeExpr(s string) string { return strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) } - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - // avoid splitting a multi-byte rune - for !utf8ValidStart(s[n]) && n > 0 { - n-- - } - return s[:n] -} - -func utf8ValidStart(b byte) bool { return b&0xC0 != 0x80 } diff --git a/app-go/app-cloud/internal/infra/mongostore/mongostore.go b/app-go/app-cloud/internal/infra/mongostore/mongostore.go index ad072672..08dd19c3 100644 --- a/app-go/app-cloud/internal/infra/mongostore/mongostore.go +++ b/app-go/app-cloud/internal/infra/mongostore/mongostore.go @@ -159,6 +159,46 @@ func (m *MongoStore) UpsertSummary(ctx context.Context, uploadUUID string, uid i return err } +// StoredSummary — the summary sub-document plus its provenance. Summary stays +// a raw BSON map so this package never imports the service layer's summary +// type; the reader decodes it. +type StoredSummary struct { + Summary map[string]any + Model string + ContentHash string +} + +// GetSummaryByUUID returns the stored summary of one file for its owner. +// Documents written before the field existed simply have no summary — the +// result is (nil, nil). A nil/disabled store yields the same. +func (m *MongoStore) GetSummaryByUUID(ctx context.Context, uid int64, + uploadUUID string) (*StoredSummary, error) { + if m == nil || m.db == nil { + return nil, nil + } + var doc struct { + Summary map[string]any `bson:"summary"` + Model string `bson:"summary_model"` + ContentHash string `bson:"summary_content_hash"` + } + err := m.db.Collection("cloud_drive_documents").FindOne(ctx, + bson.M{"upload_uuid": uploadUUID, "uid": uid, "summary": bson.M{"$exists": true}}, + options.FindOne().SetProjection(bson.M{ + "summary": 1, "summary_model": 1, "summary_content_hash": 1, "_id": 0, + }), + ).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + return nil, err + } + if doc.Summary == nil { + return nil, nil + } + return &StoredSummary{Summary: doc.Summary, Model: doc.Model, ContentHash: doc.ContentHash}, nil +} + // GetSummariesByUUIDs returns summary sub-documents for the given files // (projection excludes the full text — the routing judge never needs it). func (m *MongoStore) GetSummariesByUUIDs(ctx context.Context, uid int64, diff --git a/app-go/app-cloud/internal/metrics/metrics.go b/app-go/app-cloud/internal/metrics/metrics.go index 5860d21d..756f058e 100644 --- a/app-go/app-cloud/internal/metrics/metrics.go +++ b/app-go/app-cloud/internal/metrics/metrics.go @@ -43,6 +43,11 @@ var ( Help: "L1 document-summary stage outcomes (best-effort — errors never block vectorization).", }, []string{"result"}) // ok | error | skipped + OCRTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "appcloud_ocr_total", + Help: "Vision OCR outcomes for images and scanned PDFs: ok | error (retryable) | unsupported (permanent skip).", + }, []string{"result"}) + KgWriteTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "appcloud_kg_write_total", Help: "L1 routing-graph writes to app-kg by outcome (best-effort — drift self-heals on the next upsert).", diff --git a/app-go/app-cloud/internal/middleware/capability.go b/app-go/app-cloud/internal/middleware/capability.go new file mode 100644 index 00000000..5240a4f8 --- /dev/null +++ b/app-go/app-cloud/internal/middleware/capability.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// RequireCapability turns an optional wired service into a route-level gate: +// false means the capability is unavailable (503) before handler logic runs. +func RequireCapability(available bool, detail string) gin.HandlerFunc { + return func(c *gin.Context) { + if !available { + writeError(c, http.StatusServiceUnavailable, detail) + c.Abort() + return + } + c.Next() + } +} diff --git a/app-go/app-cloud/internal/middleware/context.go b/app-go/app-cloud/internal/middleware/context.go new file mode 100644 index 00000000..fb9b4841 --- /dev/null +++ b/app-go/app-cloud/internal/middleware/context.go @@ -0,0 +1,73 @@ +// Package middleware contains the app-cloud Gin middleware chain: identity, +// M2M credentials, capability gates and response security. Handlers never +// parse these concerns themselves. +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "app-cloud/internal/dto" + + "github.com/gin-gonic/gin" +) + +// uidContextKey is the request-context slot populated by RequireUID / +// RequireInternalUID; handlers read it through UIDFrom. +const uidContextKey = "cloud.uid" + +// RequireUID authenticates the gateway-injected X-Uid once for every owner +// route under /cloud and /workspaces. Missing/invalid identity aborts the +// chain with 401; handlers can assume a valid uid. +func RequireUID() gin.HandlerFunc { + return func(c *gin.Context) { + raw := strings.TrimSpace(c.Request.Header.Get("X-Uid")) + if raw == "" { + writeError(c, http.StatusUnauthorized, "未提供认证 token") + c.Abort() + return + } + uid, err := strconv.ParseInt(raw, 10, 64) + if err != nil || uid <= 0 { + writeError(c, http.StatusUnauthorized, "token 无效或已过期") + c.Abort() + return + } + c.Set(uidContextKey, uid) + c.Next() + } +} + +// UIDFrom returns the authenticated uid stored by the identity middleware. +func UIDFrom(c *gin.Context) int64 { + v, _ := c.Get(uidContextKey) + uid, _ := v.(int64) + return uid +} + +// RequireInternalUID validates the explicit ?uid= of an M2M call and stores it +// in the same context slot as owner routes. The apikey middleware already +// authenticated the calling service, so this is validation rather than trust. +func RequireInternalUID() gin.HandlerFunc { + return func(c *gin.Context) { + raw := strings.TrimSpace(c.Query("uid")) + if raw == "" { + writeError(c, http.StatusUnprocessableEntity, "缺少 uid 参数") + c.Abort() + return + } + uid, err := strconv.ParseInt(raw, 10, 64) + if err != nil || uid <= 0 { + writeError(c, http.StatusUnprocessableEntity, "uid 不合法") + c.Abort() + return + } + c.Set(uidContextKey, uid) + c.Next() + } +} + +func writeError(c *gin.Context, status int, detail string) { + c.JSON(status, dto.ErrorResponse{Detail: detail}) +} diff --git a/app-go/app-cloud/internal/middleware/internal.go b/app-go/app-cloud/internal/middleware/internal.go new file mode 100644 index 00000000..6406eb73 --- /dev/null +++ b/app-go/app-cloud/internal/middleware/internal.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + "strings" + + "app-cloud/internal/config" + + "github.com/gin-gonic/gin" +) + +// RequireInternalKey gates the M2M endpoints on the shared internal.api_key. +// Fail closed when the key is unconfigured (503); a configured key must match +// exactly (constant-time) or the request is rejected as 401. +func RequireInternalKey(cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + expected := "" + if cfg != nil { + expected = strings.TrimSpace(cfg.Internal.APIKey) + } + if expected == "" { + writeError(c, http.StatusServiceUnavailable, "internal api disabled") + c.Abort() + return + } + provided := strings.TrimSpace(c.GetHeader("apikey")) + if provided == "" { + provided = strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + } + if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + writeError(c, http.StatusUnauthorized, "invalid apikey") + c.Abort() + return + } + c.Next() + } +} diff --git a/app-go/app-cloud/internal/middleware/metrics.go b/app-go/app-cloud/internal/middleware/metrics.go new file mode 100644 index 00000000..db71946a --- /dev/null +++ b/app-go/app-cloud/internal/middleware/metrics.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "strconv" + "time" + + "app-cloud/internal/metrics" + + "github.com/gin-gonic/gin" +) + +// Metrics records the HTTP golden signals. The route label is the registered +// pattern (c.FullPath) so path parameters cannot explode label cardinality; +// unmatched paths collapse to UNMATCHED. +func Metrics() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + route := c.FullPath() + if route == "" { + route = "UNMATCHED" + } + metrics.HTTPRequests.WithLabelValues(c.Request.Method, route, strconv.Itoa(c.Writer.Status())).Inc() + metrics.HTTPDuration.WithLabelValues(c.Request.Method, route).Observe(time.Since(start).Seconds()) + } +} diff --git a/app-go/app-cloud/internal/middleware/security.go b/app-go/app-cloud/internal/middleware/security.go new file mode 100644 index 00000000..2efc853a --- /dev/null +++ b/app-go/app-cloud/internal/middleware/security.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "net/http" + "strings" + + "app-cloud/internal/config" + + "github.com/gin-gonic/gin" +) + +// SecurityHeaders sets the baseline response headers for every route. +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "SAMEORIGIN") // SAMEORIGIN: presigned previews render in-app + c.Header("Cache-Control", "no-store") + c.Next() + } +} + +// CORS applies the exact-origin allowlist and answers preflight locally. +func CORS(cfg *config.Config) gin.HandlerFunc { + allowed := make(map[string]bool) + if cfg != nil { + for _, o := range cfg.CORS.AllowOrigins { + allowed[strings.TrimSpace(o)] = true + } + } + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin != "" && allowed[origin] { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, If-Match, X-Fp, X-Ticket") + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/app-go/app-cloud/internal/model/model.go b/app-go/app-cloud/internal/model/model.go index a89d6785..fcf6afc6 100644 --- a/app-go/app-cloud/internal/model/model.go +++ b/app-go/app-cloud/internal/model/model.go @@ -180,6 +180,17 @@ func (CloudRef) TableName() string { return "cloud_refs" } // upload_uuid; status mirrors the cloud_files.vector_status transition // (pending → processing → done/failed). Workers claim with FOR UPDATE SKIP // LOCKED (repo.ClaimNext) and a janitor requeues claims older than +// ChunkHit — one vector match returned by the semantic index (query-time +// read model; owned here so the pipeline/service/milvus packages share it +// without importing each other). +type ChunkHit struct { + UploadUUID string + Title string + SectionTitle string + Text string + Score float64 +} + // ChunkRow — one vectorized chunk handed from the pipeline to the vector // store (shared vocabulary: pipeline builds it, milvusstore persists it). type ChunkRow struct { diff --git a/app-go/app-cloud/internal/pipeline/embed.go b/app-go/app-cloud/internal/pipeline/embed.go index d9b1c84b..a90cb1d0 100644 --- a/app-go/app-cloud/internal/pipeline/embed.go +++ b/app-go/app-cloud/internal/pipeline/embed.go @@ -168,4 +168,4 @@ func trimRight(s, cut string) string { s = s[:len(s)-len(cut)] } return s -} \ No newline at end of file +} diff --git a/app-go/app-cloud/internal/pipeline/embed_test.go b/app-go/app-cloud/internal/pipeline/embed_test.go index aa50e287..dce3db00 100644 --- a/app-go/app-cloud/internal/pipeline/embed_test.go +++ b/app-go/app-cloud/internal/pipeline/embed_test.go @@ -78,4 +78,4 @@ func TestEmbedGivesUpAfterMaxAttempts(t *testing.T) { if got := atomic.LoadInt32(&calls); got != int32(embedMaxAttempts) { t.Fatalf("expected %d attempts, got %d", embedMaxAttempts, got) } -} \ No newline at end of file +} diff --git a/app-go/app-cloud/internal/pipeline/engine.go b/app-go/app-cloud/internal/pipeline/engine.go index 98f46903..3b1a08d2 100644 --- a/app-go/app-cloud/internal/pipeline/engine.go +++ b/app-go/app-cloud/internal/pipeline/engine.go @@ -18,9 +18,11 @@ import ( "time" "app-cloud/internal/infra/minio" - "app-cloud/internal/model" "app-cloud/internal/infra/mongostore" + "app-cloud/internal/metrics" + "app-cloud/internal/model" "app-cloud/internal/repo" + "app-cloud/internal/utils" "gorm.io/gorm" ) @@ -44,14 +46,25 @@ type Engine struct { embedder *Embedder files *repo.FileRepo maxDoc int64 + // ocr reads images and image-only PDFs; nil/unconfigured keeps those + // files not_supported (the pre-OCR behaviour). + ocr *OCRClient + // extractPDFImages is the embedded-image extractor for scanned PDFs; + // injectable so the OCR assembly is testable without binary fixtures. + extractPDFImages pdfImageExtractor } func NewEngine(db *gorm.DB, mc *minio.Client, mongo *mongostore.MongoStore, milvus VectorStore, embedder *Embedder, maxDoc int64) *Engine { return &Engine{db: db, minio: mc, mongo: mongo, milvus: milvus, - embedder: embedder, files: repo.NewFileRepo(), maxDoc: maxDoc} + embedder: embedder, files: repo.NewFileRepo(), maxDoc: maxDoc, + extractPDFImages: extractPDFImagesPDFCPU} } +// SetOCR attaches the vision OCR client. A nil or unconfigured client is +// accepted: image and scanned-PDF files then stay not_supported. +func (e *Engine) SetOCR(ocr *OCRClient) { e.ocr = ocr } + // VectorizeHooks carries optional service-layer callbacks fired at fixed // points of the run. Best-effort by contract: hook errors are ignored by the // engine (durable-state concerns belong to the hook implementation). @@ -119,7 +132,7 @@ func (e *Engine) Vectorize(ctx context.Context, f *model.CloudFile, hooks Vector // ── Phase 3: embedding ── embedTexts := make([]string, len(chunks)) for i, ch := range chunks { - embedTexts[i] = EmbeddingText(ch.Text, ch.SectionTitle, displayTitle(f)) + embedTexts[i] = EmbeddingText(ch.Text, ch.SectionTitle, utils.DisplayTitle(f)) } vectors, err := e.embedder.Embed(ctx, embedTexts) if err != nil { @@ -139,7 +152,7 @@ func (e *Engine) Vectorize(ctx context.Context, f *model.CloudFile, hooks Vector rows[i] = model.ChunkRow{ ChunkIndex: i, ChunkID: f.UploadUUID + ":" + itoa(i), - Title: displayTitle(f), + Title: utils.DisplayTitle(f), Source: source, SourceType: sourceType, SectionTitle: ch.SectionTitle, @@ -177,17 +190,15 @@ func (e *Engine) Vectorize(ctx context.Context, f *model.CloudFile, hooks Vector // auto-ASR path; without it the file ends failed with a clear message). func (e *Engine) extractText(ctx context.Context, f *model.CloudFile, data []byte, contentHash, sourceType string) (string, string, []Heading, error) { - if strings.HasPrefix(strings.ToLower(f.MimeType), "video/") { - asr := e.mongo.ASRPreview(ctx, f.UploadUUID, 100<<20) - if strings.TrimSpace(asr) == "" { - return "", "", nil, fmt.Errorf("No ASR content for video %s. Run ASR first.", f.UploadUUID) - } - return asr, "asr", nil, nil - } - parsed, err := Extract(f.MimeType, f.OriginalName, data) + parsed, err := e.parseDocument(ctx, f, data) if err != nil { return "", "", nil, err } + if isVideoMime(f.MimeType) { + // Video text comes from asr_documents; there is no parsed-document + // row to write for it. + return parsed.Text, parsed.Source, nil, nil + } if err := e.mongo.UpsertParsedDocument(ctx, f.UploadUUID, f.UID, f.OriginalName, sourceType, parsed.Source, parsed.Text, contentHash, map[string]any{ @@ -200,6 +211,109 @@ func (e *Engine) extractText(ctx context.Context, f *model.CloudFile, data []byt return parsed.Text, parsed.Source, parsed.Headings, nil } +// parseDocument selects the text source for one file: video → existing ASR +// text, image → vision OCR, other documents → the text parsers. Image-only +// PDFs fall back to OCR of their embedded page images when it is configured. +func (e *Engine) parseDocument(ctx context.Context, f *model.CloudFile, data []byte) (*Parsed, error) { + if isVideoMime(f.MimeType) { + asr := e.mongo.ASRPreview(ctx, f.UploadUUID, 100<<20) + if strings.TrimSpace(asr) == "" { + return nil, fmt.Errorf("No ASR content for video %s. Run ASR first.", f.UploadUUID) + } + return &Parsed{Text: asr, Source: "asr"}, nil + } + if IsImageFile(f.MimeType, f.OriginalName) { + return e.ocrImage(ctx, f, data) + } + parsed, err := Extract(f.MimeType, f.OriginalName, data) + if err != nil && errors.Is(err, ErrEmptyPDFText) && isPDFFile(f.MimeType, f.OriginalName) { + return e.ocrScannedPDF(ctx, f, data) + } + return parsed, err +} + +// ParseDocument runs the file-to-text routing without MinIO or Mongo access: +// video files are rejected (their text comes from the ASR store, which this +// entry point deliberately does not touch), images go through vision OCR, +// image-only PDFs through OCR of their embedded page images, and everything +// else through the text parsers. It exposes the extraction decision for +// black-box tests and future callers; the vectorize pipeline keeps using the +// engine directly. +func ParseDocument(ctx context.Context, ocr *OCRClient, mimeType, filename string, data []byte) (*Parsed, error) { + e := &Engine{ocr: ocr, extractPDFImages: extractPDFImagesPDFCPU} + return e.parseDocument(ctx, &model.CloudFile{OriginalName: filename, MimeType: mimeType}, data) +} + +// ocrImage runs vision OCR over a single uploaded image. +func (e *Engine) ocrImage(ctx context.Context, f *model.CloudFile, data []byte) (*Parsed, error) { + if !e.ocr.Enabled() { + // Same permanent-skip semantics images had before OCR existed. + return nil, fmt.Errorf("%w: %s", ErrOCRDisabled, f.OriginalName) + } + mime := NormalizeImageMime(f.MimeType, data) + if mime == "" { + metrics.OCRTotal.WithLabelValues("unsupported").Inc() + return nil, fmt.Errorf("%w: %q", ErrOCRUnsupportedImage, f.MimeType) + } + text, err := e.ocr.Recognize(ctx, OCRImage{Data: data, MimeType: mime}) + if err != nil { + metrics.OCRTotal.WithLabelValues(ocrResultLabel(err)).Inc() + return nil, fmt.Errorf("ocr image %s: %w", f.UploadUUID, err) + } + metrics.OCRTotal.WithLabelValues("ok").Inc() + return &Parsed{Text: text, Source: "ocr"}, nil +} + +// ocrScannedPDF OCRs the embedded page images of an image-only PDF. Images +// that individually cannot be read are skipped so the readable pages still +// land; any transient gateway failure aborts the run as retryable. +func (e *Engine) ocrScannedPDF(ctx context.Context, f *model.CloudFile, data []byte) (*Parsed, error) { + if !e.ocr.Enabled() { + // Keep the pre-OCR permanent skip for scanned PDFs. + return nil, ErrEmptyPDFText + } + extract := e.extractPDFImages + if extract == nil { + extract = extractPDFImagesPDFCPU + } + images, err := extract(data, e.ocr.MaxPDFPages(), e.ocr.MaxImages(), e.ocr.MaxImageBytes()) + if err != nil { + slog.Warn("[PIPELINE_OCR] scanned PDF image extraction failed — treated as no extractable content", + "upload_uuid", f.UploadUUID, "err", err) + return nil, fmt.Errorf("%w: %v", ErrEmptyPDFText, err) + } + if len(images) == 0 { + return nil, ErrEmptyPDFText + } + var b strings.Builder + recognized := 0 + for _, img := range images { + text, oerr := e.ocr.Recognize(ctx, OCRImage{Data: img.Data, MimeType: img.MimeType}) + if oerr != nil { + if IsPermanentOCRError(oerr) { + // One unusable image must not discard the readable pages. + metrics.OCRTotal.WithLabelValues("unsupported").Inc() + continue + } + metrics.OCRTotal.WithLabelValues("error").Inc() + return nil, fmt.Errorf("ocr scanned pdf %s (page %d): %w", f.UploadUUID, img.Page, oerr) + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + recognized++ + b.WriteString(text) + b.WriteString("\n\n") + } + if recognized == 0 { + metrics.OCRTotal.WithLabelValues("unsupported").Inc() + return nil, ErrOCRNoContent + } + metrics.OCRTotal.WithLabelValues("ok").Inc() + return &Parsed{Text: strings.TrimSpace(b.String()), Source: "ocr"}, nil +} + // CountChunks / DeleteChunks — the service layer's reconciliation surface. func (e *Engine) CountChunks(ctx context.Context, uploadUUID string) (int, error) { return e.milvus.CountByUploadUUID(ctx, uploadUUID) @@ -214,13 +328,6 @@ func sha256Hex(b []byte) string { return hex.EncodeToString(sum[:]) } -func displayTitle(f *model.CloudFile) string { - if f.Title != nil && strings.TrimSpace(*f.Title) != "" { - return *f.Title - } - return f.OriginalName -} - func headingsToAny(hs []Heading) []map[string]any { out := make([]map[string]any, 0, len(hs)) for _, h := range hs { diff --git a/app-go/app-cloud/internal/pipeline/ocr.go b/app-go/app-cloud/internal/pipeline/ocr.go new file mode 100644 index 00000000..20670904 --- /dev/null +++ b/app-go/app-cloud/internal/pipeline/ocr.go @@ -0,0 +1,436 @@ +package pipeline + +// Vision OCR — images, screenshots and image-only (scanned) PDFs are read by a +// vision model through the same OpenAI-compatible gateway as embedding and +// summary. This file owns the gateway call, its retry policy and the error +// taxonomy that lets the orchestrator distinguish "permanently unsupported" +// (no OCR configured, unusable media, nothing readable) from "temporarily +// failed" (gateway/timeout) — only the latter is worth retrying. +// +// Pure gateway adapter: no orchestration, no Mongo, no Milvus. + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "app-cloud/internal/circuit" + "app-cloud/internal/config" +) + +// Permanent OCR outcomes. Every one of these means "the same file will never +// produce text on a retry", so the orchestrator maps them to +// vector_status=not_supported instead of failed. +var ( + // ErrOCRDisabled — OCR is switched off or the gateway is not configured. + ErrOCRDisabled = errors.New("OCR 未启用或未配置") + // ErrOCRUnsupportedImage — the media cannot be sent to the vision model. + ErrOCRUnsupportedImage = errors.New("图片格式不支持 OCR") + // ErrOCRNoContent — OCR ran but the images contained no readable text. + ErrOCRNoContent = errors.New("图片中未识别到文字") + // ErrImageTooLarge — one image exceeds the configured byte limit. + ErrImageTooLarge = errors.New("图片超过 OCR 大小限制") +) + +// ocrPrompt is the instruction sent with every image. The output is plain +// text in reading order so it can be chunked and embedded directly. +const ocrPrompt = "请提取图片中的全部文字内容,按原始阅读顺序输出,不要添加解释" + +// ocrMaxAttempts = the first call plus up to two retries. Only transient +// failures (transport, 429, 5xx, auth/route blips, unparsable completion) are +// retried; unusable media and HTTP 4xx class errors fail immediately. +const ocrMaxAttempts = 3 + +// OCRImage is one image handed to the vision model. +type OCRImage struct { + Data []byte + MimeType string +} + +// OCRClient calls the gateway's OpenAI-compatible /chat/completions with an +// image data URL part. Guarded by a circuit breaker so a dead gateway fails +// fast instead of burning every worker on timeouts. +type OCRClient struct { + cfg config.OCRConfig + base string + apiKey string + client *http.Client + br *circuit.Breaker +} + +// NewOCRClient builds the OCR caller from the shared Higress connection +// (embedding base URL + gateway key). OCR has no separate provider credentials. +func NewOCRClient(cfg config.OCRConfig, emb config.EmbeddingConfig) *OCRClient { + timeout := time.Duration(cfg.TimeoutSecs) * time.Second + if timeout <= 0 { + timeout = time.Duration(config.DefaultOCRTimeoutSecs) * time.Second + } + return &OCRClient{ + cfg: cfg, + base: strings.TrimRight(strings.TrimSpace(emb.BaseURL), "/"), + apiKey: strings.TrimSpace(emb.APIKey), + client: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + }, + br: circuit.New(5, 30*time.Second), + } +} + +// Enabled reports whether OCR should run at all: the stage must be on, a model +// configured, and the resolved gateway connection complete. A nil client is +// not enabled. +func (o *OCRClient) Enabled() bool { + return o != nil && o.cfg.Enabled && strings.TrimSpace(o.cfg.Model) != "" && + o.base != "" && o.apiKey != "" +} + +// ModelName extends the resolved model name for logging/attribution. +func (o *OCRClient) ModelName() string { + if o == nil { + return "" + } + return o.cfg.Model +} + +// MaxImageBytes is the per-image byte limit (0 when unset). +func (o *OCRClient) MaxImageBytes() int64 { + if o == nil { + return 0 + } + return o.cfg.MaxImageBytes +} + +// MaxPDFPages bounds how many pages of a scanned PDF are inspected. +func (o *OCRClient) MaxPDFPages() int { + if o == nil { + return 0 + } + return o.cfg.MaxPDFPages +} + +// MaxImages bounds how many embedded images of a scanned PDF are OCR'd. +func (o *OCRClient) MaxImages() int { + if o == nil { + return 0 + } + return o.cfg.MaxImages +} + +// Recognize runs OCR for one image and returns the extracted text. +func (o *OCRClient) Recognize(ctx context.Context, img OCRImage) (string, error) { + if !o.Enabled() { + return "", ErrOCRDisabled + } + if len(img.Data) == 0 { + return "", ErrOCRNoContent + } + if max := o.MaxImageBytes(); max > 0 && int64(len(img.Data)) > max { + return "", fmt.Errorf("%w: %d bytes > limit %d", ErrImageTooLarge, len(img.Data), max) + } + mime := NormalizeImageMime(img.MimeType, img.Data) + if mime == "" { + return "", fmt.Errorf("%w: %q", ErrOCRUnsupportedImage, img.MimeType) + } + if !o.br.Allow() { + // Downstream trip: fail fast — the durable outbox retries later. + return "", fmt.Errorf("%w: OCR 网关", circuit.ErrOpen) + } + + var lastErr error + emptyOnly := true + for attempt := 1; attempt <= ocrMaxAttempts; attempt++ { + text, status, err := o.once(ctx, mime, img.Data) + if err == nil && strings.TrimSpace(text) == "" { + // A completion without text is model behaviour, not a dead + // gateway: retryable but breaker-neutral. + err = errEmptyOCRCompletion + status = -1 + } + if err == nil { + o.br.OnSuccess() + return strings.TrimSpace(text), nil + } + lastErr = err + if !errors.Is(err, errEmptyOCRCompletion) { + emptyOnly = false + } + permanent := o.classifyPermanent(err, status) + if !permanent { + o.br.OnFailure() + } + if permanent { + return "", o.permanentError(err, status) + } + if attempt == ocrMaxAttempts { + if emptyOnly { + // Nothing readable on every attempt — a permanent verdict, + // not a transient model hiccup. + return "", fmt.Errorf("%w: %v", ErrOCRNoContent, lastErr) + } + return "", lastErr + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(attempt) * 500 * time.Millisecond): + } + } + return "", lastErr +} + +// classifyPermanent maps a failed attempt to the permanent taxonomy. status 0 +// = transport failure; -1 = completion body without usable text; HTTP status +// otherwise. +func (o *OCRClient) classifyPermanent(err error, status int) bool { + if errors.Is(err, ErrOCRUnsupportedImage) || errors.Is(err, ErrImageTooLarge) { + return true + } + switch status { + case http.StatusBadRequest, http.StatusUnsupportedMediaType, http.StatusUnprocessableEntity: + return true + case http.StatusRequestEntityTooLarge: + return true + default: + return false + } +} + +// errEmptyOCRCompletion marks a successful HTTP response whose completion had +// no text. Retryable, but a permanent no-content verdict once retries run out. +var errEmptyOCRCompletion = errors.New("OCR 返回空内容") + +// permanentError decorates a permanent gateway rejection with the taxonomy +// sentinel the orchestrator maps to vector_status=not_supported. +func (o *OCRClient) permanentError(err error, status int) error { + if status == http.StatusRequestEntityTooLarge { + return fmt.Errorf("%w: %v", ErrImageTooLarge, err) + } + return fmt.Errorf("%w: %v", ErrOCRUnsupportedImage, err) +} + +type ocrContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Image *struct { + URL string `json:"url"` + } `json:"image_url,omitempty"` +} + +type ocrChatRequest struct { + Model string `json:"model"` + Messages []ocrChatMessage `json:"messages"` + Temperature float64 `json:"temperature"` +} + +type ocrChatMessage struct { + Role string `json:"role"` + Content []ocrContentPart `json:"content"` +} + +type ocrChatResponse struct { + Choices []struct { + Message struct { + Content json.RawMessage `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// once performs a single gateway call. status 0 = transport error, -1 reserved +// for a response we could not read (handled by the caller). +func (o *OCRClient) once(ctx context.Context, mime string, data []byte) (string, int, error) { + dataURL := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) + reqBody := ocrChatRequest{ + Model: o.cfg.Model, + Messages: []ocrChatMessage{{ + Role: "user", + Content: []ocrContentPart{ + {Type: "text", Text: ocrPrompt}, + {Type: "image_url", Image: &struct { + URL string `json:"url"` + }{URL: dataURL}}, + }, + }}, + Temperature: 0, + } + payload, err := json.Marshal(reqBody) + if err != nil { + return "", 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.base+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return "", 0, err + } + req.Header.Set("Authorization", "Bearer "+o.apiKey) + req.Header.Set("Content-Type", "application/json") + resp, err := o.client.Do(req) + if err != nil { + return "", 0, fmt.Errorf("OCR 传输失败: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return "", resp.StatusCode, err + } + if resp.StatusCode != http.StatusOK { + return "", resp.StatusCode, fmt.Errorf("OCR http %d: %.200s", resp.StatusCode, string(body)) + } + var out ocrChatResponse + if err := json.Unmarshal(body, &out); err != nil { + return "", -1, fmt.Errorf("OCR 响应解析失败: %w", err) + } + if out.Error != nil && strings.TrimSpace(out.Error.Message) != "" { + return "", -1, fmt.Errorf("OCR 网关错误: %s", out.Error.Message) + } + if len(out.Choices) == 0 { + return "", -1, errors.New("OCR 响应缺少 choices") + } + return ocrMessageText(out.Choices[0].Message.Content), resp.StatusCode, nil +} + +// ocrMessageText accepts both completion shapes seen from OpenAI-compatible +// gateways: a plain string, or an array of content parts carrying "text". +func ocrMessageText(raw json.RawMessage) string { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || string(trimmed) == "null" { + return "" + } + var s string + if err := json.Unmarshal(trimmed, &s); err == nil { + return s + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(trimmed, &parts); err != nil { + return "" + } + var b strings.Builder + for _, p := range parts { + if p.Text != "" { + b.WriteString(p.Text) + } + } + return b.String() +} + +// imageMimeByExt maps the supported image extensions to their MIME type. +var imageMimeByExt = map[string]string{ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".gif": "image/gif", +} + +// ocrSupportedMime lists media the vision gateway accepts. TIFF/JP2 are +// deliberately absent: the gateway rejects them, so they stay unsupported +// instead of burning retries. +var ocrSupportedMime = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "image/bmp": true, + "image/gif": true, +} + +// imageExt returns the lowercase extension with the leading dot ("" when the +// name has none). +func imageExt(name string) string { + i := strings.LastIndex(name, ".") + if i < 0 || i == len(name)-1 { + return "" + } + ext := strings.ToLower(name[i:]) + if strings.ContainsAny(ext, "/\\ ") { + return "" + } + return ext +} + +// IsImageFile reports whether the file should go through the image OCR path: +// an image/* MIME or one of the supported image extensions. +func IsImageFile(mimeType, filename string) bool { + m := strings.ToLower(strings.TrimSpace(mimeType)) + if strings.HasPrefix(m, "image/") { + return true + } + _, ok := imageMimeByExt[imageExt(filename)] + return ok +} + +// isVideoMime reports whether the file is a video (text comes from ASR). +func isVideoMime(mimeType string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") +} + +// isPDFFile reports whether the file is a PDF (declared type or extension). +func isPDFFile(mimeType, filename string) bool { + m := strings.ToLower(strings.TrimSpace(mimeType)) + return m == "application/pdf" || imageExt(strings.ToLower(filename)) == ".pdf" +} + +// NormalizeImageMime returns the MIME type to send to the gateway, preferring +// a usable declared type and falling back to extension / content sniffing. +// Empty result = the media is not OCR-able. +func NormalizeImageMime(mimeType string, data []byte) string { + m := strings.ToLower(strings.TrimSpace(mimeType)) + if i := strings.Index(m, ";"); i >= 0 { + m = strings.TrimSpace(m[:i]) + } + if ocrSupportedMime[m] { + return m + } + if len(data) > 0 { + sniffed := strings.ToLower(http.DetectContentType(data)) + if i := strings.Index(sniffed, ";"); i >= 0 { + sniffed = strings.TrimSpace(sniffed[:i]) + } + if ocrSupportedMime[sniffed] { + return sniffed + } + } + return "" +} + +// IsPermanentOCRError reports whether an OCR failure means "a retry cannot +// help" — those map to vector_status=not_supported instead of failed. +func IsPermanentOCRError(err error) bool { + return errors.Is(err, ErrOCRDisabled) || errors.Is(err, ErrOCRUnsupportedImage) || + errors.Is(err, ErrOCRNoContent) || errors.Is(err, ErrImageTooLarge) +} + +// ocrResultLabel buckets an OCR error for the metrics counter. +func ocrResultLabel(err error) string { + if IsPermanentOCRError(err) { + return "unsupported" + } + return "error" +} + +// PDFImageMime maps pdfcpu's file-type labels to MIME types; "" means the +// extracted image cannot be sent to the vision model. +func PDFImageMime(fileType string) string { + switch strings.ToLower(strings.TrimSpace(fileType)) { + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "webp": + return "image/webp" + default: + return "" + } +} diff --git a/app-go/app-cloud/internal/pipeline/pdfimages.go b/app-go/app-cloud/internal/pipeline/pdfimages.go new file mode 100644 index 00000000..995dcfe1 --- /dev/null +++ b/app-go/app-cloud/internal/pipeline/pdfimages.go @@ -0,0 +1,82 @@ +package pipeline + +// Embedded-image extraction for image-only (scanned) PDFs. The text extractor +// reports ErrEmptyPDFText for those documents; the OCR path then pulls the +// page images out with pdfcpu (pure Go — no cgo, no external binaries) and +// hands them to the vision model. +// +// Bounds come from the OCR config: at most maxPages pages are inspected, at +// most maxImages images are returned and any image larger than maxBytes is +// skipped (it could not be sent to the gateway anyway). + +import ( + "bytes" + "fmt" + "io" + "sort" + + "app-cloud/internal/config" + + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +// PDFImage is one image extracted from a PDF page, in page order. +type PDFImage struct { + Data []byte + MimeType string + Page int // 1-based page number +} + +// pdfImageExtractor extracts page images from a PDF. Injected into the engine +// so tests can exercise the OCR assembly without binary PDF fixtures. +type pdfImageExtractor func(data []byte, maxPages, maxImages int, maxBytes int64) ([]PDFImage, error) + +// extractPDFImagesPDFCPU implements pdfImageExtractor with pdfcpu: pages are +// visited in document order and each page's images are read in resource order. +func extractPDFImagesPDFCPU(data []byte, maxPages, maxImages int, maxBytes int64) ([]PDFImage, error) { + if maxPages <= 0 { + maxPages = config.DefaultOCRMaxPDFPages + } + if maxImages <= 0 { + maxImages = config.DefaultOCRMaxImages + } + if maxBytes <= 0 { + maxBytes = config.DefaultOCRMaxImageBytes + } + // Page selection also bounds the work pdfcpu performs before decoding. + pages, err := api.ExtractImagesRaw(bytes.NewReader(data), + []string{fmt.Sprintf("1-%d", maxPages)}, nil) + if err != nil { + return nil, fmt.Errorf("pdfcpu extract images: %w", err) + } + out := make([]PDFImage, 0, maxImages) + for pageIdx, imageMap := range pages { + keys := make([]int, 0, len(imageMap)) + for k := range imageMap { + keys = append(keys, k) + } + sort.Ints(keys) + for _, k := range keys { + img := imageMap[k] + if img.Thumb || img.IsImgMask { + continue // thumbnails/masks carry no readable page content + } + mime := PDFImageMime(img.FileType) + if mime == "" { + continue // e.g. JPX/CCITT output the vision gateway rejects + } + if img.Reader == nil { + continue + } + raw, rerr := io.ReadAll(io.LimitReader(img.Reader, maxBytes+1)) + if rerr != nil || len(raw) == 0 || int64(len(raw)) > maxBytes { + continue + } + out = append(out, PDFImage{Data: raw, MimeType: mime, Page: pageIdx + 1}) + if len(out) >= maxImages { + return out, nil + } + } + } + return out, nil +} diff --git a/app-go/app-cloud/internal/repo/file.go b/app-go/app-cloud/internal/repo/file.go index c14edce2..6596fd7e 100644 --- a/app-go/app-cloud/internal/repo/file.go +++ b/app-go/app-cloud/internal/repo/file.go @@ -60,6 +60,22 @@ func (r *FileRepo) Create(db *gorm.DB, f *model.CloudFile) error { }) } +// ClaimVectorPending atomically moves a file to vector_status=pending unless +// it is already pending/processing. Returns false when another request owns +// the in-flight run, which is the cheap idempotency guard against repeated +// "入库" clicks (each reprocess would otherwise re-run OCR/summary/embedding). +func (r *FileRepo) ClaimVectorPending(db *gorm.DB, uploadUUID string, uid int64) (bool, error) { + now := time.Now() + res := db.Model(&model.CloudFile{}). + Where("upload_uuid = ? AND uid = ? AND deleted_at IS NULL", uploadUUID, uid). + Where("vector_status IS NULL OR vector_status NOT IN ?", []string{"pending", "processing"}). + Updates(map[string]any{"vector_status": "pending", "updated_at": now}) + if res.Error != nil { + return false, res.Error + } + return res.RowsAffected > 0, nil +} + // UpdateMeta patches title/description/tags/folder_id (owner-checked). // Only non-nil entries are applied; use UpdateMetaExplicit for "set to NULL". func (r *FileRepo) UpdateMeta(db *gorm.DB, uploadUUID string, uid int64, fields map[string]any) error { @@ -179,6 +195,32 @@ func (r *FileRepo) ListTrash(db *gorm.DB, uid int64) ([]model.CloudFile, error) return rows, err } +// ListByUUIDs returns the active files of one user for the given upload_uuids +// (semantic result hydration: "does this hit still exist for this owner?"). +func (r *FileRepo) ListByUUIDs(db *gorm.DB, uid int64, uploadUUIDs []string) ([]model.CloudFile, error) { + if len(uploadUUIDs) == 0 { + return nil, nil + } + var rows []model.CloudFile + err := db.Where("uid = ? AND deleted_at IS NULL AND upload_uuid IN ?", uid, uploadUUIDs). + Find(&rows).Error + return rows, err +} + +// ListActiveByFolderIDs returns the active files inside the given folders. +// Only the identifying columns are selected — callers use it to build the +// allowed file set for a folder-scoped semantic search. +func (r *FileRepo) ListActiveByFolderIDs(db *gorm.DB, uid int64, folderIDs []int64) ([]model.CloudFile, error) { + if len(folderIDs) == 0 { + return nil, nil + } + var rows []model.CloudFile + err := db.Select("upload_uuid, folder_id, original_name, title, mime_type, file_size"). + Where("uid = ? AND deleted_at IS NULL AND folder_id IN ?", uid, folderIDs). + Find(&rows).Error + return rows, err +} + // SearchByName does a substring match on original_name / title (active files). func (r *FileRepo) SearchByName(db *gorm.DB, uid int64, pattern string, limit int) ([]model.CloudFile, error) { var rows []model.CloudFile diff --git a/app-go/app-cloud/internal/repo/vector_task.go b/app-go/app-cloud/internal/repo/vector_task.go index d6dcad44..2e3bdbf7 100644 --- a/app-go/app-cloud/internal/repo/vector_task.go +++ b/app-go/app-cloud/internal/repo/vector_task.go @@ -1,10 +1,11 @@ package repo import ( + "database/sql" "time" - "unicode/utf8" "app-cloud/internal/model" + "app-cloud/internal/utils" "gorm.io/gorm" ) @@ -101,7 +102,7 @@ type RequeueCandidate struct { func (r *VectorTaskRepo) Finish(db *gorm.DB, id int64, status, errMsg string) error { return db.Model(&model.CloudVectorTask{}).Where("id = ?", id).Updates(map[string]any{ "status": status, - "last_error": truncateErr(errMsg), + "last_error": utils.TruncateBytes(errMsg, 500), "updated_at": time.Now(), }).Error } @@ -112,7 +113,7 @@ func (r *VectorTaskRepo) Finish(db *gorm.DB, id int64, status, errMsg string) er func (r *VectorTaskRepo) FinishByUploadUUID(db *gorm.DB, uploadUUID, status, errMsg string) error { return db.Model(&model.CloudVectorTask{}).Where("upload_uuid = ?", uploadUUID).Updates(map[string]any{ "status": status, - "last_error": truncateErr(errMsg), + "last_error": utils.TruncateBytes(errMsg, 500), "updated_at": time.Now(), }).Error } @@ -187,6 +188,22 @@ func (r *VectorTaskRepo) RequeueOneWithFiles(db *gorm.DB, c RequeueCandidate, st }) } +// LastUpdatedForUploadUUID returns the last task-row update for a file. +// Used to rate-limit manual reprocess requests (a completed row's updated_at +// is its finish time, so a second click inside the cooldown is rejected +// before any OCR/embedding/summary tokens are spent). +func (r *VectorTaskRepo) LastUpdatedForUploadUUID(db *gorm.DB, uploadUUID string) (time.Time, bool, error) { + var updatedAt time.Time + err := db.Raw("SELECT updated_at FROM cloud_vector_tasks WHERE upload_uuid = ? LIMIT 1", uploadUUID).Row().Scan(&updatedAt) + if err == sql.ErrNoRows { + return time.Time{}, false, nil + } + if err != nil { + return time.Time{}, false, err + } + return updatedAt, true, nil +} + // CountStates returns (pending, pendingLow, processing) row counts — // queue-depth observability for the peak-shaving buffer. func (r *VectorTaskRepo) CountStates(db *gorm.DB) (pending int64, pendingLow int64, processing int64, err error) { @@ -196,8 +213,8 @@ func (r *VectorTaskRepo) CountStates(db *gorm.DB) (pending int64, pendingLow int Processing int64 } if err = db.Raw( - "SELECT COALESCE(SUM(status = 'pending'), 0) AS pending, "+ - "COALESCE(SUM(status = 'pending_low'), 0) AS pending_low, "+ + "SELECT COALESCE(SUM(status = 'pending'), 0) AS pending, " + + "COALESCE(SUM(status = 'pending_low'), 0) AS pending_low, " + "COALESCE(SUM(status = 'processing'), 0) AS processing FROM cloud_vector_tasks", ).Scan(&out).Error; err != nil { return 0, 0, 0, err @@ -213,25 +230,3 @@ func (r *VectorTaskRepo) CountInFlightForUID(db *gorm.DB, uid int64) (int64, err []string{"pending", "pending_low", "processing"}).Count(&n).Error return n, err } - -// truncateErr bounds an error string to last_error varchar(500) without -// splitting a multi-byte rune at the boundary (Chinese error messages are -// common here). -func truncateErr(s string) string { - const max = 500 - if len(s) <= max { - return s - } - cut := s[:max] - // Strip an incomplete trailing rune: DecodeLastRuneInString reports - // RuneError/size 1 for a missing-continuation tail (a lead byte alone is - // just as invalid as a bare continuation byte). - - for len(cut) > 0 { - if r, size := utf8.DecodeLastRuneInString(cut); !(r == utf8.RuneError && size == 1) { - break - } - cut = cut[:len(cut)-1] - } - return cut -} diff --git a/app-go/app-cloud/internal/repo/vector_task_test.go b/app-go/app-cloud/internal/repo/vector_task_test.go index 8fd93fc0..2538b60a 100644 --- a/app-go/app-cloud/internal/repo/vector_task_test.go +++ b/app-go/app-cloud/internal/repo/vector_task_test.go @@ -4,13 +4,15 @@ import ( "strings" "testing" "unicode/utf8" + + "app-cloud/internal/utils" ) // truncateErr must bound the byte length without splitting a multi-byte rune // (last_error is varchar(500); Chinese error messages are common). func TestTruncateErrRespectsRuneBoundary(t *testing.T) { long := strings.Repeat("错", 250) // 750 bytes of 3-byte runes - got := truncateErr(long) + got := utils.TruncateBytes(long, 500) if len(got) > 500 { t.Fatalf("truncated to %d bytes, want <= 500", len(got)) } @@ -23,10 +25,10 @@ func TestTruncateErrRespectsRuneBoundary(t *testing.T) { } func TestTruncateErrShortPassthrough(t *testing.T) { - if got := truncateErr("短错误"); got != "短错误" { + if got := utils.TruncateBytes("短错误", 500); got != "短错误" { t.Fatalf("got %q", got) } - if got := truncateErr(""); got != "" { + if got := utils.TruncateBytes("", 500); got != "" { t.Fatalf("empty passthrough: %q", got) } } diff --git a/app-go/app-cloud/internal/router/cloud.go b/app-go/app-cloud/internal/router/cloud.go index bc73ac5a..da5cd653 100644 --- a/app-go/app-cloud/internal/router/cloud.go +++ b/app-go/app-cloud/internal/router/cloud.go @@ -7,17 +7,20 @@ import ( "strings" "time" + "app-cloud/internal/dto" + "app-cloud/internal/middleware" "app-cloud/internal/model" "app-cloud/internal/service" "github.com/gin-gonic/gin" ) -// registerCloud mounts the user-facing /cloud/* endpoints. Paths and response -// shapes match the Python backend's app/routers/cloud.py so the frontend's -// cloudApi keeps working unchanged. +// registerCloud mounts the user-facing /cloud/* endpoints. Handlers only bind +// requests, call services and map the results onto the wire DTOs in +// internal/dto — the JSON contract lives there. func registerCloud(e *gin.Engine, d Deps) { g := e.Group("/cloud") + g.Use(middleware.RequireUID()) registerShareRoutes(e, d, g) registerRefRoutes(g, d) // 1.0.16 — /cloud/refs 引用面 @@ -38,8 +41,8 @@ func registerCloud(e *gin.Engine, d Deps) { g.GET("/video/:upload_uuid", getVideoDetail(d)) g.PATCH("/video/:upload_uuid", updateVideo(d)) g.DELETE("/video/:upload_uuid", deleteVideo(d)) - g.POST("/video/:upload_uuid/process", triggerProcess(d)) - g.POST("/video/:upload_uuid/reprocess", reprocess(d)) + g.POST("/video/:upload_uuid/process", middleware.RequireCapability(d.Process != nil, "处理管线未启用"), triggerProcess(d)) + g.POST("/video/:upload_uuid/reprocess", middleware.RequireCapability(d.Process != nil, "处理管线未启用"), reprocess(d)) g.GET("/video/:upload_uuid/status", getVideoStatus(d)) g.GET("/video/:upload_uuid/preview", getDocumentPreview(d)) g.GET("/video/:upload_uuid/raw", getVideoRaw(d)) @@ -48,6 +51,9 @@ func registerCloud(e *gin.Engine, d Deps) { g.GET("/quota", getQuota(d)) g.GET("/search", searchFiles(d)) + // ── document intelligence: summary / semantic search / similar files ── + registerCloudAI(g, d) + // ── trash (new) ── g.GET("/trash", listTrash(d)) g.POST("/trash/:upload_uuid/restore", restoreTrash(d)) @@ -57,20 +63,10 @@ func registerCloud(e *gin.Engine, d Deps) { // ── upload ─────────────────────────────────────────────────────────── -type uploadInitReq struct { - Filename string `json:"filename" binding:"required"` - FileSize int64 `json:"fileSize" binding:"required"` - MimeType string `json:"mimeType" binding:"required"` - FolderID *int64 `json:"folderId"` -} - func initUpload(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - var req uploadInitReq + uid := middleware.UIDFrom(c) + var req dto.UploadInitRequest if err := c.ShouldBindJSON(&req); err != nil { apiErr(c, http.StatusUnprocessableEntity, "请求参数不合法") return @@ -85,80 +81,89 @@ func initUpload(d Deps) gin.HandlerFunc { apiErr(c, http.StatusBadRequest, err.Error()) return } - c.JSON(http.StatusOK, res) + c.JSON(http.StatusOK, uploadInitResponse(res)) } } -type heartbeatReq struct { - SessionUUID string `json:"sessionUuid" binding:"required"` +func uploadInitResponse(res *service.UploadInitResult) dto.UploadInitResponse { + presigned := make([]dto.UploadPresignedItem, 0, len(res.PresignedURLs)) + for _, p := range res.PresignedURLs { + presigned = append(presigned, dto.UploadPresignedItem{ + ChunkIndex: p.ChunkIndex, + ChunkSize: p.ChunkSize, + URL: p.URL, + }) + } + return dto.UploadInitResponse{ + UploadUUID: res.UploadUUID, + SessionUUID: res.SessionUUID, + MinioUploadID: res.MinioUploadID, + ChunkCount: res.ChunkCount, + ChunkSize: res.ChunkSize, + PresignedURLs: presigned, + } } func heartbeat(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - var req heartbeatReq + uid := middleware.UIDFrom(c) + var req dto.UploadHeartbeatRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusOK, gin.H{"ack": false}) + c.JSON(http.StatusOK, dto.UploadHeartbeatResponse{Ack: false}) return } - c.JSON(http.StatusOK, gin.H{"ack": d.Upload.Heartbeat(c.Request.Context(), req.SessionUUID, uid)}) + c.JSON(http.StatusOK, dto.UploadHeartbeatResponse{ + Ack: d.Upload.Heartbeat(c.Request.Context(), req.SessionUUID, uid), + }) } } -type uploadPartReq struct { - PartNumber int `json:"PartNumber"` - ETag string `json:"ETag"` -} - -type completeReq struct { - Parts []uploadPartReq `json:"parts" binding:"required"` -} - func completeUpload(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - var req completeReq + uid := middleware.UIDFrom(c) + var req dto.UploadCompleteRequest if err := c.ShouldBindJSON(&req); err != nil { apiErr(c, http.StatusUnprocessableEntity, "请求参数不合法") return } - parts := make([]struct { - PartNumber int - ETag string - }, 0, len(req.Parts)) + parts := make([]service.UploadPart, 0, len(req.Parts)) for _, p := range req.Parts { - parts = append(parts, struct { - PartNumber int - ETag string - }{p.PartNumber, p.ETag}) + parts = append(parts, service.UploadPart{PartNumber: p.PartNumber, ETag: p.ETag}) } res, err := d.Upload.CompleteUpload(c.Request.Context(), c.Param("upload_uuid"), parts, uid) if err != nil { apiErr(c, http.StatusBadRequest, err.Error()) return } - c.JSON(http.StatusOK, res) + c.JSON(http.StatusOK, dto.UploadCompleteResponse{ + UploadUUID: res.UploadUUID, + ETag: res.ETag, + Status: res.Status, + }) } } func resumeUpload(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) res, err := d.Upload.ResumeUpload(c.Request.Context(), c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusBadRequest, err.Error()) return } - c.JSON(http.StatusOK, res) + pending := make([]dto.UploadResumeChunk, 0, len(res.PendingChunks)) + for _, p := range res.PendingChunks { + pending = append(pending, dto.UploadResumeChunk{ + ChunkIndex: p.ChunkIndex, + ChunkSize: p.ChunkSize, + URL: p.URL, + }) + } + c.JSON(http.StatusOK, dto.UploadResumeResponse{ + UploadUUID: res.UploadUUID, + MinioUploadID: res.MinioUploadID, + PendingChunks: pending, + }) } } @@ -166,10 +171,7 @@ func resumeUpload(d Deps) gin.HandlerFunc { func listFolders(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) rows, err := d.Folders.ListByUID(d.DB, uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -187,13 +189,13 @@ func listFolders(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, gin.H{"folders": buildFolderTree(rows, nil)}) + c.JSON(http.StatusOK, dto.FolderTreeResponse{Folders: buildFolderTree(rows, nil)}) } } -// buildFolderTree assembles the FolderTreeItem forest from a flat list. -func buildFolderTree(rows []model.CloudFolder, parent *int64) []gin.H { - items := make([]gin.H, 0) +// buildFolderTree assembles the FolderItem forest from a flat list. +func buildFolderTree(rows []model.CloudFolder, parent *int64) []dto.FolderItem { + items := make([]dto.FolderItem, 0) for i := range rows { row := rows[i] // match parent: both nil (root) or equal ids @@ -207,30 +209,21 @@ func buildFolderTree(rows []model.CloudFolder, parent *int64) []gin.H { if row.VideoCount != nil { count = *row.VideoCount } - item := gin.H{ - "id": row.ID, - "parent_id": row.ParentID, - "name": row.Name, - "video_count": count, - "children": buildFolderTree(rows, &row.ID), - } - items = append(items, item) + items = append(items, dto.FolderItem{ + ID: row.ID, + ParentID: row.ParentID, + Name: row.Name, + VideoCount: count, + Children: buildFolderTree(rows, &row.ID), + }) } return items } -type folderCreateReq struct { - ParentID *int64 `json:"parentId"` - Name string `json:"name" binding:"required"` -} - func createFolder(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - var req folderCreateReq + uid := middleware.UIDFrom(c) + var req dto.FolderCreateRequest if err := c.ShouldBindJSON(&req); err != nil { apiErr(c, http.StatusUnprocessableEntity, "请求参数不合法") return @@ -251,48 +244,38 @@ func createFolder(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - count := 0 - c.JSON(http.StatusCreated, gin.H{ - "id": row.ID, "parent_id": row.ParentID, "name": row.Name, "video_count": count, + c.JSON(http.StatusCreated, dto.FolderResponse{ + ID: row.ID, ParentID: row.ParentID, Name: row.Name, VideoCount: 0, }) } } -type folderUpdateReq struct { - Name *string `json:"name"` - ParentID *int64 `json:"parentId"` - SetParent bool `json:"-"` // presence tracked via raw map below -} - func updateFolder(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) folderID, ok2 := parseID(c) if !ok2 { return } - var body map[string]any - if err := c.ShouldBindJSON(&body); err != nil { + var req dto.FolderUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { apiErr(c, http.StatusUnprocessableEntity, "请求参数不合法") return } + if req.ParentInvalid { + apiErr(c, http.StatusUnprocessableEntity, "parentId 不合法") + return + } fields := map[string]any{} - if v, present := body["name"]; present && v != nil { - fields["name"] = v + if req.NameSet && req.Name != nil { + fields["name"] = *req.Name } - if v, present := body["parentId"]; present { + if req.ParentSet { // explicit null moves to root; otherwise validate the target - if v == nil { + if req.ParentID == nil { fields["parent_id"] = nil } else { - pid, ok := toInt64(v) - if !ok { - apiErr(c, http.StatusUnprocessableEntity, "parentId 不合法") - return - } + pid := *req.ParentID if pid == folderID { apiErr(c, http.StatusBadRequest, "不能将文件夹移动到自己") return @@ -322,18 +305,15 @@ func updateFolder(d Deps) gin.HandlerFunc { if folder.VideoCount != nil { count = *folder.VideoCount } - c.JSON(http.StatusOK, gin.H{ - "id": folder.ID, "parent_id": folder.ParentID, "name": folder.Name, "video_count": count, + c.JSON(http.StatusOK, dto.FolderResponse{ + ID: folder.ID, ParentID: folder.ParentID, Name: folder.Name, VideoCount: count, }) } } func deleteFolder(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) folderID, ok := parseID(c) if !ok { return @@ -355,14 +335,13 @@ func deleteFolder(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - // 1.0.16: a force-deleted subtree's folder shares are dropped - // (RevokeAllForFolder — the subtree ids cover shares pointing at any - // level). Soft deletes stay restorable and are NOT revoked; their - // shares simply resolve uniformly as 404 until restore. + // A force-deleted subtree's folder shares are dropped (the subtree ids + // cover shares pointing at any level). Soft deletes stay restorable and + // are NOT revoked; their shares resolve uniformly as 404 until restore. if force { _ = d.Share.RevokeAllForFolders(ids) } - c.JSON(http.StatusOK, gin.H{"deleted": true, "affectedFiles": affected}) + c.JSON(http.StatusOK, dto.FolderDeleteResponse{Deleted: true, AffectedFiles: affected}) } } @@ -370,10 +349,7 @@ func deleteFolder(d Deps) gin.HandlerFunc { func listVideos(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) var folderID *int64 if v := c.Query("folderId"); v != "" { id, err := parseInt64(v) @@ -391,36 +367,22 @@ func listVideos(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - videos := make([]videoItem, 0, len(rows)) + videos := make([]dto.VideoItem, 0, len(rows)) for i := range rows { videos = append(videos, toVideoItem(&rows[i])) } - c.JSON(http.StatusOK, gin.H{ - "videos": videos, - "total": total, - "page": page, - "pageSize": pageSize, - "hasMore": int64(page*pageSize) < total, + c.JSON(http.StatusOK, dto.VideoListResponse{ + Videos: videos, + Total: total, + Page: page, + PageSize: pageSize, + HasMore: int64(page*pageSize) < total, }) } } -// videoItem — list shape (VideoItem in app/response/cloud.py). -type videoItem struct { - UploadUUID string `json:"upload_uuid"` - OriginalName string `json:"original_name"` - FileSize int64 `json:"file_size"` - MimeType string `json:"mime_type"` - Duration *int `json:"duration"` - AsrStatus string `json:"asr_status"` - VectorStatus string `json:"vector_status"` - Title *string `json:"title"` - CoverURL *string `json:"cover_url"` - CreatedAt *time.Time `json:"createdAt"` -} - -func toVideoItem(f *model.CloudFile) videoItem { - item := videoItem{ +func toVideoItem(f *model.CloudFile) dto.VideoItem { + return dto.VideoItem{ UploadUUID: f.UploadUUID, OriginalName: f.OriginalName, FileSize: f.FileSize, @@ -432,7 +394,6 @@ func toVideoItem(f *model.CloudFile) videoItem { CoverURL: f.CoverURL, CreatedAt: f.CreatedAt, } - return item } func statusOrDefault(s *string) string { @@ -444,10 +405,7 @@ func statusOrDefault(s *string) string { func getVideoDetail(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -465,79 +423,77 @@ func getVideoDetail(d Deps) gin.HandlerFunc { resp := toVideoDetail(f) if f.FolderID != nil { if folder, err := d.Folders.GetByID(d.DB, *f.FolderID, uid); err == nil && folder != nil { - resp["folderName"] = folder.Name + resp.FolderName = folder.Name } } if asr := d.ASRPreview(c.Request.Context(), f.UploadUUID); asr != "" { - resp["asrPreview"] = asr[:minStr(len(asr), 500)] + preview := asr[:minStr(len(asr), 500)] + resp.AsrPreview = &preview } c.JSON(http.StatusOK, resp) } } -func toVideoDetail(f *model.CloudFile) gin.H { - resp := gin.H{ - "upload_uuid": f.UploadUUID, - "original_name": f.OriginalName, - "file_size": f.FileSize, - "mime_type": f.MimeType, - "duration": f.Duration, - "asr_status": statusOrDefault(f.AsrStatus), - "vector_status": statusOrDefault(f.VectorStatus), - "title": f.Title, - "cover_url": f.CoverURL, - "created_at": f.CreatedAt, - "description": f.Description, - "tags": f.Tags, - "folder_id": f.FolderID, - "folderName": "", - "asrPreview": nil, - "vector_chunk_count": intFromPtr(f.VectorChunkCount), +func toVideoDetail(f *model.CloudFile) dto.VideoDetailResponse { + return dto.VideoDetailResponse{ + UploadUUID: f.UploadUUID, + OriginalName: f.OriginalName, + FileSize: f.FileSize, + MimeType: f.MimeType, + Duration: f.Duration, + AsrStatus: statusOrDefault(f.AsrStatus), + VectorStatus: statusOrDefault(f.VectorStatus), + Title: f.Title, + CoverURL: f.CoverURL, + CreatedAt: f.CreatedAt, + Description: f.Description, + Tags: f.Tags, + FolderID: f.FolderID, + FolderName: "", + AsrPreview: nil, + VectorChunkCount: intFromPtr(f.VectorChunkCount), } - return resp -} - -type videoUpdateReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Tags *[]string `json:"tags"` - FolderID **int64 `json:"-"` // handled via raw map } func updateVideo(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { + uid := middleware.UIDFrom(c) + var req dto.VideoUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + apiErr(c, http.StatusUnprocessableEntity, "请求参数不合法") return } - var body map[string]any - if err := c.ShouldBindJSON(&body); err != nil { - apiErr(c, http.StatusUnprocessableEntity, "请求参数不合法") + if req.TagsInvalid { + apiErr(c, http.StatusUnprocessableEntity, "tags 不合法") + return + } + if req.FolderInvalid { + apiErr(c, http.StatusUnprocessableEntity, "folderId 不合法") return } fields := map[string]any{} - for _, key := range []string{"title", "description"} { - if v, present := body[key]; present { - fields[key] = v + if req.TitleSet { + if req.Title == nil { + fields["title"] = nil + } else { + fields["title"] = *req.Title } } - if v, present := body["tags"]; present && v != nil { - tags, ok := toStringList(v) - if !ok { - apiErr(c, http.StatusUnprocessableEntity, "tags 不合法") - return + if req.DescriptionSet { + if req.Description == nil { + fields["description"] = nil + } else { + fields["description"] = *req.Description } - fields["tags"] = tags } - if v, present := body["folderId"]; present { - if v == nil { + if req.TagsSet { + fields["tags"] = req.Tags + } + if req.FolderSet { + if req.FolderID == nil { fields["folder_id"] = nil } else { - fid, ok := toInt64(v) - if !ok { - apiErr(c, http.StatusUnprocessableEntity, "folderId 不合法") - return - } + fid := *req.FolderID folder, err := d.Folders.GetByID(d.DB, fid, uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -562,7 +518,7 @@ func updateVideo(d Deps) gin.HandlerFunc { resp := toVideoDetail(f) if f.FolderID != nil { if folder, err := d.Folders.GetByID(d.DB, *f.FolderID, uid); err == nil && folder != nil { - resp["folderName"] = folder.Name + resp.FolderName = folder.Name } } c.JSON(http.StatusOK, resp) @@ -573,10 +529,7 @@ func updateVideo(d Deps) gin.HandlerFunc { // until the sweeper purges them (deleted_at + purge_after_days). func deleteVideo(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -592,27 +545,26 @@ func deleteVideo(d Deps) gin.HandlerFunc { } // A trashed file must not stay shareable. _ = d.Share.RevokeAllForFile(f.ID) - c.JSON(http.StatusOK, gin.H{"deleted": true, "uploadUuid": c.Param("upload_uuid")}) + c.JSON(http.StatusOK, dto.VideoDeleteResponse{ + Deleted: true, UploadUUID: c.Param("upload_uuid"), + }) } } -// triggerProcess — marks processing and returns; the pipeline runs in a +// triggerProcess — marks processing and returns; the pipeline runs in the // background goroutine (status updates arrive via the WS bridge). func triggerProcess(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - if d.Process == nil { - apiErr(c, http.StatusServiceUnavailable, "处理管线未启用") - return - } + uid := middleware.UIDFrom(c) if err := d.Process.Trigger(c.Request.Context(), c.Param("upload_uuid"), uid); err != nil { if errors.Is(err, service.ErrNotFound) { apiErr(c, http.StatusNotFound, "File not found") return } + if errors.Is(err, service.ErrAlreadyProcessing) || errors.Is(err, service.ErrAlreadyDone) { + apiErr(c, http.StatusConflict, err.Error()) + return + } if errors.Is(err, service.ErrTooManyUserTasks) || errors.Is(err, service.ErrQueueFull) { // Admission control (capacity layer): shed load as 429 with the // user-facing reason, never 500. @@ -622,40 +574,37 @@ func triggerProcess(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, gin.H{"uploadUuid": c.Param("upload_uuid")}) + c.JSON(http.StatusOK, dto.VideoProcessResponse{UploadUUID: c.Param("upload_uuid")}) } } // reprocess — delete old vectors → re-parse → re-vectorize. func reprocess(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - if d.Process == nil { - apiErr(c, http.StatusServiceUnavailable, "处理管线未启用") - return - } + uid := middleware.UIDFrom(c) taskID, err := d.Process.Reprocess(c.Request.Context(), c.Param("upload_uuid"), uid) if err != nil { - if err == service.ErrNotFound { + switch { + case errors.Is(err, service.ErrNotFound): apiErr(c, http.StatusNotFound, "File not found") - return + case errors.Is(err, service.ErrAlreadyProcessing): + apiErr(c, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrReprocessTooSoon): + apiErr(c, http.StatusTooManyRequests, err.Error()) + default: + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") } - apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, gin.H{"uploadUuid": c.Param("upload_uuid"), "taskId": taskID}) + c.JSON(http.StatusOK, dto.VideoReprocessResponse{ + UploadUUID: c.Param("upload_uuid"), TaskID: taskID, + }) } } func getVideoStatus(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -686,21 +635,18 @@ func getVideoStatus(d Deps) gin.HandlerFunc { } } } - c.JSON(http.StatusOK, gin.H{ - "asr_status": statusOrDefault(f.AsrStatus), - "asrProgress": 0, - "vector_status": statusOrDefault(f.VectorStatus), - "vectorChunkCount": count, + c.JSON(http.StatusOK, dto.VideoStatusResponse{ + AsrStatus: statusOrDefault(f.AsrStatus), + AsrProgress: 0, + VectorStatus: statusOrDefault(f.VectorStatus), + VectorChunkCount: count, }) } } func getDocumentPreview(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -716,22 +662,22 @@ func getDocumentPreview(d Deps) gin.HandlerFunc { preview, total, docMeta := d.DocumentPreview(c.Request.Context(), uid, f.UploadUUID, offset, limit) nextOffset := offset + len(preview) hasMore := nextOffset < total - var next any + var next *int if hasMore { - next = nextOffset - } - c.JSON(http.StatusOK, gin.H{ - "upload_uuid": f.UploadUUID, - "fileName": f.OriginalName, - "mime_type": f.MimeType, - "vectorizable": f.Vectorizable, - "preview": preview, - "docMeta": docMeta, - "offset": offset, - "limit": limit, - "totalChars": total, - "hasMore": hasMore, - "nextOffset": next, + next = &nextOffset + } + c.JSON(http.StatusOK, dto.DocumentPreviewResponse{ + UploadUUID: f.UploadUUID, + FileName: f.OriginalName, + MimeType: f.MimeType, + Vectorizable: f.Vectorizable, + Preview: preview, + DocMeta: docMeta, + Offset: offset, + Limit: limit, + TotalChars: total, + HasMore: hasMore, + NextOffset: next, }) } } @@ -739,10 +685,7 @@ func getDocumentPreview(d Deps) gin.HandlerFunc { // getVideoRaw — presigned GET (+ inline text content for text-like files). func getVideoRaw(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -771,13 +714,13 @@ func getVideoRaw(d Deps) gin.HandlerFunc { content = &s } } - c.JSON(http.StatusOK, gin.H{ - "url": url, - "content": content, - "mimeType": f.MimeType, - "fileName": f.OriginalName, - "fileSize": f.FileSize, - "viewMode": viewMode, + c.JSON(http.StatusOK, dto.RawFileResponse{ + URL: url, + Content: content, + MimeType: f.MimeType, + FileName: f.OriginalName, + FileSize: f.FileSize, + ViewMode: viewMode, }) } } @@ -811,31 +754,24 @@ func classifyViewMode(mime string) string { func getQuota(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) used, quota, tier, pending, err := d.Quota.Usage(c.Request.Context(), uid) if err != nil { // Still answer with the fail-closed values + error flag. - c.JSON(http.StatusOK, gin.H{ - "used": used, "quota": quota, "tier": tier, "pending": pending, - "error": err.Error(), + c.JSON(http.StatusOK, dto.QuotaResponse{ + Used: used, Quota: quota, Tier: tier, Pending: pending, Error: err.Error(), }) return } - c.JSON(http.StatusOK, gin.H{ - "used": used, "quota": quota, "tier": tier, "pending": pending, + c.JSON(http.StatusOK, dto.QuotaResponse{ + Used: used, Quota: quota, Tier: tier, Pending: pending, }) } } func searchFiles(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) q := trim(c.Query("q")) if q == "" { apiErr(c, http.StatusUnprocessableEntity, "缺少搜索关键词 q") @@ -846,11 +782,11 @@ func searchFiles(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - items := make([]videoItem, 0, len(rows)) + items := make([]dto.VideoItem, 0, len(rows)) for i := range rows { items = append(items, toVideoItem(&rows[i])) } - c.JSON(http.StatusOK, gin.H{"results": items, "total": len(items)}) + c.JSON(http.StatusOK, dto.SearchFilesResponse{Results: items, Total: len(items)}) } } @@ -858,57 +794,50 @@ func searchFiles(d Deps) gin.HandlerFunc { func listTrash(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) rows, err := d.Files.ListTrash(d.DB, uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - items := make([]gin.H, 0, len(rows)) + items := make([]dto.TrashItem, 0, len(rows)) for i := range rows { f := &rows[i] - purgeAt := timeOrNil(nil) + var purgeAt *time.Time if f.DeletedAt != nil { purge := f.DeletedAt.Add(time.Duration(d.Cfg.Trash.PurgeAfterDays) * 24 * time.Hour) purgeAt = &purge } - items = append(items, gin.H{ - "upload_uuid": f.UploadUUID, - "original_name": f.OriginalName, - "file_size": f.FileSize, - "mime_type": f.MimeType, - "deleted_at": f.DeletedAt, - "purge_at": purgeAt, + items = append(items, dto.TrashItem{ + UploadUUID: f.UploadUUID, + OriginalName: f.OriginalName, + FileSize: f.FileSize, + MimeType: f.MimeType, + DeletedAt: f.DeletedAt, + PurgeAt: purgeAt, }) } - c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) + c.JSON(http.StatusOK, dto.TrashListResponse{Items: items, Total: len(items)}) } } func restoreTrash(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) if err := d.Files.Restore(d.DB, c.Param("upload_uuid"), uid); err != nil { apiErr(c, http.StatusNotFound, "文件不在回收站中") return } - c.JSON(http.StatusOK, gin.H{"restored": true, "upload_uuid": c.Param("upload_uuid")}) + c.JSON(http.StatusOK, dto.TrashRestoreResponse{ + Restored: true, UploadUUID: c.Param("upload_uuid"), + }) } } // purgeTrashItem — immediate physical delete (MinIO + pipeline stores). func purgeTrashItem(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) if err := d.Purge.PurgeFile(c.Request.Context(), c.Param("upload_uuid"), uid); err != nil { if err == service.ErrNotFound { apiErr(c, http.StatusNotFound, "文件不在回收站中") @@ -917,22 +846,21 @@ func purgeTrashItem(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, gin.H{"purged": true, "upload_uuid": c.Param("upload_uuid")}) + c.JSON(http.StatusOK, dto.TrashPurgeResponse{ + Purged: true, UploadUUID: c.Param("upload_uuid"), + }) } } func emptyTrash(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) count, err := d.Purge.PurgeAllForUser(c.Request.Context(), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, gin.H{"purged": count}) + c.JSON(http.StatusOK, dto.EmptyTrashResponse{Purged: count}) } } @@ -948,45 +876,7 @@ func parseID(c *gin.Context) (int64, bool) { } func parseInt64(s string) (int64, error) { - var v int64 - var err error - v, err = strconv.ParseInt(trim(s), 10, 64) - return v, err -} - -func toStringList(v any) ([]string, bool) { - switch t := v.(type) { - case []string: - return t, true - case []any: - out := make([]string, 0, len(t)) - for _, item := range t { - s, ok := item.(string) - if !ok { - return nil, false - } - out = append(out, s) - } - return out, true - default: - return nil, false - } -} - -func toInt64(v any) (int64, bool) { - switch n := v.(type) { - case float64: - return int64(n), true - case string: - id, err := strconv.ParseInt(n, 10, 64) - return id, err == nil - case int64: - return n, true - case int: - return int64(n), true - default: - return 0, false - } + return strconv.ParseInt(trim(s), 10, 64) } func clampInt(s string, lo, hi int) int { @@ -1010,8 +900,6 @@ func intFromPtr(p *int) int { return *p } -func timeOrNil(t *time.Time) *time.Time { return t } - func trim(s string) string { return strings.TrimSpace(s) } func lower(s string) string { return strings.ToLower(s) } func hasPrefix(s, p string) bool { return strings.HasPrefix(s, p) } diff --git a/app-go/app-cloud/internal/router/cloud_ai.go b/app-go/app-cloud/internal/router/cloud_ai.go new file mode 100644 index 00000000..22f06dbf --- /dev/null +++ b/app-go/app-cloud/internal/router/cloud_ai.go @@ -0,0 +1,215 @@ +package router + +// Cloud document intelligence endpoints: per-file summary (owner + M2M), +// folder-scoped semantic search and similar files. +// +// The owner endpoints read the gateway-injected X-Uid like every other +// /cloud/* route. The /internal/cloud/{summary,similar} endpoints exist for +// service-to-service use (app-ts tools) and authenticate with the shared +// internal.api_key instead of a user session; an unconfigured key fails +// closed. The legacy /internal/cloud/reconcile endpoint keeps its own +// deployment posture and is deliberately not covered here. + +import ( + "errors" + "net/http" + + "app-cloud/internal/dto" + "app-cloud/internal/middleware" + "app-cloud/internal/service" + + "github.com/gin-gonic/gin" +) + +// registerCloudAI mounts the owner-facing document intelligence endpoints on +// the /cloud group. +func registerCloudAI(g *gin.RouterGroup, d Deps) { + g.GET("/video/:upload_uuid/summary", middleware.RequireCapability(d.Summary != nil, "摘要服务暂不可用"), getVideoSummary(d)) + g.GET("/video/:upload_uuid/similar", middleware.RequireCapability(d.Search != nil, "语义检索暂不可用"), getSimilarFiles(d)) + g.GET("/search/semantic", middleware.RequireCapability(d.Search != nil, "语义检索暂不可用"), searchSemantic(d)) +} + +// registerInternalCloudAI mounts the machine-to-machine endpoints. +func registerInternalCloudAI(e *gin.Engine, d Deps) { + internal := e.Group("/internal/cloud") + internal.Use(middleware.RequireInternalKey(d.Cfg), middleware.RequireInternalUID()) + internal.GET("/summary", middleware.RequireCapability(d.Summary != nil, "摘要服务暂不可用"), internalSummary(d)) + internal.GET("/similar", middleware.RequireCapability(d.Search != nil, "语义检索暂不可用"), internalSimilar(d)) +} + +// ── owner-facing ───────────────────────────────────────────────────── + +// getVideoSummary — GET /cloud/video/{uuid}/summary +func getVideoSummary(d Deps) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + writeSummary(c, d, uid, c.Param("upload_uuid")) + } +} + +// searchSemantic — GET /cloud/search/semantic?q=&folderId=&limit= +func searchSemantic(d Deps) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + q := trim(c.Query("q")) + if q == "" { + apiErr(c, http.StatusUnprocessableEntity, "缺少搜索关键词 q") + return + } + var folderID *int64 + if raw := trim(c.Query("folderId")); raw != "" { + id, err := parseInt64(raw) + if err != nil || id <= 0 { + apiErr(c, http.StatusUnprocessableEntity, "folderId 不合法") + return + } + folderID = &id + } + limit := clampInt(c.DefaultQuery("limit", "10"), 1, 20) + results, err := d.Search.SearchSemantic(c.Request.Context(), uid, q, folderID, limit) + if err != nil { + if errors.Is(err, service.ErrSemanticUnavailable) { + apiErr(c, http.StatusServiceUnavailable, "语义检索暂不可用") + return + } + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") + return + } + hits := semanticHits(results) + c.JSON(http.StatusOK, dto.SemanticSearchResponse{ + Mode: "semantic", + Results: hits, + Total: len(hits), + }) + } +} + +// getSimilarFiles — GET /cloud/video/{uuid}/similar?limit= +func getSimilarFiles(d Deps) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + limit := clampInt(c.DefaultQuery("limit", "5"), 1, 10) + results, err := d.Search.Similar(c.Request.Context(), uid, c.Param("upload_uuid"), limit) + if err != nil { + switch { + case errors.Is(err, service.ErrNotFound): + apiErr(c, http.StatusNotFound, "File not found") + case errors.Is(err, service.ErrSemanticUnavailable): + apiErr(c, http.StatusServiceUnavailable, "语义检索暂不可用") + default: + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") + } + return + } + c.JSON(http.StatusOK, dto.SimilarResponse{Items: semanticHits(results)}) + } +} + +// ── machine-to-machine ─────────────────────────────────────────────── + +// internalSummary — GET /internal/cloud/summary?uid=&upload_uuid= +func internalSummary(d Deps) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + uploadUUID := trim(c.Query("upload_uuid")) + if uploadUUID == "" { + apiErr(c, http.StatusUnprocessableEntity, "缺少 upload_uuid 参数") + return + } + writeSummary(c, d, uid, uploadUUID) + } +} + +// internalSimilar — GET /internal/cloud/similar?uid=&upload_uuid=&limit= +func internalSimilar(d Deps) gin.HandlerFunc { + return func(c *gin.Context) { + uid := middleware.UIDFrom(c) + uploadUUID := trim(c.Query("upload_uuid")) + if uploadUUID == "" { + apiErr(c, http.StatusUnprocessableEntity, "缺少 upload_uuid 参数") + return + } + limit := clampInt(c.DefaultQuery("limit", "5"), 1, 10) + results, err := d.Search.Similar(c.Request.Context(), uid, uploadUUID, limit) + if err != nil { + switch { + case errors.Is(err, service.ErrNotFound): + apiErr(c, http.StatusNotFound, "File not found") + case errors.Is(err, service.ErrSemanticUnavailable): + apiErr(c, http.StatusServiceUnavailable, "语义检索暂不可用") + default: + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") + } + return + } + c.JSON(http.StatusOK, dto.SimilarResponse{Items: semanticHits(results)}) + } +} + +// ── shared helpers ─────────────────────────────────────────────────── + +// writeSummary answers the summary payload (available / stale / model / +// summary) for one owner-scoped file. +func writeSummary(c *gin.Context, d Deps, uid int64, uploadUUID string) { + view, err := d.Summary.Get(c.Request.Context(), uid, uploadUUID) + if err != nil { + if errors.Is(err, service.ErrNotFound) { + apiErr(c, http.StatusNotFound, "File not found") + return + } + apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") + return + } + c.JSON(http.StatusOK, summaryResponse(view)) +} + +// summaryResponse maps the service read model onto the wire DTO. +func summaryResponse(view *service.SummaryView) dto.SummaryResponse { + resp := dto.SummaryResponse{ + UploadUUID: view.UploadUUID, + OriginalName: view.OriginalName, + Title: view.Title, + Available: view.Available, + Stale: view.Stale, + Model: view.Model, + } + if view.Summary == nil { + return resp + } + resp.Summary = &dto.Summary{ + Overview: view.Summary.Overview, + Topics: stringsOrEmpty(view.Summary.Topics), + Entities: stringsOrEmpty(view.Summary.Entities), + KeyPoints: stringsOrEmpty(view.Summary.KeyPoints), + Todos: stringsOrEmpty(view.Summary.Todos), + AnswersQuestions: stringsOrEmpty(view.Summary.AnswersQuestions), + Language: view.Summary.Language, + SizeChars: view.Summary.SizeChars, + } + return resp +} + +// semanticHits maps ranked service results onto the wire DTO. +func semanticHits(results []service.SemanticResult) []dto.SemanticHit { + hits := make([]dto.SemanticHit, 0, len(results)) + for _, r := range results { + hits = append(hits, dto.SemanticHit{ + UploadUUID: r.UploadUUID, + OriginalName: r.OriginalName, + Title: r.Title, + MimeType: r.MimeType, + FileSize: r.FileSize, + Score: r.Score, + Snippets: stringsOrEmpty(r.Snippets), + }) + } + return hits +} + +// stringsOrEmpty keeps list fields as [] instead of null on the wire. +func stringsOrEmpty(in []string) []string { + if in == nil { + return []string{} + } + return in +} diff --git a/app-go/app-cloud/internal/router/ref.go b/app-go/app-cloud/internal/router/ref.go index 40b6df3b..6eb9ddb4 100644 --- a/app-go/app-cloud/internal/router/ref.go +++ b/app-go/app-cloud/internal/router/ref.go @@ -5,14 +5,16 @@ import ( "net/http" "strconv" + "app-cloud/internal/dto" + "app-cloud/internal/middleware" "app-cloud/internal/service" "github.com/gin-gonic/gin" ) -// registerRefRoutes — the /cloud/refs face (plan/1.0.16 §4-refs). All -// endpoints are X-Uid authenticated; saving does NOT need attest (it is a -// logged-in action, unlike the public share access face). +// registerRefRoutes — the /cloud/refs face. All endpoints are X-Uid +// authenticated; saving does NOT need attest (it is a logged-in action, unlike +// the public share access face). func registerRefRoutes(cloudGroup *gin.RouterGroup, d Deps) { cloudGroup.POST("/refs", saveRef(d)) cloudGroup.GET("/refs", listRefs(d)) @@ -25,15 +27,8 @@ func registerRefRoutes(cloudGroup *gin.RouterGroup, d Deps) { // → {saved: number, rootFolderId?: number} func saveRef(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - var body struct { - Token string `json:"token" binding:"required"` - Code string `json:"code"` - TargetFolderID *int64 `json:"targetFolderId"` - } + uid := middleware.UIDFrom(c) + var body dto.SaveRefRequest if err := c.ShouldBindJSON(&body); err != nil { apiErr(c, http.StatusUnprocessableEntity, "参数不合法") return @@ -43,17 +38,17 @@ func saveRef(d Deps) gin.HandlerFunc { mapRefErr(c, err) return } - c.JSON(http.StatusOK, res) + c.JSON(http.StatusOK, dto.SaveRefResponse{ + Saved: res.Saved, + RootFolderID: res.RootFolderID, + }) } } // listRefs — GET /cloud/refs?folderId= → {refs, total} (folderId 缺省 = 根). func listRefs(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) var folderID *int64 if v := c.Query("folderId"); v != "" { id, err := strconv.ParseInt(v, 10, 64) @@ -68,17 +63,30 @@ func listRefs(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, res) + refs := make([]dto.RefItem, 0, len(res.Refs)) + for _, r := range res.Refs { + refs = append(refs, dto.RefItem{ + ID: r.ID, + Name: r.Name, + Size: r.Size, + MimeType: r.MimeType, + FolderID: r.FolderID, + Status: r.Status, + ShareID: r.ShareID, + SourceUploadUUID: r.SourceUploadUUID, + SourceName: r.SourceName, + CreatedAt: r.CreatedAt, + IsRef: true, + }) + } + c.JSON(http.StatusOK, dto.RefListResponse{Refs: refs, Total: res.Total}) } } // deleteRef — DELETE /cloud/refs/{id} (uid-checked soft delete). func deleteRef(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, err := strconv.ParseInt(c.Param("ref_id"), 10, 64) if err != nil || id <= 0 { apiErr(c, http.StatusUnprocessableEntity, "ref_id 不合法") @@ -88,7 +96,7 @@ func deleteRef(d Deps) gin.HandlerFunc { apiErr(c, http.StatusNotFound, "引用不存在") return } - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, dto.RefDeleteResponse{Deleted: true}) } } @@ -97,26 +105,30 @@ func deleteRef(d Deps) gin.HandlerFunc { // read uniformly as 404「引用已失效」 and are flipped dead in the same pass. func rawRef(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, err := strconv.ParseInt(c.Param("ref_id"), 10, 64) if err != nil || id <= 0 { apiErr(c, http.StatusUnprocessableEntity, "ref_id 不合法") return } download := c.Query("download") == "1" || c.Query("download") == "true" - res, err := d.Ref.RawRef(c.Request.Context(), uid, id, download) + grant, err := d.Ref.RawRef(c.Request.Context(), uid, id, download) if err != nil { mapRefErr(c, err) return } - c.JSON(http.StatusOK, res) + c.JSON(http.StatusOK, dto.RefRawResponse{ + URL: grant.URL, + ViewMode: grant.ViewMode, + MimeType: grant.MimeType, + FileName: grant.FileName, + FileSize: grant.FileSize, + ExpiresIn: grant.ExpiresIn, + }) } } -// mapRefErr — uniform ref error mapping (plan/1.0.16 §4-refs: 失效统一 404). +// mapRefErr — uniform ref error mapping (失效统一 404). func mapRefErr(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrRefDead): diff --git a/app-go/app-cloud/internal/router/router.go b/app-go/app-cloud/internal/router/router.go index 9da3f84d..9cf771dd 100644 --- a/app-go/app-cloud/internal/router/router.go +++ b/app-go/app-cloud/internal/router/router.go @@ -7,15 +7,14 @@ package router import ( "context" "net/http" - "strconv" - "strings" - "time" "app-cloud/internal/config" + "app-cloud/internal/dto" "app-cloud/internal/infra/minio" "app-cloud/internal/infra/mongostore" "app-cloud/internal/logger" "app-cloud/internal/metrics" + "app-cloud/internal/middleware" "app-cloud/internal/repo" "app-cloud/internal/service" @@ -39,6 +38,10 @@ type Deps struct { Ref *service.RefService // 1.0.16 — "save to my drive" references Attestor *service.DeviceAttestor Reconciler *service.Reconciler + // Summary / Search serve the document-intelligence read endpoints; nil + // means the corresponding capability is unavailable (503). + Summary *service.SummaryService + Search *service.SearchService } // ASRPreview proxies the Mongo store (nil-safe). @@ -56,87 +59,21 @@ func New(d Deps) *gin.Engine { gin.SetMode(gin.ReleaseMode) e := gin.New() e.SetTrustedProxies(nil) - e.Use(logger.GinRecovery(), logger.GinLogger(), securityHeaders(), cors(d.Cfg)) + e.Use(logger.GinRecovery(), logger.GinLogger(), middleware.SecurityHeaders(), middleware.CORS(d.Cfg)) e.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "app-cloud"}) + c.JSON(http.StatusOK, dto.HealthResponse{Status: "ok", Service: "app-cloud"}) }) // Prometheus exposition — cluster-internal only (nginx routes no /metrics). e.GET("/metrics", gin.WrapH(metrics.Handler())) - e.Use(metricsMiddleware()) + e.Use(middleware.Metrics()) registerCloud(e, d) registerWorkspaces(e, d) + registerInternalCloudAI(e, d) return e } -// currentUID reads the gateway-injected X-Uid (nginx auth_request → app-auth -// verify). Missing/invalid → 401. Client-supplied X-Uid is overwritten by -// nginx (auth_request_set), never trusted from the wire. -func currentUID(c *gin.Context) (int64, bool) { - raw := strings.TrimSpace(c.Request.Header.Get("X-Uid")) - if raw == "" { - apiErr(c, http.StatusUnauthorized, "未提供认证 token") - return 0, false - } - uid, err := strconv.ParseInt(raw, 10, 64) - if err != nil || uid <= 0 { - apiErr(c, http.StatusUnauthorized, "token 无效或已过期") - return 0, false - } - return uid, true -} - -func securityHeaders() gin.HandlerFunc { - return func(c *gin.Context) { - c.Header("X-Content-Type-Options", "nosniff") - c.Header("X-Frame-Options", "SAMEORIGIN") // SAMEORIGIN: presigned previews render in-app - c.Header("Cache-Control", "no-store") - c.Next() - } -} - -func cors(cfg *config.Config) gin.HandlerFunc { - allowed := make(map[string]bool, len(cfg.CORS.AllowOrigins)) - for _, o := range cfg.CORS.AllowOrigins { - allowed[strings.TrimSpace(o)] = true - } - return func(c *gin.Context) { - origin := c.GetHeader("Origin") - if origin != "" && allowed[origin] { - c.Header("Access-Control-Allow-Origin", origin) - c.Header("Vary", "Origin") - c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, If-Match, X-Fp, X-Ticket") - } - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - return - } - c.Next() - } -} - func apiErr(c *gin.Context, status int, detail string) { - c.JSON(status, gin.H{"detail": detail}) -} - -// metricsMiddleware records the HTTP golden signals. The route label is the -// registered pattern (c.FullPath, e.g. /cloud/video/:upload_uuid) so uuid -// path params cannot explode label cardinality; unmatched paths collapse to -// UNMATCHED. -func metricsMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - c.Next() - route := c.FullPath() - if route == "" { - route = "UNMATCHED" - } - method := c.Request.Method - status := strconv.Itoa(c.Writer.Status()) - metrics.HTTPRequests.WithLabelValues(method, route, status).Inc() - metrics.HTTPDuration.WithLabelValues(method, route).Observe(time.Since(start).Seconds()) - } + c.JSON(status, dto.ErrorResponse{Detail: detail}) } diff --git a/app-go/app-cloud/internal/router/share.go b/app-go/app-cloud/internal/router/share.go index 061f2631..9f1a8856 100644 --- a/app-go/app-cloud/internal/router/share.go +++ b/app-go/app-cloud/internal/router/share.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "app-cloud/internal/dto" + "app-cloud/internal/middleware" "app-cloud/internal/model" "app-cloud/internal/service" @@ -33,7 +35,7 @@ func registerShareRoutes(e *gin.Engine, d Deps, cloudGroup *gin.RouterGroup) { pub.POST("/:token/access", shareAccess(d)) // ops endpoint: per-uid ledger vs MinIO drift report (loopback-only port; - // unauthenticated by design, see §2.14 deployment notes) + // unauthenticated by design). e.GET("/internal/cloud/reconcile", reconcileHandler(d)) // app-admin-only preview grant; the separate shared token and share/file // scope checks keep the admin review plane away from arbitrary cloud files. @@ -58,27 +60,25 @@ func reconcileHandler(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "对账失败: "+err.Error()) return } - c.JSON(http.StatusOK, rep) + c.JSON(http.StatusOK, dto.ReconcileResponse{ + UID: rep.UID, + DBActiveBytes: rep.DBActiveBytes, + DBActiveCount: rep.DBActiveCount, + DBTrashBytes: rep.DBTrashBytes, + MinioBytes: rep.MinioBytes, + MinioCount: rep.MinioCount, + OrphanObjects: rep.OrphanObjects, + OrphanSample: rep.OrphanSample, + MissingObjects: rep.MissingObjects, + DriftBytes: rep.DriftBytes, + }) } } -// shareCreateBody — file / folder share creation payload (1.0.16): the -// compliance ack is mandatory — legalAck:false/missing → 422. -type shareCreateBody struct { - Code string `json:"code"` - ExpiresInDays int `json:"expiresInDays"` - MaxDownloads *int `json:"maxDownloads"` - LegalAck bool `json:"legalAck"` - ContentReviewAck bool `json:"contentReviewAck"` -} - // createShare — POST /cloud/video/{uuid}/share func createShare(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -88,10 +88,10 @@ func createShare(d Deps) gin.HandlerFunc { apiErr(c, http.StatusNotFound, "File not found") return } - var body shareCreateBody + var body dto.ShareCreateRequest if err := c.ShouldBindJSON(&body); err != nil { // empty body is fine — defaults apply (but legalAck stays false → 422) - body = shareCreateBody{} + body = dto.ShareCreateRequest{} } share, err := d.Share.CreateShare(uid, f.ID, body.Code, body.ExpiresInDays, body.MaxDownloads, body.LegalAck, body.ContentReviewAck) if err != nil { @@ -119,17 +119,14 @@ func createShare(d Deps) gin.HandlerFunc { // same body/semantics as the file share, target = the caller's folder. func createFolderShare(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) folderID, ok := parseShareFolderID(c) if !ok { return } - var body shareCreateBody + var body dto.ShareCreateRequest if err := c.ShouldBindJSON(&body); err != nil { - body = shareCreateBody{} + body = dto.ShareCreateRequest{} } share, err := d.Share.CreateFolderShare(uid, folderID, body.Code, body.ExpiresInDays, body.MaxDownloads, body.LegalAck, body.ContentReviewAck) if err != nil { @@ -155,10 +152,7 @@ func createFolderShare(d Deps) gin.HandlerFunc { // listFolderShares — GET /cloud/folders/{folder_id}/shares (1.0.16). func listFolderShares(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) folderID, ok := parseShareFolderID(c) if !ok { return @@ -175,11 +169,7 @@ func listFolderShares(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - out := make([]gin.H, 0, len(rows)) - for i := range rows { - out = append(out, shareView(&rows[i], true, d.Share.ViewCode(&rows[i]))) - } - c.JSON(http.StatusOK, gin.H{"shares": out, "total": len(out)}) + c.JSON(http.StatusOK, shareListResponse(rows, d)) } } @@ -196,10 +186,7 @@ func parseShareFolderID(c *gin.Context) (int64, bool) { // listShares — GET /cloud/video/{uuid}/shares func listShares(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) f, err := d.Files.GetByUUID(d.DB, c.Param("upload_uuid"), uid) if err != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") @@ -214,24 +201,25 @@ func listShares(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - out := make([]gin.H, 0, len(rows)) - for i := range rows { - // withToken=true:属主列表必须带 share_token/share_url,否则前端 - // 重开弹窗只看得到统计数字,无法查看/复制之前生成的链接。 - // extraction_code:属主可见的明文提取码(legacy 哈希行不可逆,为空)。 - out = append(out, shareView(&rows[i], true, d.Share.ViewCode(&rows[i]))) - } - c.JSON(http.StatusOK, gin.H{"shares": out, "total": len(out)}) + c.JSON(http.StatusOK, shareListResponse(rows, d)) + } +} + +// shareListResponse maps owner share rows onto the wire shape. withToken is +// always true here: the owner list must show share_token/share_url or the +// dialog cannot reopen an existing link. +func shareListResponse(rows []model.CloudShare, d Deps) dto.ShareListResponse { + out := make([]dto.ShareView, 0, len(rows)) + for i := range rows { + out = append(out, shareView(&rows[i], true, d.Share.ViewCode(&rows[i]))) } + return dto.ShareListResponse{Shares: out, Total: len(out)} } // revokeShare — DELETE /cloud/shares/{id} func revokeShare(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, err := strconv.ParseInt(c.Param("share_id"), 10, 64) if err != nil { apiErr(c, http.StatusUnprocessableEntity, "share_id 不合法") @@ -241,15 +229,14 @@ func revokeShare(d Deps) gin.HandlerFunc { apiErr(c, http.StatusNotFound, "分享不存在") return } - c.JSON(http.StatusOK, gin.H{"revoked": true}) + c.JSON(http.StatusOK, dto.ShareRevokeResponse{Revoked: true}) } } // shareInfo — GET /cloud/s/{token} (PUBLIC): metadata + whether a code is -// required. Invalid shares read as 404 (no state enumeration). 1.0.16: the -// shape gains targetType; folder shares add folderName + files[] (all live -// members of the subtree, path-labeled, capped at SharedFilesLimit with -// truncated: true). +// required. Invalid shares read as 404 (no state enumeration). Folder shares +// add folderName + files[] (all live members of the subtree, path-labeled, +// capped at SharedFilesLimit with truncated: true). func shareInfo(d Deps) gin.HandlerFunc { return func(c *gin.Context) { sf, err := d.Share.ResolveShare(c.Param("token"), true) @@ -257,37 +244,42 @@ func shareInfo(d Deps) gin.HandlerFunc { apiErr(c, http.StatusNotFound, "分享不存在或已失效") return } - out := gin.H{ - "targetType": shareTargetType(sf.Share), - "requiresCode": sf.Share.ExtractionCode != nil, - "createdAt": sf.Share.CreatedAt, - "expiresAt": sf.Share.ExpiresAt, + challenge := d.Attestor.Issue(c.Param("token"), time.Now()) + out := dto.ShareInfoResponse{ + TargetType: shareTargetType(sf.Share), + RequiresCode: sf.Share.ExtractionCode != nil, + CreatedAt: sf.Share.CreatedAt, + ExpiresAt: sf.Share.ExpiresAt, // 浏览器设备证明挑战(签名 + token 绑定 + 10 分钟时效) - "challenge": d.Attestor.Issue(c.Param("token"), time.Now()), + Challenge: challenge, } if sf.Folder != nil { - out["folderName"] = sf.Folder.Name + folderName := sf.Folder.Name + out.FolderName = &folderName entries, truncated, ferr := d.Share.SharedFolderFiles(sf.Share, service.SharedFilesLimit) if ferr != nil { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - files := make([]gin.H, 0, len(entries)) + files := make([]dto.SharePublicFile, 0, len(entries)) for i := range entries { - files = append(files, gin.H{ - "fileId": entries[i].File.ID, - "name": entries[i].File.OriginalName, - "size": entries[i].File.FileSize, - "mimeType": entries[i].File.MimeType, - "path": entries[i].Path, + files = append(files, dto.SharePublicFile{ + FileID: entries[i].File.ID, + Name: entries[i].File.OriginalName, + Size: entries[i].File.FileSize, + MimeType: entries[i].File.MimeType, + Path: entries[i].Path, }) } - out["files"] = files - out["truncated"] = truncated + out.Files = &files + out.Truncated = &truncated } else if sf.File != nil { - out["fileName"] = sf.File.OriginalName - out["fileSize"] = sf.File.FileSize - out["mimeType"] = sf.File.MimeType + name := sf.File.OriginalName + size := sf.File.FileSize + mime := sf.File.MimeType + out.FileName = &name + out.FileSize = &size + out.MimeType = &mime } c.JSON(http.StatusOK, out) } @@ -307,10 +299,7 @@ func shareTargetType(sh *model.CloudShare) string { // 环境,无法自然通过此流程。 func shareAttest(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - var body struct { - Challenge string `json:"challenge"` - Fingerprint string `json:"fingerprint"` - } + var body dto.ShareAttestRequest if err := c.ShouldBindJSON(&body); err != nil || !service.ValidFingerprint(body.Fingerprint) { apiErr(c, http.StatusUnprocessableEntity, "参数不合法") return @@ -319,16 +308,16 @@ func shareAttest(d Deps) gin.HandlerFunc { apiErr(c, http.StatusPreconditionFailed, "设备证明已过期,请刷新页面重试") return } - c.JSON(http.StatusOK, gin.H{ - "ticket": d.Attestor.IssueTicket(c.Param("token"), body.Fingerprint, time.Now()), - "expiresIn": service.TicketTTLSecs, + c.JSON(http.StatusOK, dto.ShareAttestResponse{ + Ticket: d.Attestor.IssueTicket(c.Param("token"), body.Fingerprint, time.Now()), + ExpiresIn: service.TicketTTLSecs, }) } } // shareAccess — POST /cloud/s/{token} (PUBLIC) -// body: {code?: string, download?: bool} → presigned URL (15 min) + counters. -// 设备证明闸门:需要 attest 签发的 (token, 指纹) 绑定票据。 +// body: {code?: string, download?: bool, fileId?: number} → presigned URL +// (15 min) + counters. 设备证明闸门:需要 attest 签发的 (token, 指纹) 绑定票据。 func shareAccess(d Deps) gin.HandlerFunc { return func(c *gin.Context) { fp := c.GetHeader("X-Fp") @@ -337,11 +326,7 @@ func shareAccess(d Deps) gin.HandlerFunc { apiErr(c, http.StatusPreconditionFailed, "请通过浏览器打开分享页后访问") return } - var body struct { - Code string `json:"code"` - Download bool `json:"download"` - FileID *int64 `json:"fileId"` // folder shares: pick one member - } + var body dto.ShareAccessRequest _ = c.ShouldBindJSON(&body) sf, err := d.Share.ResolveShare(c.Param("token"), false) if err != nil { @@ -380,7 +365,7 @@ func shareAccess(d Deps) gin.HandlerFunc { } else { target = sf.File // file share ignores fileId } - res, err := d.Share.BuildAccessFor(c.Request.Context(), sf, target, body.Download) + grant, err := d.Share.BuildAccessFor(c.Request.Context(), sf, target, body.Download) if err != nil { if errors.Is(err, service.ErrShareDownloadCap) || errors.Is(err, service.ErrShareNotFound) { // The cap was exhausted between resolve and access, or the @@ -392,7 +377,19 @@ func shareAccess(d Deps) gin.HandlerFunc { apiErr(c, http.StatusInternalServerError, "服务异常,请稍后重试") return } - c.JSON(http.StatusOK, res) + c.JSON(http.StatusOK, shareAccessResponse(grant)) + } +} + +// shareAccessResponse maps a presigned access grant onto the wire shape. +func shareAccessResponse(grant *service.AccessGrant) dto.ShareAccessResponse { + return dto.ShareAccessResponse{ + URL: grant.URL, + ViewMode: grant.ViewMode, + MimeType: grant.MimeType, + FileName: grant.FileName, + FileSize: grant.FileSize, + ExpiresIn: grant.ExpiresIn, } } @@ -400,32 +397,34 @@ func shareAccess(d Deps) gin.HandlerFunc { // 哈希行不可逆)是属主可见的明文提取码 —— 公开端点不经过此函数。 // 1.0.16 增补:target_type/folder_id/legal_ack + moderation 三元组(属主 // 可见审核态;公开端点不暴露)。 -func shareView(s *model.CloudShare, withToken bool, code string) gin.H { - out := gin.H{ - "id": s.ID, - "file_id": s.FileID, - "has_code": s.ExtractionCode != nil, - "extraction_code": code, - "expires_at": s.ExpiresAt, - "max_downloads": s.MaxDownloads, - "view_count": s.ViewCount, - "download_count": s.DownloadCount, - "is_revoked": s.IsRevoked, - "created_at": s.CreatedAt, - "target_type": shareTargetType(s), - "folder_id": s.FolderID, - "legal_ack": s.LegalAck, - "content_review_ack": s.ContentReviewAck, - "content_review_policy_version": s.ContentReviewPolicyVersion, - "content_review_ack_at": s.ContentReviewAckAt, - "moderation_status": s.ModerationStatus, - "moderation_reason": s.ModerationReason, - "moderation_time": s.ModerationTime, - "moderation_by": s.ModerationBy, +func shareView(s *model.CloudShare, withToken bool, code string) dto.ShareView { + out := dto.ShareView{ + ID: s.ID, + FileID: s.FileID, + HasCode: s.ExtractionCode != nil, + ExtractionCode: code, + ExpiresAt: s.ExpiresAt, + MaxDownloads: s.MaxDownloads, + ViewCount: s.ViewCount, + DownloadCount: s.DownloadCount, + IsRevoked: s.IsRevoked, + CreatedAt: s.CreatedAt, + TargetType: shareTargetType(s), + FolderID: s.FolderID, + LegalAck: s.LegalAck, + ContentReviewAck: s.ContentReviewAck, + ContentReviewPolicyVersion: s.ContentReviewPolicyVersion, + ContentReviewAckAt: s.ContentReviewAckAt, + ModerationStatus: s.ModerationStatus, + ModerationReason: s.ModerationReason, + ModerationTime: s.ModerationTime, + ModerationBy: s.ModerationBy, } if withToken { - out["share_token"] = s.ShareToken - out["share_url"] = "/cloud/s/" + s.ShareToken + token := s.ShareToken + url := "/cloud/s/" + s.ShareToken + out.ShareToken = &token + out.ShareURL = &url } return out } @@ -434,18 +433,13 @@ func shareView(s *model.CloudShare, withToken bool, code string) gin.H { // 提取码。链接与 token 不变,仅换码 —— 解决"忘记提取码"而无需重建分享。 func setShareCode(d Deps) gin.HandlerFunc { return func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, err := strconv.ParseInt(c.Param("share_id"), 10, 64) if err != nil || id <= 0 { apiErr(c, http.StatusUnprocessableEntity, "share_id 不合法") return } - var body struct { - Code string `json:"code" binding:"required"` - } + var body dto.ShareSetCodeRequest if err := c.ShouldBindJSON(&body); err != nil { apiErr(c, http.StatusUnprocessableEntity, "参数不合法") return @@ -454,7 +448,7 @@ func setShareCode(d Deps) gin.HandlerFunc { apiErr(c, http.StatusBadRequest, err.Error()) return } - c.JSON(http.StatusOK, gin.H{"updated": true}) + c.JSON(http.StatusOK, dto.ShareCodeResponse{Updated: true}) } } @@ -466,9 +460,7 @@ func setShareCode(d Deps) gin.HandlerFunc { // pre-disclosure legacy shares included — because moderation often happens // after the owner removed the share. Only the share's own target scope is // enforced (file-share target or live member of the shared folder tree), and -// every grant is audited by app-admin before it is minted. Response = the same -// short-lived inline preview grant as public sharing — no download URL, the -// public download counter is untouched. +// every grant is audited by app-admin before it is minted. func adminReviewPreview(d Deps) gin.HandlerFunc { return func(c *gin.Context) { token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) @@ -500,7 +492,21 @@ func adminReviewPreview(d Deps) gin.HandlerFunc { adminReviewError(c, err) return } - c.JSON(http.StatusOK, res) + out := dto.AdminReviewAccessResponse{ + ViewMode: res.Grant.ViewMode, + MimeType: res.Grant.MimeType, + FileName: res.Grant.FileName, + FileSize: res.Grant.FileSize, + ExpiresIn: res.Grant.ExpiresIn, + } + if res.Text != "" { + out.Text = res.Text + truncated := res.Truncated + out.Truncated = &truncated + } else { + out.URL = res.Grant.URL + } + c.JSON(http.StatusOK, out) } } diff --git a/app-go/app-cloud/internal/router/workspace.go b/app-go/app-cloud/internal/router/workspace.go index 0882069b..f4b33aaa 100644 --- a/app-go/app-cloud/internal/router/workspace.go +++ b/app-go/app-cloud/internal/router/workspace.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "app-cloud/internal/dto" + "app-cloud/internal/middleware" "app-cloud/internal/model" "app-cloud/internal/repo" @@ -16,79 +18,27 @@ import ( func registerWorkspaces(e *gin.Engine, d Deps) { g := e.Group("/workspaces") + g.Use(middleware.RequireUID()) wsRepo := repo.NewWorkspaceRepo() - // WorkspaceResponse (camelCase) — mirrors app/response/workspace.py. - type bindingResponse struct { - ID int64 `json:"id"` - BindType string `json:"bindType"` - FolderID *int64 `json:"folderId"` - FolderName *string `json:"folderName"` // always null in the current contract - UploadUUID *string `json:"uploadUuid"` - FileName *string `json:"fileName"` // always null in the current contract - IncludeSubfolders bool `json:"includeSubfolders"` - } - type workspaceResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description *string `json:"description"` - Icon *string `json:"icon"` - Color *string `json:"color"` - FileCount int `json:"fileCount"` - ChunkCount int `json:"chunkCount"` - Bindings []bindingResponse `json:"bindings"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - } - toResponse := func(ws *model.Workspace, bindings []model.WorkspaceBinding) workspaceResponse { - out := make([]bindingResponse, 0, len(bindings)) - for _, b := range bindings { - out = append(out, bindingResponse{ - ID: b.ID, - BindType: b.BindType, - FolderID: b.FolderID, - FolderName: nil, - UploadUUID: b.UploadUUID, - FileName: nil, - IncludeSubfolders: b.IncludeSubfolders, - }) - } - return workspaceResponse{ - ID: ws.ID, Name: ws.Name, Description: ws.Description, Icon: ws.Icon, Color: ws.Color, - FileCount: ws.FileCount, ChunkCount: ws.ChunkCount, Bindings: out, - CreatedAt: isoOrEmpty(ws.CreatedAt), UpdatedAt: isoOrEmpty(ws.UpdatedAt), - } - } - g.GET("", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) rows, err := wsRepo.ListByUID(c.Request.Context(), d.DB, uid) if err != nil { apiErr(c, http.StatusInternalServerError, "内部错误") return } - out := make([]workspaceResponse, 0, len(rows)) + out := make([]dto.Workspace, 0, len(rows)) for i := range rows { bindings, _ := wsRepo.GetBindings(c.Request.Context(), d.DB, rows[i].ID, uid) - out = append(out, toResponse(&rows[i], bindings)) + out = append(out, toWorkspaceResponse(&rows[i], bindings)) } c.JSON(http.StatusOK, out) }) g.POST("", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } - var body struct { - Name string `json:"name"` - Description *string `json:"description"` - Icon *string `json:"icon"` - Color *string `json:"color"` - } + uid := middleware.UIDFrom(c) + var body dto.WorkspaceCreateRequest if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" { apiErr(c, http.StatusBadRequest, "name 必填") return @@ -103,14 +53,11 @@ func registerWorkspaces(e *gin.Engine, d Deps) { apiErr(c, http.StatusInternalServerError, "内部错误") return } - c.JSON(http.StatusCreated, toResponse(row, nil)) + c.JSON(http.StatusCreated, toWorkspaceResponse(row, nil)) }) g.GET("/:workspace_id", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, valid := parseWorkspaceID(c) if !valid { return @@ -125,24 +72,16 @@ func registerWorkspaces(e *gin.Engine, d Deps) { return } bindings, _ := wsRepo.GetBindings(c.Request.Context(), d.DB, id, uid) - c.JSON(http.StatusOK, toResponse(row, bindings)) + c.JSON(http.StatusOK, toWorkspaceResponse(row, bindings)) }) g.PATCH("/:workspace_id", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, valid := parseWorkspaceID(c) if !valid { return } - var body struct { - Name *string `json:"name"` - Description *string `json:"description"` - Icon *string `json:"icon"` - Color *string `json:"color"` - } + var body dto.WorkspaceUpdateRequest if err := c.ShouldBindJSON(&body); err != nil { apiErr(c, http.StatusBadRequest, "请求体格式错误") return @@ -157,14 +96,11 @@ func registerWorkspaces(e *gin.Engine, d Deps) { return } bindings, _ := wsRepo.GetBindings(c.Request.Context(), d.DB, id, uid) - c.JSON(http.StatusOK, toResponse(row, bindings)) + c.JSON(http.StatusOK, toWorkspaceResponse(row, bindings)) }) g.DELETE("/:workspace_id", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, valid := parseWorkspaceID(c) if !valid { return @@ -178,14 +114,11 @@ func registerWorkspaces(e *gin.Engine, d Deps) { apiErr(c, http.StatusNotFound, "工作区不存在") return } - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, dto.WorkspaceDeleteResponse{Deleted: true}) }) g.POST("/:workspace_id/bindings", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, valid := parseWorkspaceID(c) if !valid { return @@ -199,12 +132,7 @@ func registerWorkspaces(e *gin.Engine, d Deps) { apiErr(c, http.StatusNotFound, "工作区不存在") return } - var body struct { - BindType string `json:"bindType"` - FolderID *int64 `json:"folderId"` - UploadUUID *string `json:"uploadUuid"` - IncludeSubfolders *bool `json:"includeSubfolders"` - } + var body dto.WorkspaceBindingRequest if err := c.ShouldBindJSON(&body); err != nil { apiErr(c, http.StatusBadRequest, "请求体格式错误") return @@ -258,14 +186,11 @@ func registerWorkspaces(e *gin.Engine, d Deps) { } updated, _ := wsRepo.GetByID(c.Request.Context(), d.DB, id, uid) bindings, _ := wsRepo.GetBindings(c.Request.Context(), d.DB, id, uid) - c.JSON(http.StatusOK, toResponse(updated, bindings)) + c.JSON(http.StatusOK, toWorkspaceResponse(updated, bindings)) }) g.DELETE("/:workspace_id/bindings/:binding_id", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, valid := parseWorkspaceID(c) if !valid { return @@ -288,14 +213,11 @@ func registerWorkspaces(e *gin.Engine, d Deps) { apiErr(c, http.StatusInternalServerError, "内部错误") return } - c.JSON(http.StatusOK, gin.H{"deleted": true}) + c.JSON(http.StatusOK, dto.WorkspaceDeleteResponse{Deleted: true}) }) g.GET("/:workspace_id/files", func(c *gin.Context) { - uid, ok := currentUID(c) - if !ok { - return - } + uid := middleware.UIDFrom(c) id, valid := parseWorkspaceID(c) if !valid { return @@ -314,7 +236,7 @@ func registerWorkspaces(e *gin.Engine, d Deps) { apiErr(c, http.StatusInternalServerError, "内部错误") return } - items := make([]gin.H, 0, len(files)) + items := make([]dto.WorkspaceFileItem, 0, len(files)) for _, f := range files { status := "pending" if f.VectorStatus != nil && *f.VectorStatus != "" { @@ -324,19 +246,40 @@ func registerWorkspaces(e *gin.Engine, d Deps) { if f.VectorChunkCount != nil { chunks = *f.VectorChunkCount } - items = append(items, gin.H{ - "uploadUuid": f.UploadUUID, - "originalName": f.OriginalName, - "mimeType": f.MimeType, - "vectorizable": true, - "vectorStatus": status, - "vectorChunkCount": chunks, + items = append(items, dto.WorkspaceFileItem{ + UploadUUID: f.UploadUUID, + OriginalName: f.OriginalName, + MimeType: f.MimeType, + Vectorizable: true, + VectorStatus: status, + VectorChunkCount: chunks, }) } c.JSON(http.StatusOK, items) }) } +// toWorkspaceResponse maps a workspace row + bindings onto the wire shape. +func toWorkspaceResponse(ws *model.Workspace, bindings []model.WorkspaceBinding) dto.Workspace { + out := make([]dto.WorkspaceBinding, 0, len(bindings)) + for _, b := range bindings { + out = append(out, dto.WorkspaceBinding{ + ID: b.ID, + BindType: b.BindType, + FolderID: b.FolderID, + FolderName: nil, + UploadUUID: b.UploadUUID, + FileName: nil, + IncludeSubfolders: b.IncludeSubfolders, + }) + } + return dto.Workspace{ + ID: ws.ID, Name: ws.Name, Description: ws.Description, Icon: ws.Icon, Color: ws.Color, + FileCount: ws.FileCount, ChunkCount: ws.ChunkCount, Bindings: out, + CreatedAt: isoOrEmpty(ws.CreatedAt), UpdatedAt: isoOrEmpty(ws.UpdatedAt), + } +} + // parseWorkspaceID parses the workspace_id path param (distinct from // cloud.go's folder_id parseID). func parseWorkspaceID(c *gin.Context) (int64, bool) { diff --git a/app-go/app-cloud/internal/service/content_review_test.go b/app-go/app-cloud/internal/service/content_review_test.go deleted file mode 100644 index 239eb4f0..00000000 --- a/app-go/app-cloud/internal/service/content_review_test.go +++ /dev/null @@ -1,22 +0,0 @@ -// Content-review disclosure guards (Issue #154): the second, SEPARATE -// acknowledgment must stay backend-enforced at CREATION time and must never -// silently reuse the legacy legalAck. Reviewer previews are NOT gated on it: -// moderation may need to inspect legacy / revoked / expired shares. -package service - -import "testing" - -func TestContentReviewAckErrorCopy(t *testing.T) { - if ErrContentReviewAckRequired.Error() != "请单独确认管理员内容审核告知" { - t.Fatalf("content review ack copy drifted: %q", ErrContentReviewAckRequired.Error()) - } - if ErrContentReviewAckRequired == ErrLegalAckRequired { - t.Fatal("the two acknowledgments must be distinct errors") - } -} - -func TestContentReviewPolicyVersionPinned(t *testing.T) { - if ContentReviewPolicyVersion == "" { - t.Fatal("policy version must not be empty — ack rows must be traceable to a wording version") - } -} diff --git a/app-go/app-cloud/internal/service/purge.go b/app-go/app-cloud/internal/service/purge.go index 04cc1629..3a19af5c 100644 --- a/app-go/app-cloud/internal/service/purge.go +++ b/app-go/app-cloud/internal/service/purge.go @@ -7,8 +7,8 @@ import ( "time" "app-cloud/internal/infra/minio" - "app-cloud/internal/model" "app-cloud/internal/infra/mongostore" + "app-cloud/internal/model" "app-cloud/internal/repo" "gorm.io/gorm" @@ -27,7 +27,7 @@ type PurgeService struct { minio *minio.Client mongo *mongostore.MongoStore files *repo.FileRepo - pipeline *Pipeline // nil-tolerant: Milvus delete skipped when absent + pipeline *Pipeline // nil-tolerant: Milvus delete skipped when absent graph CloudGraphWriter // nil-tolerant: Neo4j L1 node delete skipped when absent } diff --git a/app-go/app-cloud/internal/service/ref.go b/app-go/app-cloud/internal/service/ref.go index c3934853..799712d4 100644 --- a/app-go/app-cloud/internal/service/ref.go +++ b/app-go/app-cloud/internal/service/ref.go @@ -9,6 +9,7 @@ import ( "app-cloud/internal/model" "app-cloud/internal/repo" + "app-cloud/internal/utils" "gorm.io/gorm" ) @@ -45,17 +46,30 @@ var ( ErrRefTooMany = errors.New("该分享包含的文件超过 2000 上限,无法保存引用") ) -// refFolderNameBytes — cloud_folders.name is varchar(200); keep room for the -// "-引用(N)" suffix (UTF-8 byte truncation, rune-safe). -const refFolderNameBytes = 200 - 16 +// SaveRefResult is the outcome of saving a share as references. +type SaveRefResult struct { + Saved int + RootFolderID *int64 +} -// refFolderName rune-safe truncates a mirror name to the folder column limit. -func refFolderName(name string, maxBytes int) string { - for len(name) > maxBytes { - runes := []rune(name) - name = string(runes[:len(runes)-1]) - } - return name +// RefItem is one saved reference row. +type RefItem struct { + ID int64 + Name string + Size int64 + MimeType string + FolderID *int64 + Status string + ShareID int64 + SourceUploadUUID string + SourceName string + CreatedAt *time.Time +} + +// RefListResult is the reference list page. +type RefListResult struct { + Refs []RefItem + Total int64 } // SaveShare — POST /cloud/refs body {token, code?, targetFolderId?}. @@ -65,7 +79,7 @@ func refFolderName(name string, maxBytes int) string { // member file (folder share). Idempotent: an already-alive ref for // (uid, share_id, source_file_id) is skipped. NO attest — saving is a // logged-in action. Response: {saved, rootFolderId?}. -func (s *RefService) SaveShare(ctx context.Context, uid int64, token, code string, targetFolderID *int64) (map[string]any, error) { +func (s *RefService) SaveShare(ctx context.Context, uid int64, token, code string, targetFolderID *int64) (*SaveRefResult, error) { sf, err := s.share.ResolveShare(token, false) if err != nil { return nil, err // uniform 404 at the router @@ -98,14 +112,14 @@ func (s *RefService) SaveShare(ctx context.Context, uid int64, token, code strin if sf.File != nil { // ── file share → one ref at destParent ── if have[sf.File.ID] { - return map[string]any{"saved": 0}, nil + return &SaveRefResult{Saved: 0}, nil } if err := s.refs.Create(s.db, newRefRow(uid, sf.Share, sf.File, destParent)); err != nil { return nil, err } slog.Info("[CLOUD_REF] saved file", "uid", uid, "share_id", sf.Share.ID, "source_file_id", sf.File.ID, "saved", 1) - return map[string]any{"saved": 1}, nil + return &SaveRefResult{Saved: 1}, nil } // ── folder share → mirror tree + one ref per member file ── @@ -131,9 +145,9 @@ func (s *RefService) SaveShare(ctx context.Context, uid int64, token, code strin return nil, err } if len(pending) == 0 { - out := map[string]any{"saved": 0} + out := &SaveRefResult{Saved: 0} if rootID != 0 { - out["rootFolderId"] = rootID + out.RootFolderID = &rootID } return out, nil } @@ -152,7 +166,7 @@ func (s *RefService) SaveShare(ctx context.Context, uid int64, token, code strin } slog.Info("[CLOUD_REF] saved folder", "uid", uid, "share_id", sf.Share.ID, "root_folder_id", rootID, "saved", len(rows), "skipped", len(existing)) - return map[string]any{"saved": len(rows), "rootFolderId": rootID}, nil + return &SaveRefResult{Saved: len(rows), RootFolderID: &rootID}, nil } // ensureMirrorTree — reuse-or-create the mirrored subtree for this share. @@ -263,7 +277,7 @@ func (s *RefService) ensureMirrorTree(uid int64, sf *shareFile, destParent *int6 // first free "-引用(N)" suffix (contract 重名追加语义). func (s *RefService) mirrorFolder(uid int64, parent *int64, name string, mine map[int64]bool) (*model.CloudFolder, error) { var folders repo.FolderRepo - name = refFolderName(name, refFolderNameBytes) + name = utils.RefFolderName(name, utils.RefFolderNameMaxBytes) q := s.db.Where("uid = ? AND deleted_at IS NULL", uid) if parent != nil { q = q.Where("parent_id = ?", *parent) @@ -324,7 +338,7 @@ func newRefRow(uid int64, sh *model.CloudShare, f *model.CloudFile, folderID *in // ListRefs — GET /cloud/refs?folderId=. {refs: RefItem[], total}. sourceName // resolves the LIVE original name (bounded batch read); a missing source row // falls back to the snapshot name. -func (s *RefService) ListRefs(uid int64, folderID *int64) (map[string]any, error) { +func (s *RefService) ListRefs(uid int64, folderID *int64) (*RefListResult, error) { const listLimit = 2000 rows, err := s.refs.ListByFolder(s.db, uid, folderID, listLimit) if err != nil { @@ -347,28 +361,27 @@ func (s *RefService) ListRefs(uid int64, folderID *int64) (map[string]any, error } } } - items := make([]map[string]any, 0, len(rows)) + items := make([]RefItem, 0, len(rows)) for i := range rows { r := &rows[i] sourceName := r.Name if n, ok := live[r.SourceFileID]; ok { sourceName = n } - items = append(items, map[string]any{ - "id": r.ID, - "name": r.Name, - "size": r.FileSize, - "mimeType": r.MimeType, - "folderId": r.FolderID, - "status": r.Status, - "shareId": r.ShareID, - "sourceUploadUuid": r.SourceUploadUUID, - "sourceName": sourceName, - "createdAt": r.CreatedAt, - "isRef": true, + items = append(items, RefItem{ + ID: r.ID, + Name: r.Name, + Size: r.FileSize, + MimeType: r.MimeType, + FolderID: r.FolderID, + Status: r.Status, + ShareID: r.ShareID, + SourceUploadUUID: r.SourceUploadUUID, + SourceName: sourceName, + CreatedAt: r.CreatedAt, }) } - return map[string]any{"refs": items, "total": total}, nil + return &RefListResult{Refs: items, Total: total}, nil } // DeleteRef — DELETE /cloud/refs/{id} (uid-checked soft delete; the source @@ -382,7 +395,7 @@ func (s *RefService) DeleteRef(uid, id int64) error { // (BuildAccess 政策); no share cap consumption / download increment — a ref // read is the ref owner's own access. Any invalidated leg flips the ref // dead and reads uniformly as 404「引用已失效」. -func (s *RefService) RawRef(ctx context.Context, uid, id int64, download bool) (map[string]any, error) { +func (s *RefService) RawRef(ctx context.Context, uid, id int64, download bool) (*AccessGrant, error) { ref, err := s.refs.GetOwned(s.db, id, uid) if err != nil { return nil, err @@ -393,7 +406,7 @@ func (s *RefService) RawRef(ctx context.Context, uid, id int64, download bool) ( if ref.Status != "alive" { return nil, ErrRefDead } - fail := func(err error) (map[string]any, error) { + fail := func(err error) (*AccessGrant, error) { _ = s.refs.MarkDead(s.db, ref.ID) if errors.Is(err, ErrNotFound) || errors.Is(err, ErrShareNotFound) { return nil, ErrRefDead @@ -436,12 +449,12 @@ func (s *RefService) RawRef(ctx context.Context, uid, id int64, download bool) ( if err != nil { return nil, err } - return map[string]any{ - "url": url, - "viewMode": viewMode, - "mimeType": f.MimeType, - "fileName": f.OriginalName, - "fileSize": f.FileSize, - "expiresIn": s.share.shareExpire, + return &AccessGrant{ + URL: url, + ViewMode: viewMode, + MimeType: f.MimeType, + FileName: f.OriginalName, + FileSize: f.FileSize, + ExpiresIn: s.share.shareExpire, }, nil } diff --git a/app-go/app-cloud/internal/service/semantic.go b/app-go/app-cloud/internal/service/semantic.go new file mode 100644 index 00000000..2d22dbb0 --- /dev/null +++ b/app-go/app-cloud/internal/service/semantic.go @@ -0,0 +1,370 @@ +package service + +// Semantic read path over the drive's chunk vectors: folder-scoped semantic +// search and "similar files". Querying is read-only — embeddings come from the +// same gateway/model the vectorize pipeline wrote with, and the Milvus filter +// is assembled here from the authenticated uid only (never from client input). +// +// Availability is honest: when the embedding gateway or the vector index is +// not wired the call reports ErrSemanticUnavailable, which the router maps to +// 503 — file-name search keeps working independently. + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "app-cloud/internal/model" + "app-cloud/internal/repo" + "app-cloud/internal/utils" + + "gorm.io/gorm" +) + +// ErrSemanticUnavailable — embedding gateway or vector index unavailable. +var ErrSemanticUnavailable = errors.New("语义检索暂不可用") + +const ( + semanticMaxSnippets = 3 + semanticSnippetRunes = 200 + semanticOverfetchFactor = 4 + semanticMinFetch = 20 + semanticMaxFetch = 100 + semanticQueryMaxRunes = 2000 + // semanticFilterInListMax caps how many upload_uuids are pushed into the + // Milvus expression; beyond it the allowed set is applied after the + // search with a wider over-fetch. + semanticFilterInListMax = 200 + semanticWideOverfetchFactor = 10 + semanticWideMaxFetch = 400 +) + +// SemanticResult — one ranked drive file. Chunk hits are aggregated per +// upload_uuid: best score wins, snippets keep the strongest distinct chunks. +type SemanticResult struct { + UploadUUID string + OriginalName string + Title string + MimeType string + FileSize int64 + Score float64 + Snippets []string +} + +// SemanticIndex is the vector-read port (implemented by +// milvusstore.MilvusStore). filter is always built by this package. +type SemanticIndex interface { + SearchChunks(ctx context.Context, vector []float32, filter string, limit int) ([]model.ChunkHit, error) +} + +// EmbeddingClient is the embedding port (implemented by pipeline.Embedder; +// the same model that produced the stored vectors). +type EmbeddingClient interface { + Embed(ctx context.Context, texts []string) ([][]float32, error) +} + +// SemanticFileReader — owner-scoped file reads used to scope and hydrate hits. +type SemanticFileReader interface { + GetByUUID(db *gorm.DB, uploadUUID string, uid int64) (*model.CloudFile, error) + ListByUUIDs(db *gorm.DB, uid int64, uploadUUIDs []string) ([]model.CloudFile, error) + ListActiveByFolderIDs(db *gorm.DB, uid int64, folderIDs []int64) ([]model.CloudFile, error) +} + +// FolderTreeReader — recursive folder subtree ids (repo.FolderRepo). +type FolderTreeReader interface { + SubtreeIDs(db *gorm.DB, id int64, uid int64, includeChildren bool) ([]int64, error) +} + +// SearchService answers semantic search and similar-file queries for one uid. +type SearchService struct { + db *gorm.DB + files SemanticFileReader + folders FolderTreeReader + summaries *SummaryService + embedder EmbeddingClient + index SemanticIndex +} + +// NewSearchService wires the production ports. A nil embedder/index disables +// the service (calls answer ErrSemanticUnavailable). +func NewSearchService(db *gorm.DB, embedder EmbeddingClient, index SemanticIndex, + summaries *SummaryService) *SearchService { + return NewSearchServiceWithPorts(db, repo.NewFileRepo(), repo.NewFolderRepo(), + summaries, embedder, index) +} + +// NewSearchServiceWithPorts builds the service over explicit ports; tests use +// it to substitute the vector index, embedder and file/folder readers. +func NewSearchServiceWithPorts(db *gorm.DB, files SemanticFileReader, folders FolderTreeReader, + summaries *SummaryService, embedder EmbeddingClient, index SemanticIndex) *SearchService { + return &SearchService{db: db, files: files, folders: folders, + summaries: summaries, embedder: embedder, index: index} +} + +// available reports whether the semantic path can run at all. +func (s *SearchService) available() bool { + return s != nil && s.embedder != nil && s.index != nil +} + +// SearchSemantic ranks the user's files for a free-text query, optionally +// restricted to a folder subtree. +func (s *SearchService) SearchSemantic(ctx context.Context, uid int64, q string, + folderID *int64, limit int) ([]SemanticResult, error) { + q = strings.TrimSpace(q) + if q == "" { + return nil, nil + } + if !s.available() { + return nil, ErrSemanticUnavailable + } + if limit <= 0 { + limit = 10 + } + allowed, err := s.allowedSet(uid, folderID) + if err != nil { + return nil, err + } + if folderID != nil && allowed != nil && len(allowed) == 0 { + // The folder (or its subtree) holds no active files — nothing to + // search, and running a widened query would surface unrelated files. + return nil, nil + } + vectors, err := s.embedder.Embed(ctx, []string{utils.TruncateRunes(q, semanticQueryMaxRunes)}) + if err != nil || len(vectors) == 0 { + return nil, fmt.Errorf("%w: embedding: %v", ErrSemanticUnavailable, err) + } + fetch := s.fetchLimit(limit) + filter := uidFilter(uid) + if allowed != nil { + if len(allowed) <= semanticFilterInListMax { + // Small folder scope: push the allowed files into the Milvus + // expression so folder-outside chunks cannot occupy the top-N. + filter = uploadUUIDInFilter(uid, allowed) + } else { + // Huge folder scope: an expression listing thousands of uuids is + // worse than filtering after the fact, so over-fetch instead. + fetch = s.wideFetchLimit(limit) + } + } + hits, err := s.index.SearchChunks(ctx, vectors[0], filter, fetch) + if err != nil { + return nil, fmt.Errorf("%w: index: %v", ErrSemanticUnavailable, err) + } + return s.collect(uid, hits, limit, nil, allowed) +} + +// Similar returns the user's files whose content is closest to one file, +// excluding the file itself. The query vector uses the file's summary +// overview plus its display title, falling back to the file name when no +// summary exists. +func (s *SearchService) Similar(ctx context.Context, uid int64, uploadUUID string, + limit int) ([]SemanticResult, error) { + if !s.available() { + return nil, ErrSemanticUnavailable + } + if limit <= 0 { + limit = 5 + } + f, err := s.files.GetByUUID(s.db, uploadUUID, uid) + if err != nil { + return nil, err + } + if f == nil { + return nil, ErrNotFound + } + query := s.similarQueryText(ctx, uid, f) + vectors, err := s.embedder.Embed(ctx, []string{utils.TruncateRunes(query, semanticQueryMaxRunes)}) + if err != nil || len(vectors) == 0 { + return nil, fmt.Errorf("%w: embedding: %v", ErrSemanticUnavailable, err) + } + hits, err := s.index.SearchChunks(ctx, vectors[0], uidFilter(uid), s.fetchLimit(limit)) + if err != nil { + return nil, fmt.Errorf("%w: index: %v", ErrSemanticUnavailable, err) + } + return s.collect(uid, hits, limit, map[string]bool{uploadUUID: true}, nil) +} + +// similarQueryText builds the probe text: title (+ summary overview when the +// file has one). Summary reads are best-effort — a missing Mongo store simply +// degrades to the title. +func (s *SearchService) similarQueryText(ctx context.Context, uid int64, f *model.CloudFile) string { + title := strings.TrimSpace(f.OriginalName) + if f.Title != nil && strings.TrimSpace(*f.Title) != "" { + title = strings.TrimSpace(*f.Title) + } + if s.summaries != nil { + if view, err := s.summaries.Get(ctx, uid, f.UploadUUID); err == nil && + view.Available && view.Summary != nil { + if overview := strings.TrimSpace(view.Summary.Overview); overview != "" { + return title + "\n" + overview + } + } + } + return title +} + +// allowedSet returns the set of upload_uuids inside a folder subtree, or nil +// when no folder scope is requested. +func (s *SearchService) allowedSet(uid int64, folderID *int64) (map[string]bool, error) { + if folderID == nil { + return nil, nil + } + ids, err := s.folders.SubtreeIDs(s.db, *folderID, uid, true) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return map[string]bool{}, nil + } + rows, err := s.files.ListActiveByFolderIDs(s.db, uid, ids) + if err != nil { + return nil, err + } + allowed := make(map[string]bool, len(rows)) + for i := range rows { + allowed[rows[i].UploadUUID] = true + } + return allowed, nil +} + +// fetchLimit over-fetches chunks so per-file aggregation and folder filtering +// still yield the requested number of distinct files. +func (s *SearchService) fetchLimit(limit int) int { + n := limit * semanticOverfetchFactor + if n < semanticMinFetch { + n = semanticMinFetch + } + if n > semanticMaxFetch { + n = semanticMaxFetch + } + return n +} + +// wideFetchLimit over-fetches harder when a large folder scope can only be +// applied after the vector search (folder-outside chunks are discarded by +// collect, so the raw hit budget must cover them). +func (s *SearchService) wideFetchLimit(limit int) int { + n := limit * semanticWideOverfetchFactor + if n < semanticMinFetch { + n = semanticMinFetch + } + if n > semanticWideMaxFetch { + n = semanticWideMaxFetch + } + return n +} + +// uploadUUIDInFilter builds the folder-scope expression: the uid predicate +// plus an upload_uuid in-list. Every value is a server-side uuid and is +// escaped, so no caller-controlled text reaches the expression. +func uploadUUIDInFilter(uid int64, allowed map[string]bool) string { + uuids := make([]string, 0, len(allowed)) + for uuid := range allowed { + uuids = append(uuids, uuid) + } + sort.Strings(uuids) + quoted := make([]string, len(uuids)) + for i, uuid := range uuids { + quoted[i] = quoteMilvusString(uuid) + } + return uidFilter(uid) + " && upload_uuid in [" + strings.Join(quoted, ", ") + "]" +} + +// quoteMilvusString renders s as a double-quoted Milvus string literal. +func quoteMilvusString(s string) string { + escaped := strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(s) + return "\"" + escaped + "\"" +} + +// collect aggregates chunk hits into per-file results, hydrates cloud_files +// metadata for the owner and keeps only files that still exist. +func (s *SearchService) collect(uid int64, hits []model.ChunkHit, limit int, + exclude map[string]bool, allowed map[string]bool) ([]SemanticResult, error) { + type aggregate struct { + score float64 + snippets []string + } + aggregated := make(map[string]*aggregate) + order := make([]string, 0, len(hits)) + for _, hit := range hits { + uuid := strings.TrimSpace(hit.UploadUUID) + if uuid == "" || exclude[uuid] { + continue + } + if allowed != nil && !allowed[uuid] { + continue + } + agg := aggregated[uuid] + if agg == nil { + agg = &aggregate{score: hit.Score} + aggregated[uuid] = agg + order = append(order, uuid) + } else if hit.Score > agg.score { + agg.score = hit.Score + } + if len(agg.snippets) >= semanticMaxSnippets { + continue + } + snippet := utils.TruncateRunes(strings.TrimSpace(hit.Text), semanticSnippetRunes) + if snippet == "" || containsString(agg.snippets, snippet) { + continue + } + agg.snippets = append(agg.snippets, snippet) + } + if len(order) == 0 { + return []SemanticResult{}, nil + } + rows, err := s.files.ListByUUIDs(s.db, uid, order) + if err != nil { + return nil, err + } + meta := make(map[string]*model.CloudFile, len(rows)) + for i := range rows { + meta[rows[i].UploadUUID] = &rows[i] + } + results := make([]SemanticResult, 0, len(order)) + for _, uuid := range order { + f := meta[uuid] + agg := aggregated[uuid] + if f == nil || agg == nil { + continue // deleted / not owned — never surface it + } + title := "" + if f.Title != nil { + title = strings.TrimSpace(*f.Title) + } + snippets := agg.snippets + if snippets == nil { + snippets = []string{} + } + results = append(results, SemanticResult{ + UploadUUID: f.UploadUUID, + OriginalName: f.OriginalName, + Title: title, + MimeType: f.MimeType, + FileSize: f.FileSize, + Score: agg.score, + Snippets: snippets, + }) + } + sort.SliceStable(results, func(i, j int) bool { return results[i].Score > results[j].Score }) + if len(results) > limit { + results = results[:limit] + } + return results, nil +} + +// uidFilter is the only identity filter the semantic path ever sends to the +// vector store. +func uidFilter(uid int64) string { return fmt.Sprintf("uid == %d", uid) } + +func containsString(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/app-go/app-cloud/internal/service/share.go b/app-go/app-cloud/internal/service/share.go index 4e073895..e50c8744 100644 --- a/app-go/app-cloud/internal/service/share.go +++ b/app-go/app-cloud/internal/service/share.go @@ -14,6 +14,7 @@ import ( "app-cloud/internal/model" "app-cloud/internal/pipeline" "app-cloud/internal/repo" + "app-cloud/internal/utils" "gorm.io/gorm" ) @@ -344,9 +345,27 @@ func (s *ShareService) SetCode(uid, shareID int64, code string) error { return s.shares.UpdateExtractionCode(s.db, shareID, stored) } +// AccessGrant is a presigned read grant for one cloud file. +type AccessGrant struct { + URL string + ViewMode string + MimeType string + FileName string + FileSize int64 + ExpiresIn int +} + +// AdminReviewAccess is the review-plane payload: an inline presigned grant for +// media, or a bounded plain-text rendition for documents. +type AdminReviewAccess struct { + Grant *AccessGrant + Text string + Truncated bool +} + // BuildAccess renders a presigned URL for the share's target file (preview // or download) and bumps the download counter when requested. -func (s *ShareService) BuildAccess(ctx context.Context, sf *shareFile, download bool) (map[string]any, error) { +func (s *ShareService) BuildAccess(ctx context.Context, sf *shareFile, download bool) (*AccessGrant, error) { return s.BuildAccessFor(ctx, sf, sf.File, download) } @@ -354,7 +373,7 @@ func (s *ShareService) BuildAccess(ctx context.Context, sf *shareFile, download // File shares pass sf.File; folder shares pass the fileId-picked member // (validated by FolderShareFile). The disposition/XSS/cap policy is one // shared implementation — no fork between the two target types. -func (s *ShareService) BuildAccessFor(ctx context.Context, sf *shareFile, f *model.CloudFile, download bool) (map[string]any, error) { +func (s *ShareService) BuildAccessFor(ctx context.Context, sf *shareFile, f *model.CloudFile, download bool) (*AccessGrant, error) { if f == nil { return nil, ErrShareNotFound } @@ -389,13 +408,13 @@ func (s *ShareService) BuildAccessFor(ctx context.Context, sf *shareFile, f *mod return nil, ErrShareDownloadCap } } - return map[string]any{ - "url": url, - "viewMode": viewMode, - "mimeType": f.MimeType, - "fileName": f.OriginalName, - "fileSize": f.FileSize, - "expiresIn": s.shareExpire, + return &AccessGrant{ + URL: url, + ViewMode: viewMode, + MimeType: f.MimeType, + FileName: f.OriginalName, + FileSize: f.FileSize, + ExpiresIn: s.shareExpire, }, nil } @@ -406,26 +425,31 @@ func (s *ShareService) BuildAccessFor(ctx context.Context, sf *shareFile, f *mod // pipeline parsers (docx/xlsx/pptx/markdown/html/text). The console gets // plain text, never a URL and never rendered HTML — an attacker-uploaded // .html must not execute on the app origin (stored XSS). -func (s *ShareService) BuildAdminReviewAccess(ctx context.Context, share *model.CloudShare, file *model.CloudFile) (map[string]any, error) { +func (s *ShareService) BuildAdminReviewAccess(ctx context.Context, share *model.CloudShare, file *model.CloudFile) (*AdminReviewAccess, error) { if file == nil { return nil, ErrShareNotFound } switch classifyViewMode(file.MimeType) { case "pdf", "video", "audio", "image": - return s.BuildAccessFor(ctx, &shareFile{Share: share, File: file}, file, false) + grant, err := s.BuildAccessFor(ctx, &shareFile{Share: share, File: file}, file, false) + if err != nil { + return nil, err + } + return &AdminReviewAccess{Grant: grant}, nil } text, truncated, err := s.adminReviewText(ctx, file) if err != nil { return nil, err } - return map[string]any{ - "viewMode": "text", - "mimeType": file.MimeType, - "fileName": file.OriginalName, - "fileSize": file.FileSize, - "expiresIn": 0, - "text": text, - "truncated": truncated, + return &AdminReviewAccess{ + Grant: &AccessGrant{ + ViewMode: "text", + MimeType: file.MimeType, + FileName: file.OriginalName, + FileSize: file.FileSize, + }, + Text: text, + Truncated: truncated, }, nil } @@ -520,42 +544,11 @@ func (s *ShareService) folderSubtreePaths(sh *model.CloudShare, subIDs []int64) } out := make(map[int64]string, len(subIDs)) for _, id := range subIDs { - out[id] = folderRelativePath(byID, *sh.FolderID, id) + out[id] = utils.FolderRelativePath(byID, *sh.FolderID, id) } return out, nil } -// folderRelativePath — pure path-label helper (unit-tested): "a/b" for a -// descendant folder, "" for the shared root itself. Defensive against -// dangling parents / cycles (bounded hops; a chain that never reaches the -// root reads as root-level rather than as a partial path). -func folderRelativePath(byID map[int64]model.CloudFolder, rootID, folderID int64) string { - if folderID == rootID { - return "" - } - var parts []string - cur, ok := byID[folderID] - reachedRoot := false - for hops := 0; ok && hops < 64; hops++ { - if cur.ID == rootID { - reachedRoot = true - break - } - parts = append(parts, cur.Name) - if cur.ParentID == nil { - break // not a descendant of the root - } - cur, ok = byID[*cur.ParentID] - } - if !reachedRoot { - return "" // dangling chain / corrupted tree — treat as root-level - } - for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { - parts[i], parts[j] = parts[j], parts[i] - } - return strings.Join(parts, "/") -} - // FolderShareFile — folder share access picker: fileID must be a LIVE file // inside the shared folder tree. Anything else reads as ErrShareNotFound // (uniform 404 — the tree structure stays non-enumerable). diff --git a/app-go/app-cloud/internal/service/summarizer.go b/app-go/app-cloud/internal/service/summarizer.go index 467f2b43..14548b75 100644 --- a/app-go/app-cloud/internal/service/summarizer.go +++ b/app-go/app-cloud/internal/service/summarizer.go @@ -27,6 +27,7 @@ type Summary struct { Topics []string `json:"topics" bson:"topics"` Entities []string `json:"entities" bson:"entities"` KeyPoints []string `json:"key_points" bson:"key_points"` + Todos []string `json:"todos" bson:"todos"` AnswersQuestions []string `json:"answers_questions" bson:"answers_questions"` Language string `json:"language" bson:"language"` SizeChars int `json:"size_chars" bson:"size_chars"` @@ -38,6 +39,7 @@ const systemPrompt = `你是文档摘要引擎。阅读用户提供的文档, "topics": ["3-8个主题词,名词短语,中文"], "entities": ["0-15个具名实体:人物/产品/技术/组织"], "key_points": ["3-6条关键论点,每条一句话"], + "todos": ["0-8条可执行待办/下一步,没有则空"], "answers_questions": ["2-5条这篇文档能回答的问题示例"], "language": "zh 或 en" } diff --git a/app-go/app-cloud/internal/service/summary.go b/app-go/app-cloud/internal/service/summary.go new file mode 100644 index 00000000..ed28d857 --- /dev/null +++ b/app-go/app-cloud/internal/service/summary.go @@ -0,0 +1,137 @@ +package service + +// Summary read model: the owner endpoint and the machine-to-machine endpoint +// both serve the summary the vectorize run persisted in MongoDB, plus the +// freshness verdict (did the document content change since the summary was +// generated?). + +import ( + "context" + "encoding/json" + "log/slog" + + "app-cloud/internal/infra/mongostore" + "app-cloud/internal/model" + "app-cloud/internal/repo" + + "gorm.io/gorm" +) + +// SummaryView — one file's summary state as served over HTTP. OriginalName +// and Title identify the file even when no summary exists yet (M2M callers +// render them as the source label). +type SummaryView struct { + UploadUUID string + OriginalName string + Title string + Available bool + Stale bool + Model string + Summary *Summary // nil when not generated yet +} + +// SummaryStore is the read port for stored summaries (implemented by +// mongostore.MongoStore; nil-safe). +type SummaryStore interface { + GetSummaryByUUID(ctx context.Context, uid int64, uploadUUID string) (*mongostore.StoredSummary, error) +} + +// FileLookup is the owner-scoped file lookup needed to validate a summary +// request (implemented by repo.FileRepo). +type FileLookup interface { + GetByUUID(db *gorm.DB, uploadUUID string, uid int64) (*model.CloudFile, error) +} + +// SummaryService resolves a file's summary for one owner. +type SummaryService struct { + db *gorm.DB + files FileLookup + store SummaryStore +} + +// NewSummaryService wires the production ports (file repo + Mongo store). +func NewSummaryService(db *gorm.DB, store SummaryStore) *SummaryService { + return NewSummaryServiceWithPorts(db, repo.NewFileRepo(), store) +} + +// NewSummaryServiceWithPorts builds the service over explicit ports. Besides +// wiring decisions, it lets tests substitute the file lookup and summary store +// without a live MySQL/Mongo. +func NewSummaryServiceWithPorts(db *gorm.DB, files FileLookup, store SummaryStore) *SummaryService { + return &SummaryService{db: db, files: files, store: store} +} + +// Get returns the summary view for one owner-scoped file. ErrNotFound means +// the file does not exist (or is trashed) for this owner; a file without a +// summary is not an error — the view simply reports available=false. +func (s *SummaryService) Get(ctx context.Context, uid int64, uploadUUID string) (*SummaryView, error) { + f, err := s.files.GetByUUID(s.db, uploadUUID, uid) + if err != nil { + return nil, err + } + if f == nil { + return nil, ErrNotFound + } + view := &SummaryView{UploadUUID: uploadUUID, OriginalName: f.OriginalName} + if f.Title != nil { + view.Title = *f.Title + } + if s.store == nil { + return view, nil + } + stored, err := s.store.GetSummaryByUUID(ctx, uid, uploadUUID) + if err != nil { + return nil, err + } + if stored == nil { + return view, nil + } + sum, derr := decodeStoredSummary(stored.Summary) + if derr != nil { + // A malformed stored summary must not fail the read: report it as + // unavailable so the UI offers a re-process instead of a 500. + slog.Warn("[CLOUD_SUMMARY] stored summary could not be decoded", + "upload_uuid", uploadUUID, "err", derr) + return view, nil + } + fileHash := "" + if f.ContentHash != nil { + fileHash = *f.ContentHash + } + view.Available = true + view.Model = stored.Model + view.Summary = sum + // Stale = the summary was generated from different content than the file + // currently has (re-uploaded/reprocessed without a fresh summary). + view.Stale = stored.ContentHash != fileHash + return view, nil +} + +// decodeStoredSummary maps the raw BSON summary document onto the typed +// summary. Documents written before a field existed decode with empty lists. +func decodeStoredSummary(raw map[string]any) (*Summary, error) { + encoded, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var sum Summary + if err := json.Unmarshal(encoded, &sum); err != nil { + return nil, err + } + if sum.Topics == nil { + sum.Topics = []string{} + } + if sum.Entities == nil { + sum.Entities = []string{} + } + if sum.KeyPoints == nil { + sum.KeyPoints = []string{} + } + if sum.Todos == nil { + sum.Todos = []string{} + } + if sum.AnswersQuestions == nil { + sum.AnswersQuestions = []string{} + } + return &sum, nil +} diff --git a/app-go/app-cloud/internal/service/upload.go b/app-go/app-cloud/internal/service/upload.go index 63602be6..7b83b7f3 100644 --- a/app-go/app-cloud/internal/service/upload.go +++ b/app-go/app-cloud/internal/service/upload.go @@ -2,7 +2,6 @@ package service import ( "context" - "crypto/rand" "encoding/json" "fmt" "log/slog" @@ -10,10 +9,11 @@ import ( "time" "app-cloud/internal/config" - "app-cloud/internal/metrics" "app-cloud/internal/infra/minio" + "app-cloud/internal/metrics" "app-cloud/internal/model" "app-cloud/internal/repo" + "app-cloud/internal/utils" "github.com/redis/go-redis/v9" "gorm.io/gorm" @@ -75,6 +75,17 @@ func extFromMime(mime string) string { return ".bin" } +// ASRStatusForMime — the initial asr_status of a freshly completed upload: +// only video/audio can be transcribed, everything else starts as +// not_supported so the UI never shows a pending badge that cannot resolve. +func ASRStatusForMime(mime string) string { + m := strings.ToLower(strings.TrimSpace(mime)) + if strings.HasPrefix(m, "video/") || strings.HasPrefix(m, "audio/") { + return "pending" + } + return "not_supported" +} + func mimeAllowed(mime string) bool { for _, p := range allowedMimePrefixes { if strings.HasPrefix(mime, p) { @@ -84,6 +95,50 @@ func mimeAllowed(mime string) bool { return false } +// UploadPresignedItem is one chunk's presigned PUT target. +type UploadPresignedItem struct { + ChunkIndex int64 + ChunkSize int64 + URL string +} + +// UploadInitResult tells the caller how to upload the object in chunks. +type UploadInitResult struct { + UploadUUID string + SessionUUID string + MinioUploadID string + ChunkCount int64 + ChunkSize int64 + PresignedURLs []UploadPresignedItem +} + +// UploadPart is one completed multipart part. +type UploadPart struct { + PartNumber int + ETag string +} + +// UploadCompleteResult acknowledges a finalized multipart upload. +type UploadCompleteResult struct { + UploadUUID string + ETag string + Status string +} + +// UploadResumeChunk is one chunk still to be (re)uploaded. +type UploadResumeChunk struct { + ChunkIndex int64 + ChunkSize int64 + URL string +} + +// UploadResumeResult re-issues presigned URLs for an interrupted upload. +type UploadResumeResult struct { + UploadUUID string + MinioUploadID string + PendingChunks []UploadResumeChunk +} + // UploadService — multipart upload lifecycle: init → presigned chunk PUTs → // heartbeat → complete. Upload metadata lives in Redis only (TTL 1h); the // cloud_files row is created on complete, so abandoned uploads leave no DB @@ -122,7 +177,7 @@ type uploadMeta struct { // InitUpload starts a multipart upload and returns presigned per-chunk URLs. func (s *UploadService) InitUpload(ctx context.Context, uid int64, filename string, - fileSize int64, mimeType string, folderID *int64) (map[string]any, error) { + fileSize int64, mimeType string, folderID *int64) (*UploadInitResult, error) { if !mimeAllowed(mimeType) { return nil, fmt.Errorf("Unsupported mime_type=%q", mimeType) } @@ -132,11 +187,11 @@ func (s *UploadService) InitUpload(ctx context.Context, uid int64, filename stri if fileSize > maxFileSize { return nil, fmt.Errorf("File too large: %d bytes (max %d)", fileSize, maxFileSize) } - uploadUUID, err := uuid7() + uploadUUID, err := utils.UUID7() if err != nil { return nil, err } - sessionUUID, err := uuid7() + sessionUUID, err := utils.UUID7() if err != nil { return nil, err } @@ -157,12 +212,7 @@ func (s *UploadService) InitUpload(ctx context.Context, uid int64, filename stri return nil, err } - type presignedItem struct { - ChunkIndex int64 `json:"chunkIndex"` - ChunkSize int64 `json:"chunkSize"` - URL string `json:"url"` - } - presigned := make([]presignedItem, 0, chunkCount) + presigned := make([]UploadPresignedItem, 0, chunkCount) for p := int64(1); p <= chunkCount; p++ { u, err := s.minio.PresignUploadPart(ctx, objectKey, minioUploadID, int(p)) if err != nil { @@ -171,7 +221,7 @@ func (s *UploadService) InitUpload(ctx context.Context, uid int64, filename stri s.pending.Remove(ctx, uid, uploadUUID) // release the reservation return nil, err } - presigned = append(presigned, presignedItem{ChunkIndex: p - 1, ChunkSize: chunkSize, URL: u}) + presigned = append(presigned, UploadPresignedItem{ChunkIndex: p - 1, ChunkSize: chunkSize, URL: u}) } meta := uploadMeta{ @@ -194,22 +244,19 @@ func (s *UploadService) InitUpload(ctx context.Context, uid int64, filename stri "filename", filename, "size", fileSize, "chunks", chunkCount, "upload_uuid", uploadUUID) - return map[string]any{ - "uploadUuid": uploadUUID, - "sessionUuid": sessionUUID, - "minioUploadId": minioUploadID, - "chunkCount": chunkCount, - "chunkSize": chunkSize, - "presignedUrls": presigned, + return &UploadInitResult{ + UploadUUID: uploadUUID, + SessionUUID: sessionUUID, + MinioUploadID: minioUploadID, + ChunkCount: chunkCount, + ChunkSize: chunkSize, + PresignedURLs: presigned, }, nil } // CompleteUpload finalises the multipart upload and creates the DB row. func (s *UploadService) CompleteUpload(ctx context.Context, uploadUUID string, - parts []struct { - PartNumber int - ETag string - }, uid int64) (map[string]any, error) { + parts []UploadPart, uid int64) (*UploadCompleteResult, error) { raw, err := s.rdb.Get(ctx, uploadKeyPrefix+uploadUUID).Result() if err != nil { return nil, fmt.Errorf("Upload session expired or not found: %s", uploadUUID) @@ -255,7 +302,10 @@ func (s *UploadService) CompleteUpload(ctx context.Context, uploadUUID string, } uploadStatus := "completed" - asrStatus := "pending" + // ASR only ever applies to video/audio. Every other file is marked + // not_supported up front so the UI does not wait forever on a pending + // badge that will never resolve. + asrStatus := ASRStatusForMime(meta.MimeType) vectorStatus := "pending" now := time.Now() row := &model.CloudFile{ @@ -290,10 +340,10 @@ func (s *UploadService) CompleteUpload(ctx context.Context, uploadUUID string, // Vectorization is user-triggered (process / reprocess endpoints) — an // upload completion never enqueues pipeline tasks by itself. - return map[string]any{ - "uploadUuid": uploadUUID, - "etag": etag, - "status": "completed", + return &UploadCompleteResult{ + UploadUUID: uploadUUID, + ETag: etag, + Status: "completed", }, nil } @@ -355,7 +405,7 @@ func (s *UploadService) Heartbeat(ctx context.Context, sessionUUID string, uid i } // ResumeUpload returns fresh presigned URLs for all chunks. -func (s *UploadService) ResumeUpload(ctx context.Context, uploadUUID string, uid int64) (map[string]any, error) { +func (s *UploadService) ResumeUpload(ctx context.Context, uploadUUID string, uid int64) (*UploadResumeResult, error) { raw, err := s.rdb.Get(ctx, uploadKeyPrefix+uploadUUID).Result() if err != nil { return nil, fmt.Errorf("Upload session expired or not found: %s", uploadUUID) @@ -367,49 +417,18 @@ func (s *UploadService) ResumeUpload(ctx context.Context, uploadUUID string, uid if meta.UID != uid { return nil, fmt.Errorf("Upload %s does not belong to user %d", uploadUUID, uid) } - type chunkItem struct { - ChunkIndex int64 `json:"chunkIndex"` - ChunkSize int64 `json:"chunkSize"` - URL string `json:"url"` - } - pending := make([]chunkItem, 0, meta.ChunkCount) + pending := make([]UploadResumeChunk, 0, meta.ChunkCount) for p := int64(1); p <= meta.ChunkCount; p++ { u, err := s.minio.PresignUploadPart(ctx, meta.ObjectKey, meta.MinioUploadID, int(p)) if err != nil { return nil, err } - pending = append(pending, chunkItem{ChunkIndex: p - 1, ChunkSize: meta.ChunkSize, URL: u}) + pending = append(pending, UploadResumeChunk{ChunkIndex: p - 1, ChunkSize: meta.ChunkSize, URL: u}) } slog.Info("[CLOUD_UPLOAD] resume_upload", "upload_uuid", uploadUUID, "chunks", len(pending)) - return map[string]any{ - "uploadUuid": uploadUUID, - "minioUploadId": meta.MinioUploadID, - "pendingChunks": pending, + return &UploadResumeResult{ + UploadUUID: uploadUUID, + MinioUploadID: meta.MinioUploadID, + PendingChunks: pending, }, nil } - -// uuid7 generates a time-ordered UUIDv7 string per RFC 9562: 48-bit -// unix-ms timestamp + version 7 + 12 random bits (rand_a) + variant 10 + -// 62 random bits (rand_b). UUIDs are opaque keys here (DB unique key, MinIO -// object path); nothing parses their internals, so the correct bit layout is -// freely chosen over any historical formatting quirk. -func uuid7() (string, error) { - const uuid7EpochMS = int64(1577836800000) // 2020-01-01T00:00:00Z - var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - r := uint64(0) - for _, by := range b { - r = r<<8 | uint64(by) - } - ts := uint64(time.Now().UnixMilli()-uuid7EpochMS) & 0xFFFFFFFFFFFF - timeLow := ts & 0xFFFFFFFF - timeMid := (ts >> 32) & 0xFFFF - timeHi := ((ts >> 48) & 0xFFF) | 0x7000 | uint64((r>>52)&0xFFF) - clockSeq := 0x8000 | uint64((r>>48)&0x3FFF) - node := r & 0xFFFFFFFFFFFF - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", timeLow, timeMid, timeHi, clockSeq, node), nil -} - - diff --git a/app-go/app-cloud/internal/service/util.go b/app-go/app-cloud/internal/service/util.go index 93f7e610..53644681 100644 --- a/app-go/app-cloud/internal/service/util.go +++ b/app-go/app-cloud/internal/service/util.go @@ -14,11 +14,12 @@ import ( "time" "unicode/utf8" + "app-cloud/internal/infra/mongostore" "app-cloud/internal/metrics" "app-cloud/internal/model" - "app-cloud/internal/infra/mongostore" "app-cloud/internal/pipeline" "app-cloud/internal/repo" + "app-cloud/internal/utils" "gorm.io/gorm" ) @@ -52,6 +53,9 @@ type CloudGraphWriter interface { // HTTP 429. Chinese text here is USER-FACING wire detail, not a comment. var ErrTooManyUserTasks = errors.New("该账号排队中的向量化任务已达上限,请等待完成后再试") var ErrQueueFull = errors.New("系统排队繁忙,当前任务较多,请稍后再试") +var ErrAlreadyProcessing = errors.New("该文件正在入库中,请等待完成后再试") +var ErrAlreadyDone = errors.New("该文件已入库,如需重新生成请使用「重新入库」") +var ErrReprocessTooSoon = errors.New("距上次入库结束不足 30 秒,请稍后再试") func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } @@ -94,7 +98,7 @@ type Pipeline struct { mongo *mongostore.MongoStore rdb RedisPublisher channel string - engine VectorEngine // nil = pipeline disabled + engine VectorEngine // nil = pipeline disabled quota *QuotaService // tier resolution for claim priority (nil → FIFO) summary *Summarizer // L1 summary generator (nil-safe via Enabled) kg *KgClient // L1 routing-graph writer (nil-safe via Enabled) @@ -131,24 +135,37 @@ func NewPipeline(db *gorm.DB, mongo *mongostore.MongoStore, rdb RedisPublisher, kg: kg, admissionMaxPerUID: admissionMaxPerUID, admissionMaxQueue: admissionMaxQueue, - wake: make(chan struct{}, 1), - stop: make(chan struct{}), - workerID: fmt.Sprintf("%s-%d", hostname, os.Getpid()), + wake: make(chan struct{}, 1), + stop: make(chan struct{}), + workerID: fmt.Sprintf("%s-%d", hostname, os.Getpid()), + } +} + +// TierStatusForTier maps a membership tier to a queue claim priority: paid +// tiers stay "pending", while free / empty / unknown tiers and inactive +// memberships degrade to "pending_low" — fail-closed, consistent with the +// quota stance. Priority is FIFO within a tier. Pure so the mapping is +// unit-testable without a quota service or database. +func TierStatusForTier(tier string, active bool) string { + if !active { + return "pending_low" + } + switch strings.ToLower(strings.TrimSpace(tier)) { + case "vip", "svip": + return "pending" + default: + return "pending_low" } } -// tierStatus maps the membership tier to a claim priority. Unknown/free → -// pending_low (fail-closed, consistent with the quota stance); paid tiers → -// pending. Priority is FIFO within a tier. +// tierStatus maps a user's membership tier to a claim priority via the pure +// mapping. Without a quota service the legacy FIFO status is used. func (p *Pipeline) tierStatus(uid int64) string { if p.quota == nil { return "pending" } tier, _ := p.quota.TierFor(context.Background(), uid) - if tier == "free" { - return "pending_low" - } - return "pending" + return TierStatusForTier(tier, true) } // SetEngine attaches the vector engine (nil disables the pipeline). @@ -159,14 +176,9 @@ func (p *Pipeline) SetEngine(e VectorEngine) { p.engine = e } // row in "processing" forever. const pipelineTimeout = 5 * time.Minute -// docDisplayTitle — the title used for summaries, embedding prefixes and -// graph nodes (user-set title wins over the original file name). -func docDisplayTitle(f *model.CloudFile) string { - if f.Title != nil && strings.TrimSpace(*f.Title) != "" { - return *f.Title - } - return f.OriginalName -} +// reprocessCooldown is the per-file guard against repeated manual re-ingests +// (each one re-runs OCR, summary and embedding = model tokens). +const reprocessCooldown = 30 * time.Second // staleProcessingAfter is how long a row may sit in "processing" before the // status endpoint reconciles it to failed (crash / panic / hang guard). Must @@ -184,6 +196,9 @@ func (p *Pipeline) Trigger(ctx context.Context, uploadUUID string, uid int64) er if f == nil { return ErrNotFound } + if f.VectorStatus != nil && *f.VectorStatus == "done" { + return ErrAlreadyDone + } // Admission control (capacity layer): cap per-user in-flight work and the // global backlog BEFORE accepting the task — a burst sheds load as 429s // instead of starving the LLM/embedding gateways behind the workers. @@ -198,11 +213,15 @@ func (p *Pipeline) Trigger(ctx context.Context, uploadUUID string, uid int64) er return ErrQueueFull } } - // Mark pending; the claiming worker moves it to processing and pushes the - // WS status, so states stay truthful while the task waits in the queue. - if err := p.files.UpdateMeta(p.db, uploadUUID, uid, map[string]any{"vector_status": "pending"}); err != nil { + // Atomic idempotency gate: repeated "入库" clicks while a run is pending + // or processing must not enqueue/re-run OCR + summary + embedding. + claimed, err := p.files.ClaimVectorPending(p.db, uploadUUID, uid) + if err != nil { return err } + if !claimed { + return ErrAlreadyProcessing + } return p.EnqueueTask(ctx, uploadUUID, uid) } @@ -218,12 +237,24 @@ func (p *Pipeline) Reprocess(ctx context.Context, uploadUUID string, uid int64) if f == nil { return "", ErrNotFound } - taskID := uploadUUID + ":" + time.Now().Format("20060102150405") - // reset to pending so RunSync re-runs despite idempotency hash - if err := p.files.UpdateMeta(p.db, uploadUUID, uid, - map[string]any{"vector_status": "pending"}); err != nil { + if f.VectorStatus != nil && (*f.VectorStatus == "pending" || *f.VectorStatus == "processing") { + return "", ErrAlreadyProcessing + } + // A reprocess re-runs OCR/summary/embedding from scratch. Rate-limit it + // per file: a finished task row's updated_at is its finish time, so rapid + // repeated clicks are rejected before any model tokens are spent. + if updatedAt, ok, err := p.tasks.LastUpdatedForUploadUUID(p.db, uploadUUID); err == nil && ok && + time.Since(updatedAt) < reprocessCooldown { + return "", ErrReprocessTooSoon + } + claimed, err := p.files.ClaimVectorPending(p.db, uploadUUID, uid) + if err != nil { return "", err } + if !claimed { + return "", ErrAlreadyProcessing + } + taskID := uploadUUID + ":" + time.Now().Format("20060102150405") if err := p.EnqueueTask(ctx, uploadUUID, uid); err != nil { return "", err } @@ -261,7 +292,7 @@ func (p *Pipeline) RunSync(ctx context.Context, f *model.CloudFile) (int, error) hooks := pipeline.VectorizeHooks{} if p.summary.Enabled() { hooks.AfterExtract = func(ctx context.Context, f *model.CloudFile, text, contentHash string) { - sum, serr := p.summary.Summarize(ctx, docDisplayTitle(f), text) + sum, serr := p.summary.Summarize(ctx, utils.DisplayTitle(f), text) if serr != nil { slog.Warn("[PIPELINE] summary failed — skipping (non-fatal)", "upload_uuid", f.UploadUUID, "err", serr) metrics.SummaryTotal.WithLabelValues("error").Inc() @@ -275,7 +306,7 @@ func (p *Pipeline) RunSync(ctx context.Context, f *model.CloudFile) (int, error) } metrics.SummaryTotal.WithLabelValues("ok").Inc() if p.kg.Enabled() { - if gerr := p.kg.UpsertCloudDocGraph(ctx, f.UID, f.UploadUUID, docDisplayTitle(f), contentHash, sum.Topics, sum.Entities); gerr != nil { + if gerr := p.kg.UpsertCloudDocGraph(ctx, f.UID, f.UploadUUID, utils.DisplayTitle(f), contentHash, sum.Topics, sum.Entities); gerr != nil { slog.Warn("[PIPELINE] kg graph write failed — skipping (non-fatal)", "upload_uuid", f.UploadUUID, "err", gerr) metrics.KgWriteTotal.WithLabelValues("error").Inc() return @@ -297,7 +328,7 @@ func (p *Pipeline) RunSync(ctx context.Context, f *model.CloudFile) (int, error) // hits can never succeed on a retry. if errors.Is(err, pipeline.ErrEncryptedPDF) || errors.Is(err, pipeline.ErrEmptyPDFText) || errors.Is(err, pipeline.ErrNoParser) || errors.Is(err, pipeline.ErrDocTooLarge) || - errors.Is(err, pipeline.ErrZipEntryTooLarge) { + errors.Is(err, pipeline.ErrZipEntryTooLarge) || pipeline.IsPermanentOCRError(err) { _ = p.files.UpdateMeta(p.db, uploadUUID, uid, map[string]any{ "vectorizable": false, "vector_status": "not_supported", }) diff --git a/app-go/app-cloud/internal/service/util_test.go b/app-go/app-cloud/internal/service/util_test.go deleted file mode 100644 index 63483315..00000000 --- a/app-go/app-cloud/internal/service/util_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package service - -import ( - "testing" - - "app-cloud/internal/model" - "time" - - "app-cloud/internal/config" -) - -// seedTier pre-populates the quota tier cache so tierStatus can be tested -// without any HTTP or DB traffic. -func seedTier(q *QuotaService, uid int64, tier string, active bool) { - q.mu.Lock() - defer q.mu.Unlock() - q.cache[uid] = quotaCacheEntry{tier: tier, active: active, expiresAt: time.Now().Add(time.Hour)} -} - -// Claim priority: paid tiers drain before free. Unknown tier (quota cache -// miss / upstream failure) reads as free — fail-closed, consistent with the -// quota stance. -func TestTierStatusMapsMembershipTier(t *testing.T) { - q := NewQuotaService(&config.Config{}, nil, nil) - p := &Pipeline{quota: q} - - seedTier(q, 1, "svip", true) - seedTier(q, 2, "vip", true) - seedTier(q, 3, "free", true) - seedTier(q, 4, "vip", false) // inactive membership reads as free - - cases := map[int64]string{1: "pending", 2: "pending", 3: "pending_low", 4: "pending_low"} - for uid, want := range cases { - if got := p.tierStatus(uid); got != want { - t.Fatalf("uid %d: tierStatus = %q, want %q", uid, got, want) - } - } -} - -// Without a quota service the legacy FIFO status is used (parity with the -// pre-priority behavior; queue-wide ordering degrades to id order). -func TestTierStatusWithoutQuotaService(t *testing.T) { - p := &Pipeline{} - if got := p.tierStatus(1); got != "pending" { - t.Fatalf("nil quota: tierStatus = %q, want pending", got) - } -} - -// docDisplayTitle — user-set title wins over the original file name. -func modelFileForTest(title string) model.CloudFile { - return model.CloudFile{Title: strPtr(title), OriginalName: "原名.xlsx"} -} - -func strPtr(s string) *string { return &s } - -func TestDocDisplayTitle(t *testing.T) { - title := "用户标题" - f1 := modelFileForTest(title) - if got := docDisplayTitle(&f1); got != "用户标题" { - t.Fatalf("got %q", got) - } - f2 := modelFileForTest("") - if got := docDisplayTitle(&f2); got != "原名.xlsx" { - t.Fatalf("empty title falls back to file name, got %q", got) - } -} diff --git a/app-go/app-cloud/internal/service/workerpool.go b/app-go/app-cloud/internal/service/workerpool.go index 6e374967..4119fffb 100644 --- a/app-go/app-cloud/internal/service/workerpool.go +++ b/app-go/app-cloud/internal/service/workerpool.go @@ -81,10 +81,10 @@ func (p *Pipeline) spawnWorker(i int) { // pool (never shrinks — StopWorkers tears everything down) toward maxWorkers // when pending > 2x workers. Growth-only keeps lifecycle management trivial; // an idle extra worker costs one goroutine each. -// nextWorkerCount — the elastic growth rule (pure, tested): double the pool -// while pending exceeds twice the current size, capped at maxWorkers, and -// never shrink below the current count (growth-only). -func nextWorkerCount(cur, pending, max int) int { +// NextWorkerCount — the elastic growth rule (pure): double the pool while +// pending exceeds twice the current size, capped at maxWorkers, and never +// shrink below the current count (growth-only; teardown is wholesale). +func NextWorkerCount(cur, pending, max int) int { want := cur if pending > cur*2 { want = cur * 2 @@ -110,7 +110,7 @@ func (p *Pipeline) elasticLoop() { } p.workersMu.Lock() cur := p.curWorkers - want := nextWorkerCount(cur, int(pending), p.maxWorkers) + want := NextWorkerCount(cur, int(pending), p.maxWorkers) for i := cur; i < want; i++ { p.curWorkers = i + 1 p.wg.Add(1) diff --git a/app-go/app-cloud/internal/utils/domain.go b/app-go/app-cloud/internal/utils/domain.go new file mode 100644 index 00000000..c75c4723 --- /dev/null +++ b/app-go/app-cloud/internal/utils/domain.go @@ -0,0 +1,84 @@ +package utils + +// Additional pure, domain-specific helpers shared by the drive's service +// layer: folder path labels, mirrored folder names and uuid generation. They +// live here so they can be unit-tested (and reused) without exporting service +// internals. + +import ( + "crypto/rand" + "fmt" + "strings" + "time" + + "app-cloud/internal/model" +) + +// FolderRelativePath returns the path label of folderID relative to rootID: +// "a/b" for a descendant folder, "" for the shared root itself. Defensive +// against dangling parents / cycles (bounded hops; a chain that never reaches +// the root reads as root-level rather than as a partial path). +func FolderRelativePath(byID map[int64]model.CloudFolder, rootID, folderID int64) string { + if folderID == rootID { + return "" + } + var parts []string + cur, ok := byID[folderID] + reachedRoot := false + for hops := 0; ok && hops < 64; hops++ { + if cur.ID == rootID { + reachedRoot = true + break + } + parts = append(parts, cur.Name) + if cur.ParentID == nil { + break // not a descendant of the root + } + cur, ok = byID[*cur.ParentID] + } + if !reachedRoot { + return "" // dangling chain / corrupted tree — treat as root-level + } + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + return strings.Join(parts, "/") +} + +// RefFolderNameMaxBytes — cloud_folders.name is varchar(200); keep room for +// the "-引用(N)" suffix (UTF-8 byte truncation, rune-safe). +const RefFolderNameMaxBytes = 200 - 16 + +// RefFolderName rune-safe truncates a mirrored folder name to the folder +// column limit without splitting a multi-byte rune. +func RefFolderName(name string, maxBytes int) string { + for len(name) > maxBytes { + runes := []rune(name) + name = string(runes[:len(runes)-1]) + } + return name +} + +// UUID7 generates a time-ordered UUIDv7 string per RFC 9562: 48-bit unix-ms +// timestamp + version 7 + 12 random bits (rand_a) + variant 10 + 62 random +// bits (rand_b). Uuids are opaque keys (DB unique key, MinIO object path); +// nothing parses their internals, so the bit layout is chosen for correctness +// rather than any historical formatting quirk. +func UUID7() (string, error) { + const uuid7EpochMS = int64(1577836800000) // 2020-01-01T00:00:00Z + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + r := uint64(0) + for _, by := range b { + r = r<<8 | uint64(by) + } + ts := uint64(time.Now().UnixMilli()-uuid7EpochMS) & 0xFFFFFFFFFFFF + timeLow := ts & 0xFFFFFFFF + timeMid := (ts >> 32) & 0xFFFF + timeHi := ((ts >> 48) & 0xFFF) | 0x7000 | uint64((r>>52)&0xFFF) + clockSeq := 0x8000 | uint64((r>>48)&0x3FFF) + node := r & 0xFFFFFFFFFFFF + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", timeLow, timeMid, timeHi, clockSeq, node), nil +} diff --git a/app-go/app-cloud/internal/utils/strings.go b/app-go/app-cloud/internal/utils/strings.go new file mode 100644 index 00000000..02362203 --- /dev/null +++ b/app-go/app-cloud/internal/utils/strings.go @@ -0,0 +1,53 @@ +// Package utils holds the pure helpers shared across app-cloud packages +// (pipeline / service / repo / milvusstore). Keep it small and dependency-free +// so it cannot become a dumping ground for business logic. +package utils + +import ( + "strings" + "unicode/utf8" + + "app-cloud/internal/model" +) + +// DisplayTitle is the canonical display title for a drive file: the user-set +// title when present, otherwise the original file name. Used for embeddings, +// summaries and search results so every surface labels a file the same way. +func DisplayTitle(f *model.CloudFile) string { + if f == nil { + return "" + } + if f.Title != nil && strings.TrimSpace(*f.Title) != "" { + return *f.Title + } + return f.OriginalName +} + +// TruncateBytes returns s cut to at most n bytes without splitting a +// multi-byte UTF-8 rune. Used before writing into fixed-width storage columns +// (Milvus varchar fields, MySQL error text). +func TruncateBytes(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) <= n { + return s + } + cut := s[:n] + for len(cut) > 0 && !utf8.ValidString(cut) { + cut = cut[:len(cut)-1] + } + return cut +} + +// TruncateRunes returns s cut to at most n runes (display/snippet limit). +func TruncateRunes(s string, n int) string { + if n <= 0 { + return "" + } + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} diff --git a/app-go/app-cloud/main.go b/app-go/app-cloud/main.go index 2e6cba83..a3e02f17 100644 --- a/app-go/app-cloud/main.go +++ b/app-go/app-cloud/main.go @@ -128,10 +128,29 @@ func main() { // L1 routing: summary generator + graph writer. Both best-effort — the // pipeline tolerates either being absent. docSummary := service.NewSummarizer(cfg.Pipeline.Summary, cfg.Pipeline.Embedding.APIKey, cfg.Pipeline.Embedding.BaseURL) + // Vision OCR: images, screenshots and image-only PDFs. The gateway and + // credentials are resolved from the OCR config with the embedding + // connection as fallback; unconfigured means those files stay + // not_supported. + ocrClient := pipelinepkg.NewOCRClient(cfg.Pipeline.OCR, cfg.Pipeline.Embedding) + if ocrClient.Enabled() { + slog.Info("[PIPELINE] vision OCR attached", "model", ocrClient.ModelName(), + "max_image_bytes", cfg.Pipeline.OCR.MaxImageBytes, + "max_pdf_pages", cfg.Pipeline.OCR.MaxPDFPages, + "max_images", cfg.Pipeline.OCR.MaxImages) + } else { + slog.Info("[PIPELINE] vision OCR not configured — images and scanned PDFs stay not_supported") + } + // Summary read model (owner + M2M endpoints); Mongo may be disabled, in + // which case every file simply reports available=false. + summarySvc := service.NewSummaryService(db.DB, mongoStore) kgClient := service.NewKgClient(cfg.Kg) pipe := service.NewPipeline(db.DB, mongoStore, redisPublisher{rdb}, cfg.WSBridge.Channel, quotaSvc, docSummary, kgClient, cfg.Pipeline.Admission.MaxPerUID, cfg.Pipeline.Admission.MaxQueueDepth) - // Engine: extractors → chunk → embed (Higress) → Milvus cloud_drive. + // Engine: extractors (text + OCR) → chunk → embed (Higress) → Milvus + // cloud_drive. The same collection/embedding also serves the read path + // (semantic search / similar files) when attached below. + var searchSvc *service.SearchService if cfg.Pipeline.Enabled && cfg.Pipeline.Milvus.Enabled && strings.TrimSpace(cfg.Pipeline.Milvus.URI) != "" { // Bounded dial: Milvus unreachable (e.g. storage profile not started) @@ -143,9 +162,13 @@ func main() { milvusCancel() switch { case merr == nil: + embedder := pipelinepkg.NewEmbedder(cfg.Pipeline.Embedding) engine := pipelinepkg.NewEngine(db.DB, mc, mongoStore, milvusStore, - pipelinepkg.NewEmbedder(cfg.Pipeline.Embedding), cfg.Pipeline.MaxDocSize) + embedder, cfg.Pipeline.MaxDocSize) + engine.SetOCR(ocrClient) pipe.SetEngine(engine) + // Read path over the same vectors: query-time only, uid filtered. + searchSvc = service.NewSearchService(db.DB, embedder, milvusStore, summarySvc) slog.Info("[PIPELINE] vector engine attached", "collection", cfg.Pipeline.Milvus.Collection, "model", cfg.Pipeline.Embedding.Model, "dim", cfg.Pipeline.Embedding.Dimension) case strings.HasPrefix(merr.Error(), "MILVUS SCHEMA GUARD"): @@ -213,6 +236,8 @@ func main() { Share: shareSvc, Ref: refSvc, Attestor: attestor, + Summary: summarySvc, + Search: searchSvc, }) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) srv := &http.Server{Addr: addr, Handler: handler} diff --git a/app-go/app-cloud/internal/service/attest_test.go b/app-go/app-cloud/tests/attest_test.go similarity index 62% rename from app-go/app-cloud/internal/service/attest_test.go rename to app-go/app-cloud/tests/attest_test.go index 826d2d4c..730e3852 100644 --- a/app-go/app-cloud/internal/service/attest_test.go +++ b/app-go/app-cloud/tests/attest_test.go @@ -1,23 +1,24 @@ -package service +package tests import ( "bytes" "testing" "time" + + "app-cloud/internal/service" ) -// P0-2 regression: challenges/tickets are stateless HMACs — every replica -// must verify what any other replica issued. Identical shared secrets make -// that exact; boot-random keys make it impossible (documents why the shared -// secret is mandatory for multi-instance deployments). +// Challenges/tickets are stateless HMACs — every replica must verify what any +// other replica issued. Identical shared secrets make that exact; boot-random +// keys make it impossible (the shared secret is mandatory for multi-instance). func TestDeviceAttestorSharedSecretCrossInstance(t *testing.T) { shared := bytes.Repeat([]byte("mindbase-attest-shared-secret!"), 2) // >= 32 bytes - a := NewDeviceAttestor(shared) - b := NewDeviceAttestor(shared) + a := service.NewDeviceAttestor(shared) + b := service.NewDeviceAttestor(shared) token := "tok" now := time.Now() - // challenge issued by a, verified by b (nginx routed the attest call to replica b) + // challenge issued by a, verified by b (nginx routed the attest call to b) ch := a.Issue(token, now) if !b.VerifyChallenge(token, ch, now.Add(time.Second)) { t.Fatal("replica b rejected a challenge issued by replica a (shared secret broken)") @@ -31,26 +32,26 @@ func TestDeviceAttestorSharedSecretCrossInstance(t *testing.T) { } // a different secret must not verify (secret rotation / foreign deployment) - c := NewDeviceAttestor(bytes.Repeat([]byte("other-secret-other-secret-other-s"), 2)) + c := service.NewDeviceAttestor(bytes.Repeat([]byte("other-secret-other-secret-other-s"), 2)) if c.VerifyChallenge(token, ch, now.Add(time.Second)) { t.Fatal("challenge verified under a different secret") } } func TestDeviceAttestorBootRandomIsolated(t *testing.T) { - a := NewDeviceAttestor(nil) - b := NewDeviceAttestor(nil) + a := service.NewDeviceAttestor(nil) + b := service.NewDeviceAttestor(nil) ch := a.Issue("tok", time.Now()) if b.VerifyChallenge("tok", ch, time.Now().Add(time.Second)) { - t.Fatal("boot-random secrets must not cross-verify (documents the multi-replica breakage the shared secret fixes)") + t.Fatal("boot-random secrets must not cross-verify (the multi-replica breakage the shared secret fixes)") } } func TestDeviceAttestorExpiryAndTokenBinding(t *testing.T) { - a := NewDeviceAttestor(bytes.Repeat([]byte("mindbase-attest-shared-secret!"), 2)) + a := service.NewDeviceAttestor(bytes.Repeat([]byte("mindbase-attest-shared-secret!"), 2)) now := time.Now() ch := a.Issue("tok", now) - if a.VerifyChallenge("tok", ch, now.Add(ChallengeTTL+time.Second)) { + if a.VerifyChallenge("tok", ch, now.Add(service.ChallengeTTL+time.Second)) { t.Fatal("expired challenge accepted") } if a.VerifyChallenge("other", ch, now.Add(time.Second)) { diff --git a/app-go/app-cloud/tests/content_review_test.go b/app-go/app-cloud/tests/content_review_test.go new file mode 100644 index 00000000..ffac3ba2 --- /dev/null +++ b/app-go/app-cloud/tests/content_review_test.go @@ -0,0 +1,26 @@ +package tests + +// Content-review disclosure guards: the second, SEPARATE acknowledgment must +// stay backend-enforced at creation time and must never silently reuse the +// legacy legalAck. + +import ( + "testing" + + "app-cloud/internal/service" +) + +func TestContentReviewAckErrorCopy(t *testing.T) { + if service.ErrContentReviewAckRequired.Error() != "请单独确认管理员内容审核告知" { + t.Fatalf("content review ack copy drifted: %q", service.ErrContentReviewAckRequired.Error()) + } + if service.ErrContentReviewAckRequired == service.ErrLegalAckRequired { + t.Fatal("the two acknowledgments must be distinct errors") + } +} + +func TestContentReviewPolicyVersionPinned(t *testing.T) { + if service.ContentReviewPolicyVersion == "" { + t.Fatal("policy version must not be empty — ack rows must be traceable to a wording version") + } +} diff --git a/app-go/app-cloud/tests/document_parse_test.go b/app-go/app-cloud/tests/document_parse_test.go new file mode 100644 index 00000000..e78f391a --- /dev/null +++ b/app-go/app-cloud/tests/document_parse_test.go @@ -0,0 +1,176 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "image" + "image/color" + "image/png" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "app-cloud/internal/pipeline" + + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +// testPNG renders a small valid PNG for the OCR/PDF fixtures. +func testPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := 0; y < 8; y++ { + for x := 0; x < 8; x++ { + img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +// imageOnlyPDF builds a valid PDF whose only page content is the given images +// (no text operators) — the scanned-document shape. +func imageOnlyPDF(t *testing.T, images ...[]byte) []byte { + t.Helper() + readers := make([]io.Reader, len(images)) + for i := range images { + readers[i] = bytes.NewReader(images[i]) + } + var out bytes.Buffer + if err := api.ImportImages(nil, &out, readers, nil, nil); err != nil { + t.Fatalf("build image-only pdf: %v", err) + } + return out.Bytes() +} + +func TestImageFileParsedThroughOCR(t *testing.T) { + srv, calls := ocrGateway(t, []string{"图片里的文字"}) + defer srv.Close() + + parsed, err := pipeline.ParseDocument(context.Background(), testOCRClient(t, srv.URL), + "image/png", "screenshot.png", testPNG(t)) + if err != nil { + t.Fatalf("parse image: %v", err) + } + if parsed.Source != "ocr" { + t.Fatalf("source = %q, want ocr", parsed.Source) + } + if !strings.Contains(parsed.Text, "图片里的文字") { + t.Fatalf("text = %q", parsed.Text) + } + if got := atomic.LoadInt32(calls); got != 1 { + t.Fatalf("calls = %d, want 1", got) + } +} + +func TestImageOCRDisabledIsPermanentSkip(t *testing.T) { + _, err := pipeline.ParseDocument(context.Background(), disabledOCRClient(), + "image/png", "screenshot.png", testPNG(t)) + if !errors.Is(err, pipeline.ErrOCRDisabled) || !pipeline.IsPermanentOCRError(err) { + t.Fatalf("want permanent ErrOCRDisabled, got %v", err) + } +} + +func TestScannedPDFParsedByPageImages(t *testing.T) { + srv, calls := ocrGateway(t, []string{"第一页文字", "第二页文字"}) + defer srv.Close() + + parsed, err := pipeline.ParseDocument(context.Background(), testOCRClient(t, srv.URL), + "application/pdf", "scan.pdf", imageOnlyPDF(t, testPNG(t), testPNG(t))) + if err != nil { + t.Fatalf("parse scanned pdf: %v", err) + } + if parsed.Source != "ocr" { + t.Fatalf("source = %q, want ocr", parsed.Source) + } + if !strings.Contains(parsed.Text, "第一页文字") || !strings.Contains(parsed.Text, "第二页文字") { + t.Fatalf("page texts missing: %q", parsed.Text) + } + if got := atomic.LoadInt32(calls); got != 2 { + t.Fatalf("calls = %d, want one per page image (2)", got) + } +} + +func TestScannedPDFOCRDisabledStaysEmptyPdfSkip(t *testing.T) { + _, err := pipeline.ParseDocument(context.Background(), disabledOCRClient(), + "application/pdf", "scan.pdf", imageOnlyPDF(t, testPNG(t))) + if !errors.Is(err, pipeline.ErrEmptyPDFText) { + t.Fatalf("want ErrEmptyPDFText when OCR is off, got %v", err) + } +} + +// A gateway outage must stay retryable (failed, not a permanent skip). +func TestScannedPDFTransientOCRFailureIsRetryable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + + _, err := pipeline.ParseDocument(context.Background(), testOCRClient(t, srv.URL), + "application/pdf", "scan.pdf", imageOnlyPDF(t, testPNG(t))) + if err == nil { + t.Fatal("persistent gateway outage must fail the parse") + } + if pipeline.IsPermanentOCRError(err) { + t.Fatalf("gateway outage must stay retryable, got permanent: %v", err) + } +} + +// Scanned PDF whose images contain no readable text is a permanent skip. +func TestScannedPDFNoReadableTextIsPermanent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": ""}}}, + }) + })) + defer srv.Close() + + _, err := pipeline.ParseDocument(context.Background(), testOCRClient(t, srv.URL), + "application/pdf", "scan.pdf", imageOnlyPDF(t, testPNG(t))) + if !errors.Is(err, pipeline.ErrOCRNoContent) || !pipeline.IsPermanentOCRError(err) { + t.Fatalf("want permanent ErrOCRNoContent, got %v", err) + } +} + +// Plain text documents never touch the vision gateway. +func TestTextDocumentDoesNotCallOCR(t *testing.T) { + srv, calls := ocrGateway(t, []string{"不应调用"}) + defer srv.Close() + + parsed, err := pipeline.ParseDocument(context.Background(), testOCRClient(t, srv.URL), + "text/plain", "notes.txt", []byte("普通文本正文")) + if err != nil { + t.Fatalf("parse text: %v", err) + } + if parsed.Source != "text" || !strings.Contains(parsed.Text, "普通文本正文") { + t.Fatalf("text parser result wrong: %+v", parsed) + } + if got := atomic.LoadInt32(calls); got != 0 { + t.Fatalf("text documents must not hit OCR; calls = %d", got) + } +} + +// Unsupported image media (e.g. TIFF) stays a permanent skip instead of +// burning gateway retries. +func TestUnsupportedImageMediaIsPermanent(t *testing.T) { + srv, calls := ocrGateway(t, []string{"不应调用"}) + defer srv.Close() + + _, err := pipeline.ParseDocument(context.Background(), testOCRClient(t, srv.URL), + "image/tiff", "scan.tiff", []byte{0x49, 0x49, 0x2a, 0x00}) + if !errors.Is(err, pipeline.ErrOCRUnsupportedImage) || !pipeline.IsPermanentOCRError(err) { + t.Fatalf("want permanent ErrOCRUnsupportedImage, got %v", err) + } + if got := atomic.LoadInt32(calls); got != 0 { + t.Fatalf("unsupported media must not reach the gateway; calls = %d", got) + } +} diff --git a/app-go/app-cloud/tests/fixtures_test.go b/app-go/app-cloud/tests/fixtures_test.go new file mode 100644 index 00000000..33425214 --- /dev/null +++ b/app-go/app-cloud/tests/fixtures_test.go @@ -0,0 +1,217 @@ +// Package tests holds the black-box suite for the cloud document +// intelligence features: OCR, per-file summary, semantic search/similar files +// and the internal machine-to-machine endpoints. Everything here goes through +// the exported API only, so internal layout stays free to change. +package tests + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "app-cloud/internal/config" + "app-cloud/internal/infra/mongostore" + "app-cloud/internal/model" + "app-cloud/internal/pipeline" + "app-cloud/internal/router" + "app-cloud/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ── OCR test helpers ──────────────────────────────────────────────── + +// testOCRClient points an OCR client at a test gateway. +func testOCRClient(t *testing.T, baseURL string) *pipeline.OCRClient { + t.Helper() + cfg := config.OCRConfig{ + Enabled: true, Model: "z-ai/glm-test", + MaxImageBytes: 1 << 20, MaxPDFPages: 5, MaxImages: 3, TimeoutSecs: 5, + } + // OCR has no provider connection of its own: it always uses the shared + // Higress gateway connection held by the embedding config. + return pipeline.NewOCRClient(cfg, config.EmbeddingConfig{BaseURL: baseURL, APIKey: "test-key"}) +} + +// disabledOCRClient is a client whose stage is switched off. +func disabledOCRClient() *pipeline.OCRClient { + return pipeline.NewOCRClient(config.OCRConfig{}, config.EmbeddingConfig{}) +} + +// ocrGateway answers OCR calls with the given texts in call order. +func ocrGateway(t *testing.T, texts []string) (*httptest.Server, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + text := texts[(int(n)-1)%len(texts)] + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": text}}}, + }) + })) + return srv, &calls +} + +// ── fake file catalog (owner-scoped reads) ────────────────────────── + +type fakeFileCatalog struct { + files map[string]*model.CloudFile + folderFiles []model.CloudFile + err error +} + +func (f *fakeFileCatalog) GetByUUID(_ *gorm.DB, uploadUUID string, uid int64) (*model.CloudFile, error) { + if f.err != nil { + return nil, f.err + } + file := f.files[uploadUUID] + if file == nil || file.UID != uid { + return nil, nil + } + return file, nil +} + +func (f *fakeFileCatalog) ListByUUIDs(_ *gorm.DB, uid int64, uploadUUIDs []string) ([]model.CloudFile, error) { + if f.err != nil { + return nil, f.err + } + out := make([]model.CloudFile, 0, len(uploadUUIDs)) + for _, uuid := range uploadUUIDs { + if file := f.files[uuid]; file != nil && file.UID == uid { + out = append(out, *file) + } + } + return out, nil +} + +func (f *fakeFileCatalog) ListActiveByFolderIDs(_ *gorm.DB, uid int64, folderIDs []int64) ([]model.CloudFile, error) { + if f.err != nil { + return nil, f.err + } + allowed := make(map[int64]bool, len(folderIDs)) + for _, id := range folderIDs { + allowed[id] = true + } + out := make([]model.CloudFile, 0) + for i := range f.folderFiles { + row := f.folderFiles[i] + if row.UID == uid && row.FolderID != nil && allowed[*row.FolderID] { + out = append(out, row) + } + } + return out, nil +} + +// ── fake summary store ────────────────────────────────────────────── + +type fakeSummaryStore struct { + stored map[string]*mongostore.StoredSummary + err error +} + +func (s *fakeSummaryStore) GetSummaryByUUID(_ context.Context, uid int64, uploadUUID string) (*mongostore.StoredSummary, error) { + if s.err != nil { + return nil, s.err + } + return s.stored[uploadUUID], nil +} + +// ── fake vector ports ─────────────────────────────────────────────── + +type fakeEmbedder struct { + vectors [][]float32 + err error + gotTexts []string +} + +func (e *fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) { + e.gotTexts = append(e.gotTexts, texts...) + if e.err != nil { + return nil, e.err + } + if e.vectors != nil { + return e.vectors, nil + } + return [][]float32{{0.1, 0.2}}, nil +} + +type fakeSemanticIndex struct { + hits []model.ChunkHit + err error + gotFilter string + gotLimit int + calls int +} + +func (i *fakeSemanticIndex) SearchChunks(_ context.Context, vector []float32, filter string, limit int) ([]model.ChunkHit, error) { + i.calls++ + i.gotFilter, i.gotLimit = filter, limit + if i.err != nil { + return nil, i.err + } + return i.hits, nil +} + +type fakeFolderTree struct { + ids []int64 + err error + calls int +} + +func (f *fakeFolderTree) SubtreeIDs(_ *gorm.DB, id int64, uid int64, includeChildren bool) ([]int64, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.ids, nil +} + +// ── small value helpers ───────────────────────────────────────────── + +func strPtr(s string) *string { return &s } + +func int64Ptr(v int64) *int64 { return &v } + +func cloudFile(uuid string, uid int64) *model.CloudFile { + return &model.CloudFile{ + UploadUUID: uuid, UID: uid, OriginalName: uuid + ".pdf", + MimeType: "application/pdf", FileSize: 100, + } +} + +// ── router helpers ────────────────────────────────────────────────── + +// newAIRouter builds the real HTTP engine with the document-intelligence +// services backed by in-memory fixtures. +func newAIRouter(t *testing.T, internalKey string, + summary *service.SummaryService, search *service.SearchService) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + cfg := &config.Config{} + cfg.Internal.APIKey = internalKey + return router.New(router.Deps{Cfg: cfg, Summary: summary, Search: search}) +} + +// doJSON issues a request and decodes the JSON response body. +func doJSON(t *testing.T, e *gin.Engine, method, path, uid string, headers map[string]string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, nil) + if uid != "" { + req.Header.Set("X-Uid", uid) + } + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + body := map[string]any{} + if rec.Body.Len() > 0 { + _ = json.Unmarshal(rec.Body.Bytes(), &body) + } + return rec.Code, body +} diff --git a/app-go/app-cloud/internal/service/kgclient_test.go b/app-go/app-cloud/tests/kgclient_test.go similarity index 88% rename from app-go/app-cloud/internal/service/kgclient_test.go rename to app-go/app-cloud/tests/kgclient_test.go index 106ee8af..80298e44 100644 --- a/app-go/app-cloud/internal/service/kgclient_test.go +++ b/app-go/app-cloud/tests/kgclient_test.go @@ -1,4 +1,4 @@ -package service +package tests import ( "context" @@ -8,6 +8,7 @@ import ( "testing" "app-cloud/internal/config" + "app-cloud/internal/service" ) func TestKgClientUpsertSendsTokenPathAndPayload(t *testing.T) { @@ -26,7 +27,7 @@ func TestKgClientUpsertSendsTokenPathAndPayload(t *testing.T) { })) defer srv.Close() - c := NewKgClient(config.KgConfig{BaseURL: srv.URL, Token: "tok"}) + c := service.NewKgClient(config.KgConfig{BaseURL: srv.URL, Token: "tok"}) err := c.UpsertCloudDocGraph(context.Background(), 42, "uuid-1", "标题", "hash", []string{"主题"}, nil) if err != nil { t.Fatalf("upsert: %v", err) @@ -50,7 +51,7 @@ func TestKgClientDeletePath(t *testing.T) { })) defer srv.Close() - c := NewKgClient(config.KgConfig{BaseURL: srv.URL}) + c := service.NewKgClient(config.KgConfig{BaseURL: srv.URL}) if err := c.DeleteCloudDocGraph(context.Background(), 7, "uuid-2"); err != nil { t.Fatalf("delete: %v", err) } @@ -65,14 +66,14 @@ func TestKgClientSurfacesUpstreamErrors(t *testing.T) { })) defer srv.Close() - c := NewKgClient(config.KgConfig{BaseURL: srv.URL}) + c := service.NewKgClient(config.KgConfig{BaseURL: srv.URL}) if err := c.UpsertCloudDocGraph(context.Background(), 1, "u", "t", "h", nil, nil); err == nil { t.Fatal("500 must surface as an error (caller treats it as non-fatal but visible)") } } func TestKgClientUnconfiguredFailsFast(t *testing.T) { - c := NewKgClient(config.KgConfig{}) + c := service.NewKgClient(config.KgConfig{}) if c.Enabled() { t.Fatal("empty base URL must disable the client") } diff --git a/app-go/app-cloud/tests/middleware_test.go b/app-go/app-cloud/tests/middleware_test.go new file mode 100644 index 00000000..2e0ffedb --- /dev/null +++ b/app-go/app-cloud/tests/middleware_test.go @@ -0,0 +1,81 @@ +package tests + +// Middleware behaviour is exercised directly here so the onion can be +// verified without going through every route's service wiring. + +import ( + "net/http" + "net/http/httptest" + "testing" + + "app-cloud/internal/config" + "app-cloud/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func TestRequireUIDInjectsValidatedIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + e := gin.New() + e.Use(middleware.RequireUID()) + e.GET("/probe", func(c *gin.Context) { c.String(http.StatusOK, "%d", middleware.UIDFrom(c)) }) + + missing := httptest.NewRecorder() + e.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/probe", nil)) + if missing.Code != http.StatusUnauthorized { + t.Fatalf("missing X-Uid status = %d, want 401", missing.Code) + } + + bad := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/probe", nil) + req.Header.Set("X-Uid", "not-a-number") + e.ServeHTTP(bad, req) + if bad.Code != http.StatusUnauthorized { + t.Fatalf("invalid X-Uid status = %d, want 401", bad.Code) + } + + ok := httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/probe", nil) + req.Header.Set("X-Uid", "42") + e.ServeHTTP(ok, req) + if ok.Code != http.StatusOK || ok.Body.String() != "42" { + t.Fatalf("valid X-Uid = %d %q, want 200 42", ok.Code, ok.Body.String()) + } +} + +func TestRequireInternalKeyFailsClosed(t *testing.T) { + gin.SetMode(gin.TestMode) + e := gin.New() + e.Use(middleware.RequireInternalKey(nil)) + e.GET("/probe", func(c *gin.Context) { c.Status(http.StatusOK) }) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/probe", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured internal key status = %d, want 503", rec.Code) + } + + e2 := gin.New() + e2.Use(middleware.RequireInternalKey(&config.Config{Internal: config.InternalConfig{APIKey: "secret"}})) + e2.GET("/probe", func(c *gin.Context) { c.Status(http.StatusOK) }) + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/probe", nil) + req.Header.Set("apikey", "wrong") + e2.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong internal key status = %d, want 401", rec.Code) + } +} + +func TestRequireCapabilityIsRouteLevelGate(t *testing.T) { + gin.SetMode(gin.TestMode) + e := gin.New() + e.GET("/probe", middleware.RequireCapability(false, "capability unavailable"), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/probe", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("unavailable capability status = %d, want 503", rec.Code) + } +} diff --git a/app-go/app-cloud/tests/ocr_test.go b/app-go/app-cloud/tests/ocr_test.go new file mode 100644 index 00000000..c61f456c --- /dev/null +++ b/app-go/app-cloud/tests/ocr_test.go @@ -0,0 +1,251 @@ +package tests + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "app-cloud/internal/config" + "app-cloud/internal/pipeline" +) + +func TestOCRRecognizeSendsImageDataURL(t *testing.T) { + var ( + auth string + path string + body map[string]any + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = r.Header.Get("Authorization") + path = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": " 识别到的文字 "}}}, + }) + })) + defer srv.Close() + + client := testOCRClient(t, srv.URL) + image := []byte{0x89, 'P', 'N', 'G', 1, 2, 3} + text, err := client.Recognize(context.Background(), pipeline.OCRImage{Data: image, MimeType: "image/png"}) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if text != "识别到的文字" { + t.Fatalf("text = %q, want trimmed OCR text", text) + } + if path != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", path) + } + if auth != "Bearer test-key" { + t.Fatalf("authorization = %q", auth) + } + if body["model"] != "z-ai/glm-test" { + t.Fatalf("model = %v", body["model"]) + } + messages, _ := body["messages"].([]any) + if len(messages) != 1 { + t.Fatalf("messages = %v", body["messages"]) + } + msg, _ := messages[0].(map[string]any) + if msg["role"] != "user" { + t.Fatalf("role = %v", msg["role"]) + } + parts, _ := msg["content"].([]any) + if len(parts) != 2 { + t.Fatalf("content parts = %v", msg["content"]) + } + textPart, _ := parts[0].(map[string]any) + if textPart["type"] != "text" || !strings.Contains(textPart["text"].(string), "全部文字") { + t.Fatalf("text part = %v", textPart) + } + imagePart, _ := parts[1].(map[string]any) + if imagePart["type"] != "image_url" { + t.Fatalf("image part = %v", imagePart) + } + imageURL := imagePart["image_url"].(map[string]any)["url"].(string) + const prefix = "data:image/png;base64," + if !strings.HasPrefix(imageURL, prefix) { + t.Fatalf("image url prefix = %q", imageURL[:minInt(len(imageURL), len(prefix))]) + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(imageURL, prefix)) + if err != nil || string(decoded) != string(image) { + t.Fatalf("image payload round-trip failed: %v", err) + } +} + +func TestOCRRetriesTransientFailures(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) <= 2 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": "恢复后的文字"}}}, + }) + })) + defer srv.Close() + + client := testOCRClient(t, srv.URL) + text, err := client.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("img"), MimeType: "image/jpeg"}) + if err != nil { + t.Fatalf("expected recovery after transient failures: %v", err) + } + if text != "恢复后的文字" { + t.Fatalf("text = %q", text) + } + // The first call plus at most two retries. + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("calls = %d, want 3", got) + } +} + +func TestOCRDoesNotRetryUnsupportedMedia(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid image")) + })) + defer srv.Close() + + client := testOCRClient(t, srv.URL) + _, err := client.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("img"), MimeType: "image/png"}) + if !errors.Is(err, pipeline.ErrOCRUnsupportedImage) { + t.Fatalf("want ErrOCRUnsupportedImage, got %v", err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("a 400 must not be retried; calls = %d", got) + } +} + +func TestOCREmptyCompletionIsPermanentNoContent(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": ""}}}, + }) + })) + defer srv.Close() + + client := testOCRClient(t, srv.URL) + _, err := client.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("img"), MimeType: "image/png"}) + if !errors.Is(err, pipeline.ErrOCRNoContent) { + t.Fatalf("want ErrOCRNoContent, got %v", err) + } + if !pipeline.IsPermanentOCRError(err) { + t.Fatal("empty OCR output must be a permanent skip, not a retryable failure") + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("empty completions retry then give up; calls = %d", got) + } +} + +func TestOCRDisabledAndOversize(t *testing.T) { + disabled := disabledOCRClient() + if disabled.Enabled() { + t.Fatal("disabled config must not enable OCR") + } + if _, err := disabled.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("x"), MimeType: "image/png"}); !errors.Is(err, pipeline.ErrOCRDisabled) { + t.Fatalf("want ErrOCRDisabled, got %v", err) + } + + cfg := config.OCRConfig{Enabled: true, Model: "m", MaxImageBytes: 4, TimeoutSecs: 1} + client := pipeline.NewOCRClient(cfg, config.EmbeddingConfig{BaseURL: "http://unused", APIKey: "k"}) + if _, err := client.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("12345"), MimeType: "image/png"}); !errors.Is(err, pipeline.ErrImageTooLarge) { + t.Fatalf("want ErrImageTooLarge, got %v", err) + } +} + +// An OCR stage without its own credentials reuses the embedding gateway — +// one AI_GATEWAY__* pair wires both. +func TestOCRInheritsEmbeddingConnection(t *testing.T) { + var sharedCalls int32 + var sharedAuth string + shared := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&sharedCalls, 1) + sharedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": "继承网关"}}}, + }) + })) + defer shared.Close() + + inherited := pipeline.NewOCRClient( + config.OCRConfig{Enabled: true, Model: "m", TimeoutSecs: 5}, + config.EmbeddingConfig{BaseURL: shared.URL, APIKey: "shared-key"}, + ) + if !inherited.Enabled() { + t.Fatal("OCR should use the shared Higress gateway connection") + } + if _, err := inherited.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("img"), MimeType: "image/png"}); err != nil { + t.Fatalf("inherited-gateway OCR: %v", err) + } + if atomic.LoadInt32(&sharedCalls) != 1 || sharedAuth != "Bearer shared-key" { + t.Fatalf("inherited calls = %d, auth = %q", sharedCalls, sharedAuth) + } +} + +// OpenAI-compatible gateways may answer with content parts instead of a plain +// string; the text parts are joined. +func TestOCRJoinsContentPartArrays(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "甲"}, + map[string]any{"type": "text", "text": "乙"}, + }, + }}}, + }) + })) + defer srv.Close() + + client := testOCRClient(t, srv.URL) + text, err := client.Recognize(context.Background(), pipeline.OCRImage{Data: []byte("img"), MimeType: "image/png"}) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if text != "甲乙" { + t.Fatalf("text = %q, want joined content parts", text) + } +} + +func TestNormalizeImageMime(t *testing.T) { + cases := []struct { + mime string + data []byte + want string + }{ + {"image/png", nil, "image/png"}, + {"image/jpeg; charset=binary", nil, "image/jpeg"}, + {"", []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, "image/png"}, + {"image/tiff", nil, ""}, + {"application/octet-stream", []byte("not an image"), ""}, + } + for i, c := range cases { + if got := pipeline.NormalizeImageMime(c.mime, c.data); got != c.want { + t.Fatalf("case %d (%q): got %q, want %q", i, c.mime, got, c.want) + } + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/app-go/app-cloud/internal/service/quota_test.go b/app-go/app-cloud/tests/quota_test.go similarity index 95% rename from app-go/app-cloud/internal/service/quota_test.go rename to app-go/app-cloud/tests/quota_test.go index 230fedf2..6fc27c44 100644 --- a/app-go/app-cloud/internal/service/quota_test.go +++ b/app-go/app-cloud/tests/quota_test.go @@ -1,4 +1,4 @@ -package service +package tests import ( "context" @@ -9,11 +9,12 @@ import ( "testing" "app-cloud/internal/config" + "app-cloud/internal/service" ) // newTestQuotaService builds a QuotaService against a fake app-pay endpoint // (nil db/pending — TierFor touches neither). -func newTestQuotaService(t *testing.T, handler http.HandlerFunc) (*QuotaService, *int32) { +func newTestQuotaService(t *testing.T, handler http.HandlerFunc) (*service.QuotaService, *int32) { t.Helper() var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -25,7 +26,7 @@ func newTestQuotaService(t *testing.T, handler http.HandlerFunc) (*QuotaService, cfg.Quota.MembershipBaseURL = srv.URL cfg.Quota.MembershipAPIKey = "k" cfg.Quota.MembershipCacheTTL = 60 - return NewQuotaService(cfg, nil, nil), &calls + return service.NewQuotaService(cfg, nil, nil), &calls } func membershipBody(tier string, active bool) string { diff --git a/app-go/app-cloud/tests/router_ai_test.go b/app-go/app-cloud/tests/router_ai_test.go new file mode 100644 index 00000000..877ae047 --- /dev/null +++ b/app-go/app-cloud/tests/router_ai_test.go @@ -0,0 +1,224 @@ +package tests + +import ( + "errors" + "net/http" + "testing" + + "app-cloud/internal/infra/mongostore" + "app-cloud/internal/model" + "app-cloud/internal/service" +) + +func summaryFixture() *service.SummaryService { + file := &model.CloudFile{ + UploadUUID: "u1", UID: 7, OriginalName: "报告.pdf", Title: strPtr("季度报告"), + MimeType: "application/pdf", FileSize: 2048, ContentHash: strPtr("hash-1"), + } + files := &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": file}} + store := &fakeSummaryStore{stored: map[string]*mongostore.StoredSummary{ + "u1": { + Model: "z-ai/glm-5.3-flash", + ContentHash: "hash-1", + Summary: map[string]any{ + "overview": "报告概览", + "topics": []any{"主题"}, + "entities": []any{"实体"}, + "key_points": []any{"要点"}, + "todos": []any{"待办"}, + "answers_questions": []any{"问题"}, + "language": "zh", + "size_chars": 512, + }, + }, + }} + return service.NewSummaryServiceWithPorts(nil, files, store) +} + +// semanticSearchFixture wires a search service over in-memory ports. +func semanticSearchFixture(hits []model.ChunkHit, embedErr, indexErr error) *service.SearchService { + files := &fakeFileCatalog{files: map[string]*model.CloudFile{ + "u1": {UploadUUID: "u1", UID: 7, OriginalName: "报告.pdf", Title: strPtr("季度报告"), + MimeType: "application/pdf", FileSize: 2048}, + }} + return service.NewSearchServiceWithPorts(nil, files, &fakeFolderTree{ids: []int64{1}}, + nil, &fakeEmbedder{err: embedErr}, &fakeSemanticIndex{hits: hits, err: indexErr}) +} + +// ── owner summary endpoint ───────────────────────────────────────── + +func TestVideoSummaryReturnsSummaryAndFileIdentity(t *testing.T) { + e := newAIRouter(t, "", summaryFixture(), nil) + code, body := doJSON(t, e, http.MethodGet, "/cloud/video/u1/summary", "7", nil) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if body["upload_uuid"] != "u1" || body["original_name"] != "报告.pdf" || body["title"] != "季度报告" { + t.Fatalf("file identity fields missing: %v", body) + } + if body["available"] != true || body["stale"] != false || body["model"] != "z-ai/glm-5.3-flash" { + t.Fatalf("summary state wrong: %v", body) + } + sum, ok := body["summary"].(map[string]any) + if !ok { + t.Fatalf("summary payload missing: %v", body["summary"]) + } + if sum["overview"] != "报告概览" || sum["size_chars"] != float64(512) { + t.Fatalf("summary content wrong: %v", sum) + } + todos, ok := sum["todos"].([]any) + if !ok || len(todos) != 1 || todos[0] != "待办" { + t.Fatalf("todos missing: %v", sum["todos"]) + } +} + +func TestVideoSummaryWithoutSummaryStillReturnsIdentity(t *testing.T) { + files := &fakeFileCatalog{files: map[string]*model.CloudFile{ + "u2": {UploadUUID: "u2", UID: 7, OriginalName: "无摘要.docx", MimeType: "text/plain"}, + }} + summary := service.NewSummaryServiceWithPorts(nil, files, &fakeSummaryStore{}) + e := newAIRouter(t, "", summary, nil) + code, body := doJSON(t, e, http.MethodGet, "/cloud/video/u2/summary", "7", nil) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if body["available"] != false || body["summary"] != nil || body["stale"] != false { + t.Fatalf("missing summary must report unavailable: %v", body) + } + if body["original_name"] != "无摘要.docx" || body["title"] != "" { + t.Fatalf("identity must be present without a summary: %v", body) + } +} + +func TestVideoSummaryAuthAndNotFound(t *testing.T) { + e := newAIRouter(t, "", summaryFixture(), nil) + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/video/u1/summary", "", nil); code != http.StatusUnauthorized { + t.Fatalf("missing X-Uid must be 401, got %d", code) + } + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/video/missing/summary", "7", nil); code != http.StatusNotFound { + t.Fatalf("missing file must be 404, got %d", code) + } + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/video/u1/summary", "8", nil); code != http.StatusNotFound { + t.Fatalf("foreign file must be 404, got %d", code) + } +} + +// ── internal summary endpoint ────────────────────────────────────── + +func TestInternalSummaryAuth(t *testing.T) { + summary := summaryFixture() + // Unconfigured key: fail closed before any credential comparison. + noKey := newAIRouter(t, "", summary, nil) + if code, _ := doJSON(t, noKey, http.MethodGet, "/internal/cloud/summary?uid=7&upload_uuid=u1", "", nil); code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured internal key must be 503, got %d", code) + } + + withKey := newAIRouter(t, "internal-secret", summary, nil) + path := "/internal/cloud/summary?uid=7&upload_uuid=u1" + if code, _ := doJSON(t, withKey, http.MethodGet, path, "", nil); code != http.StatusUnauthorized { + t.Fatalf("missing apikey must be 401, got %d", code) + } + if code, _ := doJSON(t, withKey, http.MethodGet, path, "", map[string]string{"apikey": "wrong"}); code != http.StatusUnauthorized { + t.Fatalf("wrong apikey must be 401, got %d", code) + } + code, body := doJSON(t, withKey, http.MethodGet, path, "", map[string]string{"apikey": "internal-secret"}) + if code != http.StatusOK { + t.Fatalf("valid apikey must pass, got %d %v", code, body) + } + if body["original_name"] != "报告.pdf" || body["title"] != "季度报告" || body["available"] != true { + t.Fatalf("M2M summary payload wrong: %v", body) + } + // Bearer form is accepted for symmetry with the other internal endpoints. + bearer := map[string]string{"Authorization": "Bearer internal-secret"} + if code, _ := doJSON(t, withKey, http.MethodGet, path, "", bearer); code != http.StatusOK { + t.Fatalf("Bearer apikey must pass, got %d", code) + } + if code, _ := doJSON(t, withKey, http.MethodGet, + "/internal/cloud/summary?upload_uuid=u1", "", map[string]string{"apikey": "internal-secret"}); code != http.StatusUnprocessableEntity { + t.Fatalf("missing uid must be 422, got %d", code) + } +} + +// ── semantic search endpoint ─────────────────────────────────────── + +func TestSearchSemanticEndpoint(t *testing.T) { + hits := []model.ChunkHit{{UploadUUID: "u1", Text: "相关片段", Score: 0.87}} + e := newAIRouter(t, "", nil, semanticSearchFixture(hits, nil, nil)) + code, body := doJSON(t, e, http.MethodGet, "/cloud/search/semantic?q=报告&limit=5", "7", nil) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if body["mode"] != "semantic" || body["total"] != float64(1) { + t.Fatalf("envelope wrong: %v", body) + } + results, _ := body["results"].([]any) + if len(results) != 1 { + t.Fatalf("results = %v", body["results"]) + } + hit, _ := results[0].(map[string]any) + if hit["upload_uuid"] != "u1" || hit["original_name"] != "报告.pdf" || hit["score"] != 0.87 { + t.Fatalf("result shape wrong: %v", hit) + } + if snippets, _ := hit["snippets"].([]any); len(snippets) != 1 { + t.Fatalf("snippets missing: %v", hit["snippets"]) + } + + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/search/semantic", "7", nil); code != http.StatusUnprocessableEntity { + t.Fatalf("missing q must be 422, got %d", code) + } + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/search/semantic?q=x&folderId=abc", "7", nil); code != http.StatusUnprocessableEntity { + t.Fatalf("bad folderId must be 422, got %d", code) + } +} + +func TestSearchSemanticUnavailableIs503(t *testing.T) { + e := newAIRouter(t, "", nil, semanticSearchFixture(nil, nil, errors.New("milvus down"))) + if code, body := doJSON(t, e, http.MethodGet, "/cloud/search/semantic?q=x", "7", nil); code != http.StatusServiceUnavailable { + t.Fatalf("index failure must be 503, got %d %v", code, body) + } + // No search service wired at all (pipeline/Milvus disabled). + e = newAIRouter(t, "", nil, nil) + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/search/semantic?q=x", "7", nil); code != http.StatusServiceUnavailable { + t.Fatalf("disabled semantic search must be 503, got %d", code) + } +} + +// ── similar endpoint ─────────────────────────────────────────────── + +func TestSimilarEndpoint(t *testing.T) { + files := &fakeFileCatalog{files: map[string]*model.CloudFile{ + "u1": {UploadUUID: "u1", UID: 7, OriginalName: "报告.pdf", MimeType: "application/pdf"}, + }} + search := service.NewSearchServiceWithPorts(nil, files, &fakeFolderTree{}, nil, + &fakeEmbedder{}, &fakeSemanticIndex{hits: []model.ChunkHit{{UploadUUID: "u1", Text: "t", Score: 0.5}}}) + e := newAIRouter(t, "", nil, search) + code, body := doJSON(t, e, http.MethodGet, "/cloud/video/u1/similar?limit=5", "7", nil) + if code != http.StatusOK { + t.Fatalf("status = %d, body = %v", code, body) + } + if items, ok := body["items"].([]any); !ok || len(items) != 0 { + t.Fatalf("self must be excluded and items must be an array: %v", body["items"]) + } + if code, _ := doJSON(t, e, http.MethodGet, "/cloud/video/missing/similar", "7", nil); code != http.StatusNotFound { + t.Fatalf("missing file must be 404, got %d", code) + } +} + +func TestInternalSimilarAuth(t *testing.T) { + files := &fakeFileCatalog{files: map[string]*model.CloudFile{ + "u1": {UploadUUID: "u1", UID: 7, OriginalName: "报告.pdf", MimeType: "application/pdf"}, + }} + search := service.NewSearchServiceWithPorts(nil, files, &fakeFolderTree{}, nil, + &fakeEmbedder{}, &fakeSemanticIndex{}) + path := "/internal/cloud/similar?uid=7&upload_uuid=u1" + if code, _ := doJSON(t, newAIRouter(t, "", nil, search), http.MethodGet, path, "", nil); code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured key must be 503, got %d", code) + } + withKey := newAIRouter(t, "internal-secret", nil, search) + if code, _ := doJSON(t, withKey, http.MethodGet, path, "", map[string]string{"apikey": "wrong"}); code != http.StatusUnauthorized { + t.Fatalf("wrong apikey must be 401, got %d", code) + } + if code, body := doJSON(t, withKey, http.MethodGet, path, "", map[string]string{"apikey": "internal-secret"}); code != http.StatusOK { + t.Fatalf("valid apikey must pass, got %d %v", code, body) + } +} diff --git a/app-go/app-cloud/tests/semantic_test.go b/app-go/app-cloud/tests/semantic_test.go new file mode 100644 index 00000000..c203b79a --- /dev/null +++ b/app-go/app-cloud/tests/semantic_test.go @@ -0,0 +1,243 @@ +package tests + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "app-cloud/internal/infra/mongostore" + "app-cloud/internal/model" + "app-cloud/internal/service" +) + +func semanticCatalog() *fakeFileCatalog { + u1 := cloudFile("u1", 42) + u1.Title = strPtr("文档一") + u2 := cloudFile("u2", 42) + u2.Title = strPtr("文档二") + return &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": u1, "u2": u2}} +} + +func TestSearchSemanticAggregatesByFile(t *testing.T) { + index := &fakeSemanticIndex{hits: []model.ChunkHit{ + {UploadUUID: "u1", Text: "片段A", Score: 0.92}, + {UploadUUID: "u1", Text: "片段B", Score: 0.81}, + {UploadUUID: "u1", Text: "片段A", Score: 0.70}, + {UploadUUID: "u2", Text: "片段C", Score: 0.88}, + {UploadUUID: "ghost", Text: "已删除文件的片段", Score: 0.99}, + }} + emb := &fakeEmbedder{} + svc := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, emb, index) + + results, err := svc.SearchSemantic(context.Background(), 42, " 检索词 ", nil, 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 2 { + t.Fatalf("results = %d, want 2 owned files (unknown uuid dropped)", len(results)) + } + if results[0].UploadUUID != "u1" || results[0].Score != 0.92 { + t.Fatalf("ranking wrong: %+v", results[0]) + } + if len(results[0].Snippets) != 2 { + t.Fatalf("snippets must dedupe and cap: %+v", results[0].Snippets) + } + if results[0].Title != "文档一" || results[0].OriginalName != "u1.pdf" || results[0].FileSize != 100 { + t.Fatalf("metadata hydration wrong: %+v", results[0]) + } + if index.gotFilter != "uid == 42" { + t.Fatalf("identity filter = %q, want the authenticated uid only", index.gotFilter) + } + if index.gotLimit < 20 { + t.Fatalf("over-fetch limit = %d, want aggregation headroom", index.gotLimit) + } + if len(emb.gotTexts) != 1 || emb.gotTexts[0] != "检索词" { + t.Fatalf("embedded texts = %#v", emb.gotTexts) + } +} + +func TestSearchSemanticRespectsLimit(t *testing.T) { + hits := []model.ChunkHit{ + {UploadUUID: "u1", Text: "a", Score: 0.9}, + {UploadUUID: "u2", Text: "b", Score: 0.8}, + } + svc := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, + &fakeEmbedder{}, &fakeSemanticIndex{hits: hits}) + results, err := svc.SearchSemantic(context.Background(), 42, "q", nil, 1) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 1 || results[0].UploadUUID != "u1" { + t.Fatalf("limit not applied: %+v", results) + } +} + +// A small folder scope is pushed into the vector expression so files outside +// the folder cannot fill the top-N and produce a false-empty result. With the +// default limit 10 the fetch budget is 10 x 4. +func TestSearchSemanticFolderScopeSmallSetPushesUUIDFilter(t *testing.T) { + u1 := cloudFile("u1", 42) + u1.FolderID = int64Ptr(5) + u2 := cloudFile("u2", 42) + u2.FolderID = int64Ptr(5) + outside := cloudFile("outside", 42) + outside.FolderID = int64Ptr(9) + catalog := &fakeFileCatalog{ + files: map[string]*model.CloudFile{"u1": u1, "u2": u2, "outside": outside}, + folderFiles: []model.CloudFile{*u1, *u2}, + } + index := &fakeSemanticIndex{hits: []model.ChunkHit{ + {UploadUUID: "u1", Text: "文件夹内命中", Score: 0.8}, + }} + folderID := int64(5) + svc := service.NewSearchServiceWithPorts(nil, catalog, &fakeFolderTree{ids: []int64{5}}, nil, + &fakeEmbedder{}, index) + + results, err := svc.SearchSemantic(context.Background(), 42, "关键词", &folderID, 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 1 || results[0].UploadUUID != "u1" { + t.Fatalf("folder-scope hit lost: %+v", results) + } + want := "uid == 42 && upload_uuid in [\"u1\", \"u2\"]" + if index.gotFilter != want { + t.Fatalf("filter = %q, want %q", index.gotFilter, want) + } + if index.gotLimit != 40 { + t.Fatalf("fetch limit = %d, want the normal 4x over-fetch of limit 10", index.gotLimit) + } +} + +// Above 200 scoped files the in-list would be huge: the expression stays +// uid-only and the over-fetch widens instead. +func TestSearchSemanticFolderScopeLargeSetWidensOverfetch(t *testing.T) { + const scopedFiles = 201 + folderFiles := make([]model.CloudFile, 0, scopedFiles) + files := make(map[string]*model.CloudFile, scopedFiles) + for i := 0; i < scopedFiles; i++ { + file := cloudFile(fmt.Sprintf("u%d", i), 42) + file.FolderID = int64Ptr(5) + folderFiles = append(folderFiles, *file) + files[file.UploadUUID] = file + } + catalog := &fakeFileCatalog{files: files, folderFiles: folderFiles} + index := &fakeSemanticIndex{hits: []model.ChunkHit{ + {UploadUUID: "u0", Text: "命中", Score: 0.8}, + }} + folderID := int64(5) + svc := service.NewSearchServiceWithPorts(nil, catalog, &fakeFolderTree{ids: []int64{5}}, nil, + &fakeEmbedder{}, index) + + results, err := svc.SearchSemantic(context.Background(), 42, "关键词", &folderID, 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 1 { + t.Fatalf("large-scope hit lost: %+v", results) + } + if index.gotFilter != "uid == 42" { + t.Fatalf("large scope must not inline hundreds of uuids: %q", index.gotFilter) + } + if index.gotLimit <= 40 { + t.Fatalf("large scope must over-fetch beyond the normal 40, got %d", index.gotLimit) + } +} + +func TestSearchSemanticEmptyFolderShortCircuits(t *testing.T) { + index := &fakeSemanticIndex{} + folderID := int64(5) + svc := service.NewSearchServiceWithPorts(nil, &fakeFileCatalog{files: map[string]*model.CloudFile{}}, + &fakeFolderTree{}, nil, &fakeEmbedder{}, index) + results, err := svc.SearchSemantic(context.Background(), 42, "q", &folderID, 10) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 0 { + t.Fatalf("empty folder must yield no results: %+v", results) + } + if index.calls != 0 { + t.Fatalf("an empty folder scope must not query the vector store, calls = %d", index.calls) + } +} + +func TestSearchSemanticUnavailable(t *testing.T) { + disabled := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, nil, nil) + if _, err := disabled.SearchSemantic(context.Background(), 42, "q", nil, 10); !errors.Is(err, service.ErrSemanticUnavailable) { + t.Fatalf("nil ports must be unavailable, got %v", err) + } + embedDown := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, + &fakeEmbedder{err: errors.New("gateway down")}, &fakeSemanticIndex{}) + if _, err := embedDown.SearchSemantic(context.Background(), 42, "q", nil, 10); !errors.Is(err, service.ErrSemanticUnavailable) { + t.Fatalf("embedding failure must be unavailable, got %v", err) + } + indexDown := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, + &fakeEmbedder{}, &fakeSemanticIndex{err: errors.New("milvus down")}) + if _, err := indexDown.SearchSemantic(context.Background(), 42, "q", nil, 10); !errors.Is(err, service.ErrSemanticUnavailable) { + t.Fatalf("index failure must be unavailable, got %v", err) + } +} + +func TestSimilarExcludesSelfAndUsesSummaryOverview(t *testing.T) { + target := cloudFile("target", 42) + target.Title = strPtr("目标文档") + target.ContentHash = strPtr("hash-1") + other := cloudFile("other", 42) + catalog := &fakeFileCatalog{files: map[string]*model.CloudFile{"target": target, "other": other}} + store := &fakeSummaryStore{stored: map[string]*mongostore.StoredSummary{ + "target": { + Model: "m", + ContentHash: "hash-1", + Summary: map[string]any{"overview": "目标文件的概述"}, + }, + }} + summarySvc := service.NewSummaryServiceWithPorts(nil, catalog, store) + emb := &fakeEmbedder{} + index := &fakeSemanticIndex{hits: []model.ChunkHit{ + {UploadUUID: "target", Text: "自身片段", Score: 0.99}, + {UploadUUID: "other", Text: "相似片段", Score: 0.85}, + }} + svc := service.NewSearchServiceWithPorts(nil, catalog, &fakeFolderTree{}, summarySvc, emb, index) + + results, err := svc.Similar(context.Background(), 42, "target", 5) + if err != nil { + t.Fatalf("similar: %v", err) + } + if len(results) != 1 || results[0].UploadUUID != "other" { + t.Fatalf("self must be excluded: %+v", results) + } + if len(emb.gotTexts) != 1 || !strings.Contains(emb.gotTexts[0], "目标文档") || + !strings.Contains(emb.gotTexts[0], "目标文件的概述") { + t.Fatalf("probe text must combine title and summary overview: %#v", emb.gotTexts) + } + if index.gotFilter != "uid == 42" { + t.Fatalf("filter = %q", index.gotFilter) + } +} + +func TestSimilarFallsBackToTitleWithoutSummary(t *testing.T) { + target := cloudFile("target", 42) + catalog := &fakeFileCatalog{files: map[string]*model.CloudFile{"target": target}} + emb := &fakeEmbedder{} + svc := service.NewSearchServiceWithPorts(nil, catalog, &fakeFolderTree{}, nil, emb, &fakeSemanticIndex{}) + if _, err := svc.Similar(context.Background(), 42, "target", 5); err != nil { + t.Fatalf("similar: %v", err) + } + if len(emb.gotTexts) != 1 || emb.gotTexts[0] != "target.pdf" { + t.Fatalf("probe text = %#v, want the file name", emb.gotTexts) + } +} + +func TestSimilarNotFoundAndUnavailable(t *testing.T) { + svc := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, + &fakeEmbedder{}, &fakeSemanticIndex{}) + if _, err := svc.Similar(context.Background(), 42, "missing", 5); !errors.Is(err, service.ErrNotFound) { + t.Fatalf("missing file must be ErrNotFound, got %v", err) + } + disabled := service.NewSearchServiceWithPorts(nil, semanticCatalog(), &fakeFolderTree{}, nil, nil, nil) + if _, err := disabled.Similar(context.Background(), 42, "u1", 5); !errors.Is(err, service.ErrSemanticUnavailable) { + t.Fatalf("disabled search must be unavailable, got %v", err) + } +} diff --git a/app-go/app-cloud/internal/service/share_folder_test.go b/app-go/app-cloud/tests/share_folder_test.go similarity index 59% rename from app-go/app-cloud/internal/service/share_folder_test.go rename to app-go/app-cloud/tests/share_folder_test.go index 7327480c..2878d22e 100644 --- a/app-go/app-cloud/internal/service/share_folder_test.go +++ b/app-go/app-cloud/tests/share_folder_test.go @@ -1,13 +1,15 @@ -package service +package tests import ( "testing" "app-cloud/internal/model" + "app-cloud/internal/service" + "app-cloud/internal/utils" ) -// folderRelativePath — public folder-share payloads label every file with its -// path relative to the shared folder root ("a/b"; "" = root level). +// FolderRelativePath labels every file in a public folder-share payload with +// its path relative to the shared folder root ("a/b"; "" = root level). func TestFolderRelativePath(t *testing.T) { mk := func(id int64, parent *int64, name string) model.CloudFolder { return model.CloudFolder{ID: id, ParentID: parent, Name: name} @@ -35,8 +37,8 @@ func TestFolderRelativePath(t *testing.T) { {1, 6, ""}, // cycle — bounded hops, defensive root-level } for _, tc := range cases { - if got := folderRelativePath(byID, tc.root, tc.folder); got != tc.want { - t.Fatalf("folderRelativePath(root=%d, folder=%d) = %q, want %q", tc.root, tc.folder, got, tc.want) + if got := utils.FolderRelativePath(byID, tc.root, tc.folder); got != tc.want { + t.Fatalf("FolderRelativePath(root=%d, folder=%d) = %q, want %q", tc.root, tc.folder, got, tc.want) } } } @@ -48,36 +50,35 @@ func TestRefFolderNameTruncation(t *testing.T) { for len(long) < 300 { long += "文件夹" // 9 bytes per repetition } - got := refFolderName(long, refFolderNameBytes) - if len(got) > refFolderNameBytes { - t.Fatalf("truncated to %d bytes, want <= %d", len(got), refFolderNameBytes) + got := utils.RefFolderName(long, utils.RefFolderNameMaxBytes) + if len(got) > utils.RefFolderNameMaxBytes { + t.Fatalf("truncated to %d bytes, want <= %d", len(got), utils.RefFolderNameMaxBytes) } - // truncate to a rune boundary: appending is safe, splitting is not - for _, r := range []string{"文件夹", "a", "b"} { + for range []string{"文件夹", "a", "b"} { + // truncation must land on a rune boundary: re-encoding is lossless if string([]rune(got)) != got { - t.Fatalf("truncation split a multi-byte rune") + t.Fatal("truncation split a multi-byte rune") } - _ = r } - if refFolderName("短名", refFolderNameBytes) != "短名" { + if utils.RefFolderName("短名", utils.RefFolderNameMaxBytes) != "短名" { t.Fatal("short passthrough broken") } - if refFolderName("", refFolderNameBytes) != "" { + if utils.RefFolderName("", utils.RefFolderNameMaxBytes) != "" { t.Fatal("empty passthrough broken") } } // LegalAck is a backend-enforced gate: the sentinel must exist and stay the -// exact user-facing copy the contract pins (plan/1.0.16 §四: 422 文案). +// exact user-facing copy. func TestLegalAckErrorCopy(t *testing.T) { - if ErrLegalAckRequired.Error() != "请先阅读并同意分享规范" { - t.Fatalf("legalAck copy drifted: %q", ErrLegalAckRequired.Error()) + if service.ErrLegalAckRequired.Error() != "请先阅读并同意分享规范" { + t.Fatalf("legalAck copy drifted: %q", service.ErrLegalAckRequired.Error()) } } // Ref invalidation copy is the uniform wire detail (404「引用已失效」). func TestRefDeadErrorCopy(t *testing.T) { - if ErrRefDead.Error() != "引用已失效" { - t.Fatalf("ref-dead copy drifted: %q", ErrRefDead.Error()) + if service.ErrRefDead.Error() != "引用已失效" { + t.Fatalf("ref-dead copy drifted: %q", service.ErrRefDead.Error()) } } diff --git a/app-go/app-cloud/tests/status_test.go b/app-go/app-cloud/tests/status_test.go new file mode 100644 index 00000000..b8fb9853 --- /dev/null +++ b/app-go/app-cloud/tests/status_test.go @@ -0,0 +1,25 @@ +package tests + +import ( + "testing" + + "app-cloud/internal/service" +) + +// ASR only applies to video/audio. Every other upload must start as +// not_supported so the UI never shows a pending ASR badge that cannot resolve. +func TestASRStatusForMime(t *testing.T) { + cases := map[string]string{ + "video/mp4": "pending", + "audio/mpeg": "pending", + "image/png": "not_supported", + "application/pdf": "not_supported", + "text/plain": "not_supported", + "application/zip": "not_supported", + } + for mime, want := range cases { + if got := service.ASRStatusForMime(mime); got != want { + t.Fatalf("ASRStatusForMime(%q) = %q, want %q", mime, got, want) + } + } +} diff --git a/app-go/app-cloud/internal/service/summarizer_test.go b/app-go/app-cloud/tests/summarizer_test.go similarity index 75% rename from app-go/app-cloud/internal/service/summarizer_test.go rename to app-go/app-cloud/tests/summarizer_test.go index 95c41f1b..21b823c1 100644 --- a/app-go/app-cloud/internal/service/summarizer_test.go +++ b/app-go/app-cloud/tests/summarizer_test.go @@ -1,4 +1,4 @@ -package service +package tests import ( "context" @@ -11,24 +11,25 @@ import ( "unicode/utf8" "app-cloud/internal/config" + "app-cloud/internal/service" ) -func testConfig() config.SummaryConfig { +func testSummaryConfig() config.SummaryConfig { return config.SummaryConfig{Enabled: true, Model: "test-flash", MaxInputChars: 60000, MaxOutputTok: 1500} } -const goodSummary = `{"overview":"一份测试文档","topics":["测试"],"entities":[],"key_points":["要点"],"answers_questions":["它讲什么?"],"language":"zh"}` +const summarizerGoodJSON = `{"overview":"一份测试文档","topics":["测试"],"entities":[],"key_points":["要点"],"answers_questions":["它讲什么?"],"language":"zh"}` func TestSummarizeParsesCleanJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{{"message": map[string]any{"content": goodSummary}}}, + "choices": []map[string]any{{"message": map[string]any{"content": summarizerGoodJSON}}}, }) })) defer srv.Close() - s := NewSummarizer(testConfig(), "k", srv.URL) + s := service.NewSummarizer(testSummaryConfig(), "k", srv.URL) sum, err := s.Summarize(context.Background(), "标题", "正文") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -42,12 +43,12 @@ func TestSummarizeToleratesMarkdownFences(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{{"message": map[string]any{"content": "```json\n" + goodSummary + "\n```"}}}, + "choices": []map[string]any{{"message": map[string]any{"content": "```json" + summarizerGoodJSON + "```"}}}, }) })) defer srv.Close() - s := NewSummarizer(testConfig(), "k", srv.URL) + s := service.NewSummarizer(testSummaryConfig(), "k", srv.URL) if _, err := s.Summarize(context.Background(), "标题", "正文"); err != nil { t.Fatalf("fenced JSON must parse: %v", err) } @@ -60,7 +61,7 @@ func TestSummarizeRetriesFormatViolations(t *testing.T) { w.Header().Set("Content-Type", "application/json") content := "这不是 JSON" if n >= 2 { - content = goodSummary + content = summarizerGoodJSON } _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []map[string]any{{"message": map[string]any{"content": content}}}, @@ -68,7 +69,7 @@ func TestSummarizeRetriesFormatViolations(t *testing.T) { })) defer srv.Close() - s := NewSummarizer(testConfig(), "k", srv.URL) + s := service.NewSummarizer(testSummaryConfig(), "k", srv.URL) if _, err := s.Summarize(context.Background(), "标题", "正文"); err != nil { t.Fatalf("expected recovery after a format violation: %v", err) } @@ -85,7 +86,7 @@ func TestSummarizeDoesNotRetryClientErrors(t *testing.T) { })) defer srv.Close() - s := NewSummarizer(testConfig(), "bad-key", srv.URL) + s := service.NewSummarizer(testSummaryConfig(), "bad-key", srv.URL) if _, err := s.Summarize(context.Background(), "标题", "正文"); err == nil { t.Fatal("expected 401 to fail") } @@ -94,10 +95,15 @@ func TestSummarizeDoesNotRetryClientErrors(t *testing.T) { } } -func TestLongInputTruncatedOnRuneBoundary(t *testing.T) { +func TestSummarizeLongInputTruncatedOnRuneBoundary(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req chatRequest + var req struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } _ = json.NewDecoder(r.Body).Decode(&req) for _, m := range req.Messages { if m.Role == "user" { @@ -106,14 +112,14 @@ func TestLongInputTruncatedOnRuneBoundary(t *testing.T) { } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "choices": []map[string]any{{"message": map[string]any{"content": goodSummary}}}, + "choices": []map[string]any{{"message": map[string]any{"content": summarizerGoodJSON}}}, }) })) defer srv.Close() - cfg := testConfig() + cfg := testSummaryConfig() cfg.MaxInputChars = 100 - s := NewSummarizer(cfg, "k", srv.URL) + s := service.NewSummarizer(cfg, "k", srv.URL) text := strings.Repeat("汉", 100) // 300 bytes, 100 runes if _, err := s.Summarize(context.Background(), "标题", text); err != nil { t.Fatalf("unexpected error: %v", err) @@ -124,20 +130,20 @@ func TestLongInputTruncatedOnRuneBoundary(t *testing.T) { if !strings.Contains(got, "[文档过长,已截断]") { t.Fatal("truncation marker missing") } - // 100-byte cap over 3-byte runes → exactly 33 full 汉 survive the cut + // 100-byte cap over 3-byte runes -> exactly 33 full 汉 survive the cut // (byte 99 is the lead byte of the 34th and is stripped). if n := strings.Count(got, "汉"); n != 33 { t.Fatalf("expected 33 surviving runes after boundary-safe truncation, got %d", n) } } -func TestEnabledRequiresModel(t *testing.T) { - cfg := testConfig() +func TestSummaryEnabledRequiresModel(t *testing.T) { + cfg := testSummaryConfig() cfg.Model = "" - if NewSummarizer(cfg, "k", "http://x").Enabled() { + if service.NewSummarizer(cfg, "k", "http://x").Enabled() { t.Fatal("empty model must disable the stage") } - if NewSummarizer(cfg, "k", "http://x").ModelName() != "" { + if service.NewSummarizer(cfg, "k", "http://x").ModelName() != "" { t.Fatal("ModelName should mirror config") } } diff --git a/app-go/app-cloud/tests/summary_test.go b/app-go/app-cloud/tests/summary_test.go new file mode 100644 index 00000000..f94d81a4 --- /dev/null +++ b/app-go/app-cloud/tests/summary_test.go @@ -0,0 +1,183 @@ +package tests + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "app-cloud/internal/config" + "app-cloud/internal/infra/mongostore" + "app-cloud/internal/model" + "app-cloud/internal/service" +) + +func TestSummaryReadNotOwnedIsNotFound(t *testing.T) { + files := &fakeFileCatalog{files: map[string]*model.CloudFile{ + "u1": {UploadUUID: "u1", UID: 2, OriginalName: "other.pdf"}, + }} + svc := service.NewSummaryServiceWithPorts(nil, files, &fakeSummaryStore{}) + if _, err := svc.Get(context.Background(), 1, "u1"); !errors.Is(err, service.ErrNotFound) { + t.Fatalf("want ErrNotFound for another owner's file, got %v", err) + } +} + +func TestSummaryReadWithoutStoredSummary(t *testing.T) { + file := cloudFile("u1", 1) + file.Title = strPtr("标题") + files := &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": file}} + svc := service.NewSummaryServiceWithPorts(nil, files, &fakeSummaryStore{}) + + view, err := svc.Get(context.Background(), 1, "u1") + if err != nil { + t.Fatalf("get: %v", err) + } + if view.Available || view.Stale || view.Summary != nil { + t.Fatalf("no stored summary must report unavailable: %+v", view) + } + if view.OriginalName != "u1.pdf" || view.Title != "标题" { + t.Fatalf("file identity missing: %+v", view) + } +} + +func TestSummaryReadAvailableAndStaleness(t *testing.T) { + file := cloudFile("u1", 1) + file.ContentHash = strPtr("hash-new") + files := &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": file}} + store := &fakeSummaryStore{stored: map[string]*mongostore.StoredSummary{ + "u1": { + Model: "z-ai/glm-5.3-flash", + ContentHash: "hash-new", + Summary: map[string]any{ + "overview": "文档概览", + "topics": []any{"主题"}, + "entities": []any{"实体"}, + "key_points": []any{"要点"}, + "todos": []any{"待办一", "待办二"}, + "answers_questions": []any{"能回答什么"}, + "language": "zh", + "size_chars": 1234, + }, + }, + }} + svc := service.NewSummaryServiceWithPorts(nil, files, store) + + view, err := svc.Get(context.Background(), 1, "u1") + if err != nil { + t.Fatalf("get: %v", err) + } + if !view.Available || view.Stale { + t.Fatalf("fresh summary must be available and not stale: %+v", view) + } + if view.Model != "z-ai/glm-5.3-flash" || view.Summary == nil || view.Summary.Overview != "文档概览" { + t.Fatalf("summary payload wrong: %+v", view) + } + if len(view.Summary.Todos) != 2 || view.Summary.Todos[0] != "待办一" { + t.Fatalf("todos lost: %+v", view.Summary.Todos) + } + if view.Summary.SizeChars != 1234 { + t.Fatalf("size_chars = %d", view.Summary.SizeChars) + } + + // The document content changed after the summary was generated. + file.ContentHash = strPtr("hash-changed") + view, err = svc.Get(context.Background(), 1, "u1") + if err != nil { + t.Fatalf("get: %v", err) + } + if !view.Available || !view.Stale { + t.Fatalf("mismatched content hash must be stale: %+v", view) + } +} + +// Documents summarised before the todos field existed still decode, with an +// empty todos list. +func TestSummaryReadLegacyDocumentHasEmptyTodos(t *testing.T) { + file := cloudFile("u1", 1) + files := &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": file}} + store := &fakeSummaryStore{stored: map[string]*mongostore.StoredSummary{ + "u1": {Model: "old-model", Summary: map[string]any{ + "overview": "旧摘要", + "topics": []any{"甲"}, + }}, + }} + svc := service.NewSummaryServiceWithPorts(nil, files, store) + view, err := svc.Get(context.Background(), 1, "u1") + if err != nil { + t.Fatalf("get: %v", err) + } + if view.Summary == nil { + t.Fatal("legacy summary must still decode") + } + if view.Summary.Todos == nil || len(view.Summary.Todos) != 0 { + t.Fatalf("legacy document must expose an empty todos list, got %#v", view.Summary.Todos) + } + if view.Summary.Entities == nil || view.Summary.KeyPoints == nil || view.Summary.AnswersQuestions == nil { + t.Fatalf("missing lists must decode as empty arrays, got %+v", view.Summary) + } +} + +func TestSummaryReadSurvivesMalformedStoredSummary(t *testing.T) { + file := cloudFile("u1", 1) + files := &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": file}} + store := &fakeSummaryStore{stored: map[string]*mongostore.StoredSummary{ + "u1": {Summary: map[string]any{"overview": 42}}, // wrong type + }} + svc := service.NewSummaryServiceWithPorts(nil, files, store) + view, err := svc.Get(context.Background(), 1, "u1") + if err != nil { + t.Fatalf("malformed summary must not fail the read: %v", err) + } + if view.Available || view.Summary != nil { + t.Fatalf("malformed summary must report unavailable: %+v", view) + } +} + +func TestSummaryReadPropagatesStoreError(t *testing.T) { + files := &fakeFileCatalog{files: map[string]*model.CloudFile{"u1": cloudFile("u1", 1)}} + svc := service.NewSummaryServiceWithPorts(nil, files, &fakeSummaryStore{err: errors.New("mongo down")}) + if _, err := svc.Get(context.Background(), 1, "u1"); err == nil { + t.Fatal("store errors must propagate so the handler can answer 500") + } +} + +// The summary prompt must request the todos field and the parser must keep it. +func TestSummarizerRequestsAndParsesTodos(t *testing.T) { + var systemContent string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if msgs, ok := body["messages"].([]any); ok && len(msgs) > 0 { + if system, ok := msgs[0].(map[string]any); ok { + systemContent, _ = system["content"].(string) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{ + "content": `{"overview":"概览","topics":["主题"],"entities":[],"key_points":["要点"],"todos":["下一步一","下一步二"],"answers_questions":["问题"],"language":"zh"}`, + }}}, + }) + })) + defer srv.Close() + + summarizer := service.NewSummarizer(config.SummaryConfig{ + Enabled: true, Model: "test-flash", MaxInputChars: 60000, MaxOutputTok: 1500, + }, "test-key", srv.URL) + if !summarizer.Enabled() { + t.Fatal("summarizer should be enabled with a model and gateway") + } + sum, err := summarizer.Summarize(context.Background(), "标题", "正文") + if err != nil { + t.Fatalf("summarize: %v", err) + } + if !strings.Contains(systemContent, "todos") { + t.Fatalf("summary prompt must request todos: %q", systemContent) + } + if len(sum.Todos) != 2 || sum.Todos[1] != "下一步二" { + t.Fatalf("todos lost after parsing: %+v", sum.Todos) + } +} diff --git a/app-go/app-cloud/tests/tier_status_test.go b/app-go/app-cloud/tests/tier_status_test.go new file mode 100644 index 00000000..291dfe24 --- /dev/null +++ b/app-go/app-cloud/tests/tier_status_test.go @@ -0,0 +1,31 @@ +package tests + +import ( + "testing" + + "app-cloud/internal/service" +) + +// Claim priority: paid tiers drain before free. Free/empty tiers and inactive +// memberships map to the low-priority queue (fail-closed, consistent with the +// quota stance). +func TestTierStatusForTier(t *testing.T) { + cases := []struct { + tier string + active bool + want string + }{ + {"svip", true, "pending"}, + {"SVIP", true, "pending"}, + {" vip ", true, "pending"}, + {"free", true, "pending_low"}, + {"", true, "pending_low"}, + {"vip", false, "pending_low"}, + {"svip", false, "pending_low"}, + } + for _, c := range cases { + if got := service.TierStatusForTier(c.tier, c.active); got != c.want { + t.Fatalf("TierStatusForTier(%q, %v) = %q, want %q", c.tier, c.active, got, c.want) + } + } +} diff --git a/app-go/app-cloud/internal/service/upload_test.go b/app-go/app-cloud/tests/upload_test.go similarity index 89% rename from app-go/app-cloud/internal/service/upload_test.go rename to app-go/app-cloud/tests/upload_test.go index 317a16e1..ab26b644 100644 --- a/app-go/app-cloud/internal/service/upload_test.go +++ b/app-go/app-cloud/tests/upload_test.go @@ -1,13 +1,15 @@ -package service +package tests import ( "strings" "testing" + + "app-cloud/internal/utils" ) // UUIDv7 wire format: 8-4-4-4-12 hex, version nibble 7, RFC-4122 variant. func TestUUID7Layout(t *testing.T) { - id, err := uuid7() + id, err := utils.UUID7() if err != nil { t.Fatal(err) } @@ -28,7 +30,7 @@ func TestUUID7Layout(t *testing.T) { func TestUUID7Unique(t *testing.T) { seen := map[string]bool{} for i := 0; i < 1000; i++ { - id, err := uuid7() + id, err := utils.UUID7() if err != nil { t.Fatal(err) } diff --git a/app-go/app-cloud/tests/utils_test.go b/app-go/app-cloud/tests/utils_test.go new file mode 100644 index 00000000..4ec28ffb --- /dev/null +++ b/app-go/app-cloud/tests/utils_test.go @@ -0,0 +1,50 @@ +package tests + +// Shared cloud helpers (internal/utils): the same title and truncation +// behaviour is used by the pipeline, services and storage adapters. + +import ( + "strings" + "testing" + "unicode/utf8" + + "app-cloud/internal/model" + "app-cloud/internal/utils" +) + +func TestDisplayTitlePrefersUserTitle(t *testing.T) { + title := "季度报告" + if got := utils.DisplayTitle(&model.CloudFile{Title: &title, OriginalName: "report.pdf"}); got != title { + t.Fatalf("display title = %q, want %q", got, title) + } + empty := " " + if got := utils.DisplayTitle(&model.CloudFile{Title: &empty, OriginalName: "report.pdf"}); got != "report.pdf" { + t.Fatalf("blank title must fall back to file name, got %q", got) + } + if got := utils.DisplayTitle(nil); got != "" { + t.Fatalf("nil file title = %q", got) + } +} + +func TestTruncateBytesKeepsUTF8Boundary(t *testing.T) { + long := strings.Repeat("错", 250) // 750 bytes + got := utils.TruncateBytes(long, 500) + if len(got) > 500 || !utf8.ValidString(got) { + t.Fatalf("invalid byte truncation: len=%d valid=%v", len(got), utf8.ValidString(got)) + } + if !strings.HasPrefix(long, got) { + t.Fatal("truncated value must be a prefix") + } + if got := utils.TruncateBytes("short", 500); got != "short" { + t.Fatalf("short passthrough = %q", got) + } +} + +func TestTruncateRunesCountsRunes(t *testing.T) { + if got := utils.TruncateRunes("一二三四五", 2); got != "一二" { + t.Fatalf("rune truncation = %q", got) + } + if got := utils.TruncateRunes("abc", 0); got != "" { + t.Fatalf("zero limit = %q", got) + } +} diff --git a/app-go/app-cloud/tests/wire_dto_test.go b/app-go/app-cloud/tests/wire_dto_test.go new file mode 100644 index 00000000..2e573efa --- /dev/null +++ b/app-go/app-cloud/tests/wire_dto_test.go @@ -0,0 +1,293 @@ +package tests + +// Wire-shape guards for the DTO layer: every domain's JSON must keep the exact +// field names, null vs [] semantics and optional-key behavior the frontend and +// the agent tools rely on. + +import ( + "encoding/json" + "net/http" + "sort" + "testing" + "time" + + "app-cloud/internal/config" + "app-cloud/internal/dto" + "app-cloud/internal/model" + "app-cloud/internal/router" +) + +// asJSON marshals v and decodes it back into a generic object for inspection. +func asJSON(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// assertKeys compares the object's exact key set (no extra or missing keys). +func assertKeys(t *testing.T, obj map[string]any, want ...string) { + t.Helper() + got := make([]string, 0, len(obj)) + for k := range obj { + got = append(got, k) + } + sort.Strings(got) + sort.Strings(want) + if len(got) != len(want) { + t.Fatalf("key set = %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("key set = %v, want %v", got, want) + } + } +} + +func TestUploadWireShape(t *testing.T) { + init := dto.UploadInitResponse{ + UploadUUID: "u", SessionUUID: "s", MinioUploadID: "m", + ChunkCount: 2, ChunkSize: 10, + PresignedURLs: []dto.UploadPresignedItem{{ChunkIndex: 0, ChunkSize: 10, URL: "http://x"}}, + } + obj := asJSON(t, init) + assertKeys(t, obj, "uploadUuid", "sessionUuid", "minioUploadId", "chunkCount", "chunkSize", "presignedUrls") + item := asJSON(t, init.PresignedURLs[0]) + assertKeys(t, item, "chunkIndex", "chunkSize", "url") + + complete := asJSON(t, dto.UploadCompleteResponse{UploadUUID: "u", ETag: "e", Status: "completed"}) + assertKeys(t, complete, "uploadUuid", "etag", "status") + + resume := asJSON(t, dto.UploadResumeResponse{ + UploadUUID: "u", MinioUploadID: "m", + PendingChunks: []dto.UploadResumeChunk{}, + }) + assertKeys(t, resume, "uploadUuid", "minioUploadId", "pendingChunks") + if _, ok := resume["pendingChunks"].([]any); !ok { + t.Fatalf("pendingChunks must be an array: %v", resume["pendingChunks"]) + } + + assertKeys(t, asJSON(t, dto.UploadHeartbeatResponse{Ack: true}), "ack") +} + +func TestFolderWireShape(t *testing.T) { + item := dto.FolderItem{ID: 1, Name: "root", VideoCount: 0, Children: []dto.FolderItem{}} + obj := asJSON(t, item) + assertKeys(t, obj, "id", "parent_id", "name", "video_count", "children") + if obj["parent_id"] != nil { + t.Fatalf("parent_id must be null at root: %v", obj["parent_id"]) + } + children, ok := obj["children"].([]any) + if !ok || len(children) != 0 { + t.Fatalf("children must be [] for a leaf: %v", obj["children"]) + } + + tree := asJSON(t, dto.FolderTreeResponse{Folders: []dto.FolderItem{item}}) + assertKeys(t, tree, "folders") + + created := asJSON(t, dto.FolderResponse{ID: 2, Name: "a", VideoCount: 0}) + assertKeys(t, created, "id", "parent_id", "name", "video_count") + + deleted := asJSON(t, dto.FolderDeleteResponse{Deleted: true, AffectedFiles: 3}) + assertKeys(t, deleted, "deleted", "affectedFiles") +} + +func TestFileWireShape(t *testing.T) { + item := dto.VideoItem{UploadUUID: "u", OriginalName: "a.pdf", AsrStatus: "pending", VectorStatus: "pending"} + list := asJSON(t, dto.VideoListResponse{Videos: []dto.VideoItem{item}, Page: 1, PageSize: 50}) + assertKeys(t, list, "videos", "total", "page", "pageSize", "hasMore") + itemJSON := asJSON(t, item) + assertKeys(t, itemJSON, "upload_uuid", "original_name", "file_size", "mime_type", + "duration", "asr_status", "vector_status", "title", "cover_url", "createdAt") + if itemJSON["title"] != nil || itemJSON["cover_url"] != nil || itemJSON["duration"] != nil { + t.Fatalf("nullable file fields must serialize as null: %v", itemJSON) + } + + detail := asJSON(t, dto.VideoDetailResponse{UploadUUID: "u", FolderName: "", VectorChunkCount: 0}) + assertKeys(t, detail, "upload_uuid", "original_name", "file_size", "mime_type", "duration", + "asr_status", "vector_status", "title", "cover_url", "created_at", "description", "tags", + "folder_id", "folderName", "asrPreview", "vector_chunk_count") + if detail["tags"] != nil { + t.Fatalf("nil tags must serialize as null: %v", detail["tags"]) + } + if detail["asrPreview"] != nil { + t.Fatalf("asrPreview must be null when absent: %v", detail["asrPreview"]) + } + + preview := asJSON(t, dto.DocumentPreviewResponse{UploadUUID: "u", HasMore: false}) + assertKeys(t, preview, "upload_uuid", "fileName", "mime_type", "vectorizable", "preview", + "docMeta", "offset", "limit", "totalChars", "hasMore", "nextOffset") + if preview["nextOffset"] != nil { + t.Fatalf("nextOffset must be null on the last page: %v", preview["nextOffset"]) + } + + raw := asJSON(t, dto.RawFileResponse{URL: "http://x", ViewMode: "pdf"}) + assertKeys(t, raw, "url", "content", "mimeType", "fileName", "fileSize", "viewMode") + if raw["content"] != nil { + t.Fatalf("content must be null when not inlined: %v", raw["content"]) + } + + status := asJSON(t, dto.VideoStatusResponse{}) + assertKeys(t, status, "asr_status", "asrProgress", "vector_status", "vectorChunkCount") + + search := asJSON(t, dto.SearchFilesResponse{Results: []dto.VideoItem{}, Total: 0}) + assertKeys(t, search, "results", "total") + + assertKeys(t, asJSON(t, dto.VideoDeleteResponse{Deleted: true, UploadUUID: "u"}), "deleted", "uploadUuid") + assertKeys(t, asJSON(t, dto.VideoProcessResponse{UploadUUID: "u"}), "uploadUuid") + assertKeys(t, asJSON(t, dto.VideoReprocessResponse{UploadUUID: "u", TaskID: "t"}), "uploadUuid", "taskId") +} + +func TestTrashWireShape(t *testing.T) { + item := asJSON(t, dto.TrashItem{UploadUUID: "u", OriginalName: "a"}) + assertKeys(t, item, "upload_uuid", "original_name", "file_size", "mime_type", "deleted_at", "purge_at") + if item["deleted_at"] != nil || item["purge_at"] != nil { + t.Fatalf("timestamps must serialize as null: %v", item) + } + + list := asJSON(t, dto.TrashListResponse{Items: []dto.TrashItem{}, Total: 0}) + assertKeys(t, list, "items", "total") + assertKeys(t, asJSON(t, dto.TrashRestoreResponse{Restored: true, UploadUUID: "u"}), "restored", "upload_uuid") + assertKeys(t, asJSON(t, dto.TrashPurgeResponse{Purged: true, UploadUUID: "u"}), "purged", "upload_uuid") + assertKeys(t, asJSON(t, dto.EmptyTrashResponse{Purged: 2}), "purged") +} + +func TestQuotaWireShape(t *testing.T) { + ok := asJSON(t, dto.QuotaResponse{Used: 1, Quota: 2, Tier: "free", Pending: 0}) + assertKeys(t, ok, "used", "quota", "tier", "pending") + failed := asJSON(t, dto.QuotaResponse{Used: 1, Quota: 2, Tier: "free", Pending: 0, Error: "boom"}) + assertKeys(t, failed, "used", "quota", "tier", "pending", "error") +} + +func TestShareWireShape(t *testing.T) { + token, url := "tok", "/cloud/s/tok" + now := time.Now() + view := asJSON(t, dto.ShareView{ + ID: 1, FileID: 2, ExtractionCode: "", CreatedAt: &now, + TargetType: "file", ModerationStatus: model.ModerationActive, + ShareToken: &token, ShareURL: &url, + }) + assertKeys(t, view, "id", "file_id", "has_code", "extraction_code", "expires_at", + "max_downloads", "view_count", "download_count", "is_revoked", "created_at", + "target_type", "folder_id", "legal_ack", "content_review_ack", + "content_review_policy_version", "content_review_ack_at", "moderation_status", + "moderation_reason", "moderation_time", "moderation_by", "share_token", "share_url") + if view["expires_at"] != nil || view["max_downloads"] != nil { + t.Fatalf("nullable share fields must serialize as null: %v", view) + } + + list := asJSON(t, dto.ShareListResponse{Shares: []dto.ShareView{}, Total: 0}) + assertKeys(t, list, "shares", "total") + assertKeys(t, asJSON(t, dto.ShareRevokeResponse{Revoked: true}), "revoked") + assertKeys(t, asJSON(t, dto.ShareCodeResponse{Updated: true}), "updated") + + fileInfo := dto.ShareInfoResponse{TargetType: "file", RequiresCode: false, Challenge: "c"} + name, size, mime := "a.pdf", int64(10), "application/pdf" + fileInfo.FileName, fileInfo.FileSize, fileInfo.MimeType = &name, &size, &mime + fileObj := asJSON(t, fileInfo) + assertKeys(t, fileObj, "targetType", "requiresCode", "createdAt", "expiresAt", "challenge", + "fileName", "fileSize", "mimeType") + if fileObj["createdAt"] != nil || fileObj["expiresAt"] != nil { + t.Fatalf("nullable timestamps must be null: %v", fileObj) + } + + folderName := "folder" + truncated := false + files := []dto.SharePublicFile{} + folderInfo := asJSON(t, dto.ShareInfoResponse{ + TargetType: "folder", RequiresCode: true, Challenge: "c", + FolderName: &folderName, Files: &files, Truncated: &truncated, + }) + assertKeys(t, folderInfo, "targetType", "requiresCode", "createdAt", "expiresAt", + "challenge", "folderName", "files", "truncated") + if f, ok := folderInfo["files"].([]any); !ok || len(f) != 0 { + t.Fatalf("files must be [] for an empty folder share: %v", folderInfo["files"]) + } + if folderInfo["truncated"] != false { + t.Fatalf("truncated must stay present and false: %v", folderInfo["truncated"]) + } + pubFile := asJSON(t, dto.SharePublicFile{FileID: 1, Name: "a", Path: "x"}) + assertKeys(t, pubFile, "fileId", "name", "size", "mimeType", "path") + + assertKeys(t, asJSON(t, dto.ShareAttestResponse{Ticket: "t", ExpiresIn: 60}), "ticket", "expiresIn") + assertKeys(t, asJSON(t, dto.ShareAccessResponse{URL: "u"}), "url", "viewMode", "mimeType", + "fileName", "fileSize", "expiresIn") +} + +func TestAdminReviewWireShape(t *testing.T) { + grant := asJSON(t, dto.AdminReviewAccessResponse{URL: "http://x", ViewMode: "pdf", ExpiresIn: 900}) + assertKeys(t, grant, "url", "viewMode", "mimeType", "fileName", "fileSize", "expiresIn") + + truncated := false + textObj := asJSON(t, dto.AdminReviewAccessResponse{ + ViewMode: "text", Text: "body", Truncated: &truncated, + }) + assertKeys(t, textObj, "viewMode", "mimeType", "fileName", "fileSize", "expiresIn", "text", "truncated") + if textObj["truncated"] != false { + t.Fatalf("text preview must expose truncated=false: %v", textObj["truncated"]) + } +} + +func TestRefWireShape(t *testing.T) { + omitted := asJSON(t, dto.SaveRefResponse{Saved: 1}) + assertKeys(t, omitted, "saved") + root := int64(9) + withRoot := asJSON(t, dto.SaveRefResponse{Saved: 2, RootFolderID: &root}) + assertKeys(t, withRoot, "saved", "rootFolderId") + + item := asJSON(t, dto.RefItem{ID: 1, Name: "a", Status: "alive", IsRef: true}) + assertKeys(t, item, "id", "name", "size", "mimeType", "folderId", "status", "shareId", + "sourceUploadUuid", "sourceName", "createdAt", "isRef") + list := asJSON(t, dto.RefListResponse{Refs: []dto.RefItem{}, Total: 0}) + assertKeys(t, list, "refs", "total") + assertKeys(t, asJSON(t, dto.RefDeleteResponse{Deleted: true}), "deleted") + assertKeys(t, asJSON(t, dto.RefRawResponse{URL: "u"}), "url", "viewMode", "mimeType", + "fileName", "fileSize", "expiresIn") +} + +func TestWorkspaceWireShape(t *testing.T) { + ws := asJSON(t, dto.Workspace{ID: 1, Name: "w", Bindings: []dto.WorkspaceBinding{}}) + assertKeys(t, ws, "id", "name", "description", "icon", "color", "fileCount", "chunkCount", + "bindings", "createdAt", "updatedAt") + if b, ok := ws["bindings"].([]any); !ok || len(b) != 0 { + t.Fatalf("bindings must be [] when empty: %v", ws["bindings"]) + } + binding := asJSON(t, dto.WorkspaceBinding{ID: 1, BindType: "folder", IncludeSubfolders: true}) + assertKeys(t, binding, "id", "bindType", "folderId", "folderName", "uploadUuid", "fileName", "includeSubfolders") + if binding["folderName"] != nil || binding["fileName"] != nil { + t.Fatalf("binding names must serialize as null: %v", binding) + } + fileItem := asJSON(t, dto.WorkspaceFileItem{UploadUUID: "u", Vectorizable: true}) + assertKeys(t, fileItem, "uploadUuid", "originalName", "mimeType", "vectorizable", + "vectorStatus", "vectorChunkCount") + assertKeys(t, asJSON(t, dto.WorkspaceDeleteResponse{Deleted: true}), "deleted") +} + +func TestInternalAndCommonWireShape(t *testing.T) { + reconcile := asJSON(t, dto.ReconcileResponse{UID: 1, OrphanSample: []string{}}) + assertKeys(t, reconcile, "uid", "db_active_bytes", "db_active_count", "db_trash_bytes", + "minio_bytes", "minio_count", "orphan_objects", "orphan_sample", "missing_objects", "drift_bytes") + + health := asJSON(t, dto.HealthResponse{Status: "ok", Service: "app-cloud"}) + assertKeys(t, health, "status", "service") + assertKeys(t, asJSON(t, dto.ErrorResponse{Detail: "boom"}), "detail") +} + +// TestHealthEndpointWire exercises the health route through the real engine. +func TestHealthEndpointWire(t *testing.T) { + engine := router.New(router.Deps{Cfg: &config.Config{}}) + code, body := doJSON(t, engine, http.MethodGet, "/health", "", nil) + if code != http.StatusOK { + t.Fatalf("health status = %d", code) + } + assertKeys(t, body, "status", "service") + if body["status"] != "ok" || body["service"] != "app-cloud" { + t.Fatalf("health body = %v", body) + } +} diff --git a/app-go/app-cloud/internal/service/workerpool_test.go b/app-go/app-cloud/tests/workerpool_test.go similarity index 54% rename from app-go/app-cloud/internal/service/workerpool_test.go rename to app-go/app-cloud/tests/workerpool_test.go index 476c8b40..fae8a1b3 100644 --- a/app-go/app-cloud/internal/service/workerpool_test.go +++ b/app-go/app-cloud/tests/workerpool_test.go @@ -1,26 +1,30 @@ -package service +package tests -import "testing" +import ( + "testing" + + "app-cloud/internal/service" +) // Elastic growth rule: double the pool while the pending backlog exceeds // twice the current size, capped at maxWorkers, and never shrink (growth-only -// — StopWorkers tears the pool down wholesale). +// — teardown stops the whole pool). func TestNextWorkerCount(t *testing.T) { cases := []struct { cur, pending, max, want int note string }{ - {4, 0, 32, 4, "empty queue → no growth"}, - {4, 8, 32, 4, "pending == 2x → no growth (strictly greater triggers)"}, - {4, 9, 32, 8, "pending > 2x → double"}, + {4, 0, 32, 4, "empty queue -> no growth"}, + {4, 8, 32, 4, "pending == 2x -> no growth (strictly greater triggers)"}, + {4, 9, 32, 8, "pending > 2x -> double"}, {4, 100, 32, 8, "huge backlog still doubles once per tick (gradual)"}, {30, 100, 32, 32, "capped at maxWorkers"}, - {32, 100, 32, 32, "at cap → no growth"}, + {32, 100, 32, 32, "at cap -> no growth"}, {16, 0, 32, 16, "idle pool stays at its size"}, } for _, c := range cases { - if got := nextWorkerCount(c.cur, c.pending, c.max); got != c.want { - t.Fatalf("nextWorkerCount(%d, %d, %d) = %d, want %d (%s)", + if got := service.NextWorkerCount(c.cur, c.pending, c.max); got != c.want { + t.Fatalf("NextWorkerCount(%d, %d, %d) = %d, want %d (%s)", c.cur, c.pending, c.max, got, c.want, c.note) } } diff --git a/app-ts/config/default.yaml b/app-ts/config/default.yaml index 8cdeafd1..de13906e 100644 --- a/app-ts/config/default.yaml +++ b/app-ts/config/default.yaml @@ -34,6 +34,13 @@ appAuth: apiKey: ${APISIX_CONSUMER_KEY} timeoutMs: 5000 +appCloud: + # 云盘文档智能读取(/internal/cloud/summary,apikey 守卫);uid 只经 X-Uid + # 与查询参数下发,永不出现在 LLM 可写参数里 + baseUrl: ${APP_CLOUD_BASE_URL:http://app-cloud:8007} + apiKey: ${APISIX_CONSUMER_KEY} + timeoutMs: 8000 + taskQuiz: # 定时出题:agent 对话(/task-quiz/chat)+ 注册(/task-quiz/register) # + app-task 执行器回调(/internal/quiz/generate-llm|status) diff --git a/app-ts/prompts/agents/main.md b/app-ts/prompts/agents/main.md index 617268f8..c2085904 100644 --- a/app-ts/prompts/agents/main.md +++ b/app-ts/prompts/agents/main.md @@ -10,6 +10,9 @@ - **ask_user**:问题含糊需要澄清,或写操作(如删除板子)前需要确认时调用;一次只发起一个交互,调用后本轮结束等待用户回复。不要替用户作答,也不要在回复文本里重复问题内容。 - **fetch_webpage**:用户给出具体 URL,或问题需要网页的当前内容(新闻/在线文档/知识库不可能有的信息)时调用;抓取公开网页正文,内网地址会被拒绝。可跟进返回的 links 抓取后续页面。 - **web_search**:需要公开/最新资料而知识库可能没有时先联网搜索(考试大纲、行业规范、新闻、版本动态),拿到线索后用 fetch_webpage 抓正文;出题/规范类需求可把线索完整转交给 quiz 子 agent。 +- **cloud_doc_summary**:用户明确给出云盘文件 UUID(如 /cloud-drive/ 链接)并询问该文档的摘要/要点/待办时调用,读取已生成的 AI 摘要;不要凭空编造 UUID,摘要尚未生成时如实告知用户可重新入库。 +- **cloud_semantic_search**:用户要在云盘里按语义精确找文件("哪份文档讲过 X""找一下关于 X 的资料")时调用,可限定文件夹;通用知识库问答(视频/云盘混合)仍用 vector_search。 +- **cloud_similar_files**:已知某云盘文件 UUID、用户想找内容相近的其他文件时调用;服务端已排除文件自身。 {{KG_SEARCH_SECTION}} {{SKILLS_SECTION}} diff --git a/app-ts/src/agent/main/agent.ts b/app-ts/src/agent/main/agent.ts index f4e671d2..a40c9edd 100644 --- a/app-ts/src/agent/main/agent.ts +++ b/app-ts/src/agent/main/agent.ts @@ -19,7 +19,13 @@ function buildScopeLine(scope: SearchScope): string { parts.push(`限定视频范围:${scope.bvids.length} 个(已自动应用,无需重复检索)`); } if (scope.uploadUuids && scope.uploadUuids.length > 0) { - parts.push(`限定云盘文档范围:${scope.uploadUuids.length} 个`); + // Include the UUIDs (bounded) so file-scoped chats can call cloud_doc_summary + // directly instead of asking the user for an opaque identifier. + const shown = scope.uploadUuids.slice(0, 5).join("、"); + const suffix = scope.uploadUuids.length > 5 ? " 等" : ""; + parts.push( + `限定云盘文档范围:${scope.uploadUuids.length} 个(upload_uuid:${shown}${suffix};涉及具体文件时可调用 cloud_doc_summary)`, + ); } if (scope.mediaIds && scope.mediaIds.length > 0) { parts.push(`限定视频分P范围:${scope.mediaIds.length} 个`); @@ -58,6 +64,9 @@ export function createMainAgent( "plan", "fetch_webpage", "web_search", + "cloud_doc_summary", + "cloud_semantic_search", + "cloud_similar_files", ...(includeKgTool ? ["kg_search"] : []), ], purpose: "chat", diff --git a/app-ts/src/services/app-auth-client.ts b/app-ts/src/auth/app-auth-client.ts similarity index 100% rename from app-ts/src/services/app-auth-client.ts rename to app-ts/src/auth/app-auth-client.ts diff --git a/app-ts/src/services/app-bili-client.ts b/app-ts/src/auth/app-bili-client.ts similarity index 100% rename from app-ts/src/services/app-bili-client.ts rename to app-ts/src/auth/app-bili-client.ts diff --git a/app-ts/src/auth/app-cloud-client.ts b/app-ts/src/auth/app-cloud-client.ts new file mode 100644 index 00000000..156bc033 --- /dev/null +++ b/app-ts/src/auth/app-cloud-client.ts @@ -0,0 +1,225 @@ +import { config } from "../config/index.js"; +import { InternalServiceClient } from "../transport/internal-client.js"; + +/** + * Client for app-cloud's internal document-intelligence face + * (GET /internal/cloud/summary?uid=&upload_uuid=, apikey-guarded). + * + * The cloud-drive file and its summary belong to app-cloud; app-ts only reads + * them over this M2M face. Identity rides the X-Uid header and the uid query + * parameter — both are filled from the authenticated tool context, never from + * model-controlled arguments. Nothing is cached here: a re-ingest or a + * regeneration must be observable on the next call. + */ + +export interface CloudDocumentSummary { + overview: string; + topics: string[]; + entities: string[]; + key_points: string[]; + /** Executable follow-ups extracted from the document; empty for summaries generated before this field existed. */ + todos: string[]; + answers_questions: string[]; + language: string; + size_chars: number; +} + +export interface CloudSummaryResponse { + upload_uuid: string; + /** false = the document exists but no summary has been generated (yet). */ + available: boolean; + /** true = the file content changed after the summary was generated. */ + stale: boolean; + /** Model that produced the summary; empty when none. */ + model: string; + summary: CloudDocumentSummary | null; + /** File display name, supplied by the service when available. */ + title?: string; + original_name?: string; +} + +/** One ranked cloud-drive file returned by semantic search / similar files. */ +export interface CloudSemanticHit { + upload_uuid: string; + /** File name as stored (always present on the wire). */ + original_name: string; + /** Human title when the drive derived one; often empty. */ + title: string; + mime_type: string; + file_size: number; + score: number; + /** Distinct matching chunks, best first. */ + snippets: string[]; +} + +export interface CloudSemanticSearchResponse { + mode: string; + results: CloudSemanticHit[]; + total: number; +} + +export interface CloudSimilarResponse { + items: CloudSemanticHit[]; +} + +export class AppCloudError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + this.name = "AppCloudError"; + } +} + +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + for (const item of value) { + if (typeof item === "string" && item.trim() !== "") out.push(item.trim()); + } + return out; +} + +function optionalText(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined; +} + +function finiteNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function normalizeHit(raw: unknown): CloudSemanticHit | null { + if (raw === null || typeof raw !== "object") return null; + const r = raw as Record; + const uploadUuid = optionalText(r.upload_uuid); + if (uploadUuid === undefined) return null; + return { + upload_uuid: uploadUuid, + original_name: optionalText(r.original_name) ?? "", + title: optionalText(r.title) ?? "", + mime_type: optionalText(r.mime_type) ?? "", + file_size: finiteNumber(r.file_size, 0), + score: finiteNumber(r.score, 0), + snippets: stringList(r.snippets), + }; +} + +function normalizeHits(raw: unknown): CloudSemanticHit[] { + if (!Array.isArray(raw)) return []; + const hits: CloudSemanticHit[] = []; + for (const item of raw) { + const hit = normalizeHit(item); + if (hit) hits.push(hit); + } + return hits; +} + +function normalizeSummary(raw: unknown): CloudDocumentSummary | null { + if (raw === null || typeof raw !== "object") return null; + const r = raw as Record; + const size = r.size_chars; + return { + overview: typeof r.overview === "string" ? r.overview : "", + topics: stringList(r.topics), + entities: stringList(r.entities), + key_points: stringList(r.key_points), + todos: stringList(r.todos), + answers_questions: stringList(r.answers_questions), + language: typeof r.language === "string" ? r.language : "", + size_chars: typeof size === "number" && Number.isFinite(size) ? size : 0, + }; +} + +export class AppCloudClient extends InternalServiceClient { + constructor(opts?: { baseUrl?: string; apiKey?: string; timeoutMs?: number }) { + super( + { + baseUrl: opts?.baseUrl ?? config.appCloud.baseUrl, + apiKey: opts?.apiKey ?? config.appCloud.apiKey, + timeoutMs: opts?.timeoutMs ?? config.appCloud.timeoutMs, + }, + "app-cloud", + ); + } + + /** Both halves are required: an apikey-less call would only ever collect 401/503. */ + get configured(): boolean { + return this.baseUrl !== "" && this.apiKey !== ""; + } + + protected override error(message: string, status?: number): Error { + return new AppCloudError(message, status); + } + + /** Read one document's summary for `uid`; the uid is sent both as X-Uid and as the query identity. */ + async getSummary(uid: string, uploadUuid: string): Promise { + const query = new URLSearchParams({ uid, upload_uuid: uploadUuid }); + try { + const res = await this.request("GET", `/internal/cloud/summary?${query.toString()}`, undefined, uid); + const raw = await this.json>(res, "云盘摘要读取失败"); + return this.normalize(raw, uploadUuid); + } catch (err) { + if (err instanceof AppCloudError) throw err; + throw new AppCloudError( + `云盘摘要服务调用失败:${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Search the user's cloud drive by meaning; `folderId` scopes it to that folder's subtree. */ + async searchSemantic( + uid: string, + query: string, + opts: { folderId?: number; limit?: number } = {}, + ): Promise { + const params = new URLSearchParams({ q: query }); + if (opts.folderId !== undefined) params.set("folderId", String(opts.folderId)); + if (opts.limit !== undefined) params.set("limit", String(opts.limit)); + try { + const res = await this.request("GET", `/cloud/search/semantic?${params.toString()}`, undefined, uid); + const raw = await this.json>(res, "云盘语义检索失败"); + const results = normalizeHits(raw.results); + return { + mode: optionalText(raw.mode) ?? "semantic", + results, + total: finiteNumber(raw.total, results.length), + }; + } catch (err) { + throw this.wrapCallFailure("云盘语义检索调用失败", err); + } + } + + /** Files whose content is closest to one owned file; the service excludes the file itself. */ + async getSimilar(uid: string, uploadUuid: string, limit?: number): Promise { + const params = new URLSearchParams({ uid, upload_uuid: uploadUuid }); + if (limit !== undefined) params.set("limit", String(limit)); + try { + const res = await this.request("GET", `/internal/cloud/similar?${params.toString()}`, undefined, uid); + const raw = await this.json>(res, "云盘相似文件读取失败"); + return { items: normalizeHits(raw.items) }; + } catch (err) { + throw this.wrapCallFailure("云盘相似文件调用失败", err); + } + } + + private wrapCallFailure(label: string, err: unknown): AppCloudError { + if (err instanceof AppCloudError) return err; + return new AppCloudError(`${label}:${err instanceof Error ? err.message : String(err)}`); + } + + private normalize(raw: Record, fallbackUuid: string): CloudSummaryResponse { + const uploadUuid = optionalText(raw.upload_uuid) ?? fallbackUuid; + const title = optionalText(raw.title); + const originalName = optionalText(raw.original_name); + return { + upload_uuid: uploadUuid, + available: raw.available === true, + stale: raw.stale === true, + model: typeof raw.model === "string" ? raw.model : "", + summary: normalizeSummary(raw.summary), + ...(title !== undefined ? { title } : {}), + ...(originalName !== undefined ? { original_name: originalName } : {}), + }; + } +} diff --git a/app-ts/src/services/app-quiz-client.ts b/app-ts/src/auth/app-quiz-client.ts similarity index 100% rename from app-ts/src/services/app-quiz-client.ts rename to app-ts/src/auth/app-quiz-client.ts diff --git a/app-ts/src/services/app-task-client.ts b/app-ts/src/auth/app-task-client.ts similarity index 100% rename from app-ts/src/services/app-task-client.ts rename to app-ts/src/auth/app-task-client.ts diff --git a/app-ts/src/services/literature-client.ts b/app-ts/src/auth/literature-client.ts similarity index 100% rename from app-ts/src/services/literature-client.ts rename to app-ts/src/auth/literature-client.ts diff --git a/app-ts/src/bootstrap/container.ts b/app-ts/src/bootstrap/container.ts index 4ccafb55..e9d04976 100644 --- a/app-ts/src/bootstrap/container.ts +++ b/app-ts/src/bootstrap/container.ts @@ -19,6 +19,11 @@ import { createAskUserTool } from "../tools/interaction/ask-user.js"; import { createPlanTool } from "../tools/harness/plan.js"; import { createFetchWebpageTool } from "../tools/web/fetch-page.js"; import { createWebSearchTool } from "../tools/web/search.js"; +import { + createCloudDocSummaryTool, + createCloudSemanticSearchTool, + createCloudSimilarFilesTool, +} from "../tools/cloud/index.js"; import { createFetchPortWithDeepseekPrimary } from "../tools/web/deepseek.js"; import { HttpWebFetch } from "../tools/web/fetcher.js"; import { AgentOrchestrator } from "../agent/orchestrator.js"; @@ -34,7 +39,7 @@ import { createAcademicAgent } from "../agent/academic/agent.js"; import { createAcademicProjectTool } from "../tools/academic/project.js"; import { createAcademicLiteratureTool } from "../tools/academic/literature.js"; import { createAcademicManuscriptTool } from "../tools/academic/manuscript.js"; -import { createLiteratureClient } from "../services/literature-client.js"; +import { createLiteratureClient } from "../auth/literature-client.js"; import { AcademicService } from "../services/academic.js"; import { createAcademicEvidenceTool } from "../tools/academic/evidence.js"; import { MemoryAcademicEvidenceStore } from "../repository/memory/academic-evidence.js"; @@ -49,7 +54,7 @@ import { BoardCompletionService } from "../services/board-completion.js"; import { SessionSummaryService } from "../services/session-summary.js"; import { QuizGenerationService } from "../services/quiz-generation.js"; import { QuizGradingService } from "../services/quiz-grading.js"; -import { AppQuizClient } from "../services/app-quiz-client.js"; +import { AppQuizClient } from "../auth/app-quiz-client.js"; import { QuotaService } from "../services/quota.js"; import { UsageReconciliationJob } from "../services/usage-reconciliation.js"; import { RedisQuotaCounter } from "../repository/redis/quota-counter.js"; @@ -96,11 +101,12 @@ import { MongoSessionDocumentStore } from "../repository/mongo/session-docs.js"; import { MongoAgentContextStore } from "../repository/mongo/agent-context.js"; import { MemoryVectorTaskStore } from "../repository/memory/vector-tasks.js"; import { MongoVectorTaskStore } from "../repository/mongo/vector-tasks.js"; -import { AppBiliClient } from "../services/app-bili-client.js"; +import { AppBiliClient } from "../auth/app-bili-client.js"; import { VideoVectorizerService } from "../services/video-vectorizer.js"; import { BiliEventBridge } from "../bootstrap/redis-events.js"; -import { AppTaskClient } from "../services/app-task-client.js"; -import { AppAuthClient } from "../services/app-auth-client.js"; +import { AppTaskClient } from "../auth/app-task-client.js"; +import { AppAuthClient } from "../auth/app-auth-client.js"; +import { AppCloudClient } from "../auth/app-cloud-client.js"; import { MemoryTaskQuizDocStore } from "../repository/memory/task-quiz-docs.js"; import { MongoTaskQuizDocStore } from "../repository/mongo/task-quiz-docs.js"; import { TaskQuizGenerationService } from "../services/task-quiz-generation.js"; @@ -299,6 +305,25 @@ export async function bootstrap() { const webFetch = createFetchPortWithDeepseekPrimary(new HttpWebFetch()); registry.register(createFetchWebpageTool(webFetch)); registry.register(createWebSearchTool()); + // Cloud-drive document intelligence: summary / semantic search / similar files + // are read from app-cloud's existing endpoints (apikey + X-Uid). The tools are + // registered unconditionally so a deployment without app-cloud answers with an + // explicit "not configured" notice instead of silently losing the capability + // from the agent roster. + const appCloud = new AppCloudClient(); + registry.register(createCloudDocSummaryTool(appCloud)); + registry.register(createCloudSemanticSearchTool(appCloud)); + registry.register(createCloudSimilarFilesTool(appCloud)); + if (appCloud.configured) { + log.info( + `[BOOT] cloud tools registered: cloud_doc_summary / cloud_semantic_search / cloud_similar_files (app-cloud ${config.appCloud.baseUrl})`, + ); + } else { + log.warn( + "[BOOT] app-cloud not configured (APP_CLOUD_BASE_URL / APISIX_CONSUMER_KEY) — " + + "cloud tools will answer with a not-configured notice", + ); + } // 2.7 KG (knowledge graph): AI (extraction/retrieval/build) here, storage // (Neo4j + video.kg bookkeeping) in the app-kg Go service over gRPC. diff --git a/app-ts/src/config/index.ts b/app-ts/src/config/index.ts index 265b90c9..dd4e5fc6 100644 --- a/app-ts/src/config/index.ts +++ b/app-ts/src/config/index.ts @@ -32,6 +32,12 @@ export interface AppConfig { apiKey: string; timeoutMs: number; }; + appCloud: { + /** cloud-drive service internal face (/internal/cloud/*, apikey-guarded) */ + baseUrl: string; + apiKey: string; + timeoutMs: number; + }; taskQuiz: { /** false = /task-quiz/* + /internal/quiz/generate-llm not served */ enabled: boolean; diff --git a/app-ts/src/services/academic.ts b/app-ts/src/services/academic.ts index 8e809be0..e8260bf0 100644 --- a/app-ts/src/services/academic.ts +++ b/app-ts/src/services/academic.ts @@ -23,7 +23,7 @@ import { type AcademicStageState, type AcademicStageStatus, } from "../repository/academic.js"; -import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "./literature-client.js"; +import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../auth/literature-client.js"; import { createLogger } from "../logger.js"; const log = createLogger("ACADEMIC"); diff --git a/app-ts/src/services/blindspot.ts b/app-ts/src/services/blindspot.ts index 18cf9c08..3a7fa159 100644 --- a/app-ts/src/services/blindspot.ts +++ b/app-ts/src/services/blindspot.ts @@ -3,8 +3,8 @@ import { createLogger } from "../logger.js"; import type { KgGraphRepository } from "../repository/kg-graph.js"; import type { KgEntityIndex } from "../repository/kg-entity-index.js"; import type { EmbeddingsClient } from "../llm/embeddings.js"; -import type { AppBiliClient } from "./app-bili-client.js"; -import type { AppQuizClient } from "./app-quiz-client.js"; +import type { AppBiliClient } from "../auth/app-bili-client.js"; +import type { AppQuizClient } from "../auth/app-quiz-client.js"; import type { QuizGenerationService } from "./quiz-generation.js"; import { normalizeName } from "../kg/resolver.js"; import type { ExposureRow } from "../kg/types.js"; diff --git a/app-ts/src/services/quiz-generation.ts b/app-ts/src/services/quiz-generation.ts index 2eb2b74f..3b0c2a2f 100644 --- a/app-ts/src/services/quiz-generation.ts +++ b/app-ts/src/services/quiz-generation.ts @@ -7,7 +7,7 @@ import { aiTelemetry } from "../llm/tracing.js"; import { createLogger } from "../logger.js"; import type { PromptRegistry } from "../agent/prompts/loader.js"; import type { SessionSummaryService } from "./session-summary.js"; -import type { AgentQuestionPayload, AppQuizClient, WrongAnswerEntry } from "./app-quiz-client.js"; +import type { AgentQuestionPayload, AppQuizClient, WrongAnswerEntry } from "../auth/app-quiz-client.js"; import type { RAGService } from "../rag/service.js"; import type { Document } from "../rag/types.js"; import { parseDocument } from "../doc-parser/index.js"; diff --git a/app-ts/src/services/quiz-grading.ts b/app-ts/src/services/quiz-grading.ts index 98060e5b..fa64cbb5 100644 --- a/app-ts/src/services/quiz-grading.ts +++ b/app-ts/src/services/quiz-grading.ts @@ -4,8 +4,8 @@ import { createGatewayModel } from "../llm/provider.js"; import { aiTelemetry } from "../llm/tracing.js"; import { createLogger } from "../logger.js"; import { EXAM_BLUEPRINTS } from "./quiz-exam-blueprints.js"; -import { AppQuizError } from "./app-quiz-client.js"; -import type { AppQuizClient, GradedAnswerPayload, GradingPayload } from "./app-quiz-client.js"; +import { AppQuizError } from "../auth/app-quiz-client.js"; +import type { AppQuizClient, GradedAnswerPayload, GradingPayload } from "../auth/app-quiz-client.js"; import { QuizGenerationError } from "./quiz-generation.js"; import { blueprintTotal } from "./quiz-exam-blueprints.js"; import { blueprintDistribution, expandBlueprintPlan } from "./quiz-generation.js"; diff --git a/app-ts/src/services/task-quiz-agent.ts b/app-ts/src/services/task-quiz-agent.ts index fdd7b9bd..a891c115 100644 --- a/app-ts/src/services/task-quiz-agent.ts +++ b/app-ts/src/services/task-quiz-agent.ts @@ -2,7 +2,7 @@ import { streamText, stepCountIs, tool, type ModelMessage, type LanguageModel } import { z } from "zod"; import { createGatewayModel } from "../llm/provider.js"; import { createLogger } from "../logger.js"; -import { AppTaskClient } from "./app-task-client.js"; +import { AppTaskClient } from "../auth/app-task-client.js"; import { config } from "../config/index.js"; const log = createLogger("TASK_QUIZ_AGENT"); diff --git a/app-ts/src/services/task-quiz-generation.ts b/app-ts/src/services/task-quiz-generation.ts index 9cb45efb..536bd20c 100644 --- a/app-ts/src/services/task-quiz-generation.ts +++ b/app-ts/src/services/task-quiz-generation.ts @@ -10,8 +10,8 @@ import { type TaskQuizDoc, type TaskQuizQuestion, } from "../repository/task-quiz-docs.js"; -import { AppQuizClient } from "./app-quiz-client.js"; -import { AppTaskClient, bestEffort } from "./app-task-client.js"; +import { AppQuizClient } from "../auth/app-quiz-client.js"; +import { AppTaskClient, bestEffort } from "../auth/app-task-client.js"; import type { PromptRegistry } from "../agent/prompts/loader.js"; const log = createLogger("TASK_QUIZ_GEN"); diff --git a/app-ts/src/tools/academic/literature.ts b/app-ts/src/tools/academic/literature.ts index 12618414..daaf7e8d 100644 --- a/app-ts/src/tools/academic/literature.ts +++ b/app-ts/src/tools/academic/literature.ts @@ -14,7 +14,7 @@ import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions, -} from "../../services/literature-client.js"; +} from "../../auth/literature-client.js"; /** * academic_literature: the project literature library. diff --git a/app-ts/src/tools/cloud/index.ts b/app-ts/src/tools/cloud/index.ts new file mode 100644 index 00000000..a0a555c3 --- /dev/null +++ b/app-ts/src/tools/cloud/index.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import { defineTool, type ToolOutput } from "../base.js"; +import { createLogger } from "../../logger.js"; +import type { CloudDocumentSummary, CloudSummaryResponse } from "../../auth/app-cloud-client.js"; +import type { Source } from "../../context/types.js"; + +/** + * cloud_doc_summary: reads the AI summary app-cloud generated for one cloud-drive + * document. Identity comes from the tool context only (ctx.uid) — the model can + * never ask for another user's document. A missing summary is a normal state + * (the file was ingested before summarization ran), so it is reported as text + * rather than raised as an error. + */ + +const log = createLogger("CLOUD"); + +/** Transport surface the tool needs; lets tests substitute a fake without the network. */ +export interface CloudDocSummaryClient { + readonly configured: boolean; + getSummary(uid: string, uploadUuid: string): Promise; +} + +function bulletList(items: string[], empty = "(无)"): string { + if (items.length === 0) return empty; + return items.map((item, i) => `${i + 1}. ${item}`).join("\n"); +} + +function inlineList(items: string[], empty = "(无)"): string { + return items.length === 0 ? empty : items.join("、"); +} + +/** Render one summary as the structured text handed back to the model. */ +export function formatCloudSummary(summary: CloudDocumentSummary, opts: { title?: string; stale?: boolean } = {}): string { + const header = opts.title ? `【云盘文档摘要】${opts.title}` : "【云盘文档摘要】"; + const parts: string[] = [header]; + if (opts.stale) { + parts.push("注意:文件内容已更新,当前摘要基于较早版本(可重新入库以刷新)。"); + } + parts.push( + `概览:\n${summary.overview || "(无)"}`, + `主题:${inlineList(summary.topics)}`, + `实体:${inlineList(summary.entities)}`, + `关键点:\n${bulletList(summary.key_points)}`, + `待办:\n${bulletList(summary.todos)}`, + `可回答问题:\n${bulletList(summary.answers_questions)}`, + ); + return parts.join("\n\n"); +} + +export { createCloudSemanticSearchTool, createCloudSimilarFilesTool } from "./search.js"; +export type { CloudSearchClient } from "./search.js"; + +export function createCloudDocSummaryTool(client: CloudDocSummaryClient) { + return defineTool({ + name: "cloud_doc_summary", + description: + "读取某个云盘文档已生成的 AI 摘要(概览/主题/实体/关键点/待办/可回答问题)。" + + "当用户询问云盘文档的内容、要点或待办,且已知文件 uploadUuid 时使用;" + + "摘要尚未生成时会返回提示,可告知用户重新入库该文件。", + inputSchema: z.object({ + uploadUuid: z.string().min(16).max(128).describe("云盘文件 UUID(16-128 字符),来自云盘文件列表/引用"), + }), + async execute(args, ctx): Promise { + if (!client.configured) { + log.warn("[CLOUD] cloud_doc_summary called while app-cloud is not configured"); + return { content: "云盘摘要服务未配置,暂时无法读取云盘文档摘要。请稍后重试或联系管理员。" }; + } + if (ctx.uid === undefined) { + return { content: "读取失败:当前会话没有用户身份,无法访问云盘文档。" }; + } + let resp: CloudSummaryResponse; + try { + resp = await client.getSummary(ctx.uid, args.uploadUuid); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`[CLOUD] summary read failed upload_uuid=${args.uploadUuid}: ${message}`); + return { content: `云盘摘要服务暂不可用:${message}。请稍后重试,或告知用户该功能暂不可用。` }; + } + const title = resp.title ?? resp.original_name; + const source: Source = { + uploadUuid: resp.upload_uuid || args.uploadUuid, + ...(title !== undefined ? { title } : {}), + }; + if (!resp.available || resp.summary === null) { + return { + content: "该云盘文档的摘要尚未生成(可重新入库该文件后再生成摘要)。", + sources: [source], + }; + } + return { + content: formatCloudSummary(resp.summary, { + ...(title !== undefined ? { title } : {}), + stale: resp.stale, + }), + sources: [source], + }; + }, + }); +} diff --git a/app-ts/src/tools/cloud/search.ts b/app-ts/src/tools/cloud/search.ts new file mode 100644 index 00000000..ca004d0d --- /dev/null +++ b/app-ts/src/tools/cloud/search.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; +import { defineTool, type ToolOutput } from "../base.js"; +import { createLogger } from "../../logger.js"; +import type { + CloudSemanticHit, + CloudSemanticSearchResponse, + CloudSimilarResponse, +} from "../../auth/app-cloud-client.js"; +import type { Source } from "../../context/types.js"; + +/** + * Cloud-drive retrieval tools for the main agent: + * - cloud_semantic_search: meaning-based file search across the user's drive + * (optionally inside one folder subtree); + * - cloud_similar_files: files whose content is close to one known file. + * + * Identity comes from the tool context only (ctx.uid). app-cloud stays the + * authority: these tools only read its existing search endpoints and never + * touch embeddings or Milvus directly. A degraded search service is reported + * as readable text so the turn continues with other tools. + */ + +const log = createLogger("CLOUD"); + +/** Transport surface the tools need; lets tests substitute a fake without the network. */ +export interface CloudSearchClient { + readonly configured: boolean; + searchSemantic( + uid: string, + query: string, + opts?: { folderId?: number; limit?: number }, + ): Promise; + getSimilar(uid: string, uploadUuid: string, limit?: number): Promise; +} + +function displayName(hit: CloudSemanticHit): string { + return hit.title || hit.original_name || "(无标题)"; +} + +function sourceOf(hit: CloudSemanticHit): Source { + const title = hit.title || hit.original_name; + return { + uploadUuid: hit.upload_uuid, + ...(title !== "" ? { title } : {}), + }; +} + +/** Cut by code points so surrogate pairs (emoji) never split. */ +function truncate(text: string, max: number): string { + const chars = Array.from(text); + return chars.length <= max ? text : `${chars.slice(0, max).join("")}…`; +} + +/** One hit per block: display name, score, uploadUuid and at most a few snippets. */ +function renderHit(hit: CloudSemanticHit, index: number, maxSnippets: number, snippetChars: number): string { + const lines = [ + `${index + 1}. ${displayName(hit)}(score=${hit.score.toFixed(4)})`, + ` uploadUuid:${hit.upload_uuid}`, + ]; + const snippets = hit.snippets + .slice(0, maxSnippets) + .map((snippet) => snippet.trim()) + .filter((snippet) => snippet !== "") + .map((snippet) => ` - ${truncate(snippet, snippetChars)}`); + if (snippets.length > 0) lines.push(" 相关片段:", ...snippets); + return lines.join("\n"); +} + +function renderHits(hits: CloudSemanticHit[], maxSnippets: number, snippetChars: number): string { + return hits.map((hit, i) => renderHit(hit, i, maxSnippets, snippetChars)).join("\n\n"); +} + +export function createCloudSemanticSearchTool(client: CloudSearchClient) { + return defineTool({ + name: "cloud_semantic_search", + description: + "在用户云盘内按语义精确检索文件:输入自然语言问题/关键词,返回文件级结果(文件名、相关度、匹配片段、uploadUuid)。" + + "用户问“云盘里哪份文档讲过 X”“找一下关于 X 的文件”时使用;" + + "通用知识库问答(视频/云盘混合)仍用 vector_search。", + inputSchema: z.object({ + query: z.string().min(1).max(500).describe("自然语言检索问题或关键词(1-500 字符)"), + folderId: z.number().int().positive().optional().describe("限定文件夹 ID(含其子文件夹);省略 = 全云盘"), + limit: z.number().int().min(1).max(20).optional().describe("返回文件数,默认 10,最多 20"), + }), + async execute(args, ctx): Promise { + if (!client.configured) { + log.warn("[CLOUD] cloud_semantic_search called while app-cloud is not configured"); + return { content: "云盘服务未配置,暂时无法进行云盘语义检索。请稍后重试或联系管理员。" }; + } + if (ctx.uid === undefined) { + return { content: "检索失败:当前会话没有用户身份,无法访问云盘文件。" }; + } + let resp: CloudSemanticSearchResponse; + try { + resp = await client.searchSemantic(ctx.uid, args.query, { + ...(args.folderId !== undefined ? { folderId: args.folderId } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`[CLOUD] semantic search failed: ${message}`); + return { + content: `云盘语义检索服务暂不可用:${message}。请稍后重试,或改用 vector_search 检索知识库。`, + }; + } + if (resp.results.length === 0) { + return { + content: `云盘中没有找到与「${args.query}」语义相关的文件。可以换个说法,或改用 vector_search 检索知识库。`, + sources: [], + }; + } + const totalNote = resp.total > resp.results.length ? `(共 ${resp.total} 个,展示前 ${resp.results.length} 个)` : ""; + return { + content: `【云盘语义检索】「${args.query}」命中 ${resp.results.length} 个文件${totalNote}\n\n${renderHits(resp.results, 3, 200)}`, + sources: resp.results.map(sourceOf), + }; + }, + }); +} + +export function createCloudSimilarFilesTool(client: CloudSearchClient) { + return defineTool({ + name: "cloud_similar_files", + description: + "查找与某个云盘文件内容相近的其他文件(相似文件列表,含文件名、相关度、匹配片段、uploadUuid)。" + + "已知文件 uploadUuid、用户想找同主题的其他资料时使用;服务端已排除文件自身。", + inputSchema: z.object({ + uploadUuid: z.string().min(16).max(128).describe("云盘文件 UUID(16-128 字符)"), + limit: z.number().int().min(1).max(10).optional().describe("返回文件数,默认 5,最多 10"), + }), + async execute(args, ctx): Promise { + if (!client.configured) { + log.warn("[CLOUD] cloud_similar_files called while app-cloud is not configured"); + return { content: "云盘服务未配置,暂时无法查找相似文件。请稍后重试或联系管理员。" }; + } + if (ctx.uid === undefined) { + return { content: "查找失败:当前会话没有用户身份,无法访问云盘文件。" }; + } + let resp: CloudSimilarResponse; + try { + resp = await client.getSimilar(ctx.uid, args.uploadUuid, args.limit); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`[CLOUD] similar files failed upload_uuid=${args.uploadUuid}: ${message}`); + return { content: `云盘相似文件服务暂不可用:${message}。请稍后重试。` }; + } + if (resp.items.length === 0) { + return { + content: "没有找到与该云盘文件内容相近的其他文件。", + sources: [], + }; + } + return { + content: `【相似云盘文件】找到 ${resp.items.length} 个内容相近的文件\n\n${renderHits(resp.items, 3, 200)}`, + sources: resp.items.map(sourceOf), + }; + }, + }); +} diff --git a/app-ts/src/transport/http/routes/blindspot.ts b/app-ts/src/transport/http/routes/blindspot.ts index 95a24aea..139f3bb6 100644 --- a/app-ts/src/transport/http/routes/blindspot.ts +++ b/app-ts/src/transport/http/routes/blindspot.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { getRequestContext } from "../../../context/request-context.js"; import { BlindspotError, type BlindspotService } from "../../../services/blindspot.js"; import { QuizGenerationError } from "../../../services/quiz-generation.js"; -import { AppQuizError } from "../../../services/app-quiz-client.js"; +import { AppQuizError } from "../../../auth/app-quiz-client.js"; /** * /blindspot/* — knowledge blind-spot map (contract mirror of the Python diff --git a/app-ts/src/transport/http/routes/knowledge.ts b/app-ts/src/transport/http/routes/knowledge.ts index f9f41492..5c7187c0 100644 --- a/app-ts/src/transport/http/routes/knowledge.ts +++ b/app-ts/src/transport/http/routes/knowledge.ts @@ -1,7 +1,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { RAGService } from "../../../rag/service.js"; import type { VideoVectorizerService } from "../../../services/video-vectorizer.js"; -import type { AppBiliClient } from "../../../services/app-bili-client.js"; +import type { AppBiliClient } from "../../../auth/app-bili-client.js"; import type { KgService } from "../../../kg/service.js"; import { getRequestContext } from "../../../context/request-context.js"; import { createLogger } from "../../../logger.js"; diff --git a/app-ts/src/transport/http/routes/quiz.ts b/app-ts/src/transport/http/routes/quiz.ts index 3eb2e8b8..3446753e 100644 --- a/app-ts/src/transport/http/routes/quiz.ts +++ b/app-ts/src/transport/http/routes/quiz.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { getRequestContext } from "../../../context/request-context.js"; -import { AppQuizError } from "../../../services/app-quiz-client.js"; +import { AppQuizError } from "../../../auth/app-quiz-client.js"; import { QuizGenerationError, QUIZ_STYLES, diff --git a/app-ts/src/transport/http/routes/task-quiz.ts b/app-ts/src/transport/http/routes/task-quiz.ts index 0364b5be..d5abf971 100644 --- a/app-ts/src/transport/http/routes/task-quiz.ts +++ b/app-ts/src/transport/http/routes/task-quiz.ts @@ -4,9 +4,9 @@ import { config } from "../../../config/index.js"; import { resolveTier } from "../../../utils/membership.js"; import type { QuotaService } from "../../../services/quota.js"; import { createLogger } from "../../../logger.js"; -import { AppTaskError } from "../../../services/app-task-client.js"; -import { AppAuthError } from "../../../services/app-auth-client.js"; -import { AppQuizError } from "../../../services/app-quiz-client.js"; +import { AppTaskError } from "../../../auth/app-task-client.js"; +import { AppAuthError } from "../../../auth/app-auth-client.js"; +import { AppQuizError } from "../../../auth/app-quiz-client.js"; import { TaskQuizGenerationService, type TaskQuizGenerateInput, @@ -49,8 +49,8 @@ export function registerTaskQuizRoutes( app: FastifyInstance, deps: { generation: TaskQuizGenerationService; - appTask: import("../../../services/app-task-client.js").AppTaskClient; - appAuth: import("../../../services/app-auth-client.js").AppAuthClient; + appTask: import("../../../auth/app-task-client.js").AppTaskClient; + appAuth: import("../../../auth/app-auth-client.js").AppAuthClient; /** 会员用量配额(定时出题活跃任务数上限);absent = 不限 */ quota?: QuotaService; /** test-only model override for the chat agent */ diff --git a/app-ts/src/transport/http/server.ts b/app-ts/src/transport/http/server.ts index ec578ce6..9ffc7175 100644 --- a/app-ts/src/transport/http/server.ts +++ b/app-ts/src/transport/http/server.ts @@ -36,10 +36,10 @@ import type { SkillManager, SkillStoreClient } from "../../skills/manager.js"; import type { BlindspotService } from "../../services/blindspot.js"; import { registerKnowledgeRoutes } from "././routes/knowledge.js"; import type { VideoVectorizerService } from "../../services/video-vectorizer.js"; -import type { AppBiliClient } from "../../services/app-bili-client.js"; +import type { AppBiliClient } from "../../auth/app-bili-client.js"; import type { TaskQuizGenerationService } from "../../services/task-quiz-generation.js"; -import type { AppTaskClient } from "../../services/app-task-client.js"; -import type { AppAuthClient } from "../../services/app-auth-client.js"; +import type { AppTaskClient } from "../../auth/app-task-client.js"; +import type { AppAuthClient } from "../../auth/app-auth-client.js"; import type { KgService } from "../../kg/service.js"; import type { BoardCompletionService } from "../../services/board-completion.js"; import type { SessionSummaryService } from "../../services/session-summary.js"; diff --git a/app-ts/test/academic-routes.test.ts b/app-ts/test/academic-routes.test.ts index 93f810fc..739f398a 100644 --- a/app-ts/test/academic-routes.test.ts +++ b/app-ts/test/academic-routes.test.ts @@ -4,7 +4,7 @@ import { runWithRequestContext } from "../src/context/request-context.js"; import { registerAcademicRoutes } from "../src/transport/http/routes/academic.js"; import { MemoryAcademicStore } from "../src/repository/memory/academic.js"; import { AcademicService } from "../src/services/academic.js"; -import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../src/services/literature-client.js"; +import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../src/auth/literature-client.js"; /** * Route-level tests for /academic/* (plan/1.0.17 Phase 2): uid comes from the diff --git a/app-ts/test/academic-service.test.ts b/app-ts/test/academic-service.test.ts index f8d191fb..607f172f 100644 --- a/app-ts/test/academic-service.test.ts +++ b/app-ts/test/academic-service.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { MemoryAcademicStore } from "../src/repository/memory/academic.js"; import { AcademicService } from "../src/services/academic.js"; -import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../src/services/literature-client.js"; +import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../src/auth/literature-client.js"; /** * Service-level tests for the /academic/* REST surface (plan/1.0.17 Phase 2): diff --git a/app-ts/test/academic-tools.test.ts b/app-ts/test/academic-tools.test.ts index c98c6c15..a4571bde 100644 --- a/app-ts/test/academic-tools.test.ts +++ b/app-ts/test/academic-tools.test.ts @@ -26,7 +26,7 @@ import { type AcademicSectionInput, type AcademicStage, } from "../src/repository/academic.js"; -import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../src/services/literature-client.js"; +import type { LiteratureClient, LiteratureHit, LiteratureSearchOptions } from "../src/auth/literature-client.js"; import { createAcademicProjectTool } from "../src/tools/academic/project.js"; import { createAcademicLiteratureTool } from "../src/tools/academic/literature.js"; import { createAcademicManuscriptTool } from "../src/tools/academic/manuscript.js"; diff --git a/app-ts/test/cloud-doc-summary.test.ts b/app-ts/test/cloud-doc-summary.test.ts new file mode 100644 index 00000000..22e78209 --- /dev/null +++ b/app-ts/test/cloud-doc-summary.test.ts @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppCloudClient, AppCloudError, type CloudSummaryResponse } from "../src/auth/app-cloud-client.js"; +import { createCloudDocSummaryTool, formatCloudSummary } from "../src/tools/cloud/index.js"; +import { ResultCollector } from "../src/context/types.js"; +import type { ToolRunContext } from "../src/context/types.js"; + +/** + * Offline tests: the app-cloud HTTP face is stubbed, and the tool is exercised + * with a fake client so formatting/identity rules are verified without a + * running Go service. + */ + +const UUID = "1f2e3d4c-5b6a-4789-8abc-def012345678"; // 36 chars, inside 16-128 +const BASE = "http://cloud.test:8007/"; + +function stubFetch(status: number, body: unknown) { + const fn = vi.fn(async () => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fn); + return fn; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeCtx(uid: string | undefined): ToolRunContext { + return { uid, scope: {}, collector: new ResultCollector() }; +} + +function summaryBody(overrides: Record = {}) { + return { + upload_uuid: UUID, + available: true, + stale: false, + model: "z-ai/glm-5.3-flash", + summary: { + overview: "介绍云盘上传与摘要闭环。", + topics: ["云盘", "摘要"], + entities: ["app-cloud", "Milvus"], + key_points: ["上传后异步生成摘要", "摘要写入 Mongo"], + todos: ["补充 OCR 用例", "回归语义检索"], + answers_questions: ["摘要怎么生成?", "支持哪些文件类型?"], + language: "zh", + size_chars: 1234, + }, + ...overrides, + }; +} + +const fullResponse: CloudSummaryResponse = { + upload_uuid: UUID, + available: true, + stale: false, + model: "z-ai/glm-5.3-flash", + title: "云盘架构说明.pdf", + summary: { + overview: "介绍云盘上传与摘要闭环。", + topics: ["云盘", "摘要"], + entities: ["app-cloud", "Milvus"], + key_points: ["上传后异步生成摘要", "摘要写入 Mongo"], + todos: ["补充 OCR 用例", "回归语义检索"], + answers_questions: ["摘要怎么生成?", "支持哪些文件类型?"], + language: "zh", + size_chars: 1234, + }, +}; + +/** Fake client: records calls, returns a fixed response or throws a fixed error. */ +function fakeClient( + result: CloudSummaryResponse | Error, + opts: { configured?: boolean } = {}, +): { + configured: boolean; + calls: Array<{ uid: string; uploadUuid: string }>; + getSummary(uid: string, uploadUuid: string): Promise; +} { + const calls: Array<{ uid: string; uploadUuid: string }> = []; + return { + configured: opts.configured ?? true, + calls, + async getSummary(uid: string, uploadUuid: string) { + calls.push({ uid, uploadUuid }); + if (result instanceof Error) throw result; + return result; + }, + }; +} + +// --------------------------------------------------------------------------- +// Client transport +// --------------------------------------------------------------------------- + +describe("AppCloudClient.getSummary", () => { + it("calls the internal summary face with apikey + X-Uid + query identity and normalizes the payload", async () => { + const fetchMock = stubFetch(200, summaryBody({ summary: { ...summaryBody().summary, todos: undefined } })); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const out = await client.getSummary("42", UUID); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`http://cloud.test:8007/internal/cloud/summary?uid=42&upload_uuid=${UUID}`); + const headers = new Headers(init.headers); + expect(headers.get("apikey")).toBe("internal-key"); + expect(headers.get("X-Uid")).toBe("42"); + + expect(out.upload_uuid).toBe(UUID); + expect(out.available).toBe(true); + expect(out.summary?.todos).toEqual([]); // legacy summary documents have no todos + expect(out.title).toBeUndefined(); + }); + + it("does not cache: every call hits the service", async () => { + const fetchMock = stubFetch(200, summaryBody()); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + await client.getSummary("42", UUID); + await client.getSummary("42", UUID); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps a service error to AppCloudError with status and detail", async () => { + stubFetch(503, { detail: "摘要服务未配置" }); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const err = await client.getSummary("42", UUID).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppCloudError); + expect((err as AppCloudError).status).toBe(503); + expect((err as AppCloudError).message).toContain("摘要服务未配置"); + }); + + it("wraps network failures in AppCloudError", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED"))); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const err = await client.getSummary("42", UUID).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppCloudError); + expect((err as AppCloudError).message).toContain("ECONNREFUSED"); + }); + + it("reports itself unconfigured unless both baseUrl and apiKey are present", () => { + expect(new AppCloudClient({ baseUrl: "", apiKey: "k", timeoutMs: 800 }).configured).toBe(false); + expect(new AppCloudClient({ baseUrl: BASE, apiKey: "", timeoutMs: 800 }).configured).toBe(false); + expect(new AppCloudClient({ baseUrl: BASE, apiKey: "k", timeoutMs: 800 }).configured).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tool +// --------------------------------------------------------------------------- + +describe("cloud_doc_summary tool", () => { + it("formats every summary section and emits the document as a source", async () => { + const client = fakeClient(fullResponse); + const tool = createCloudDocSummaryTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + const content = typeof out === "string" ? out : out.content; + + expect(content).toContain("【云盘文档摘要】云盘架构说明.pdf"); + expect(content).toContain("介绍云盘上传与摘要闭环。"); + expect(content).toContain("主题:云盘、摘要"); + expect(content).toContain("实体:app-cloud、Milvus"); + expect(content).toContain("1. 上传后异步生成摘要"); + expect(content).toContain("1. 补充 OCR 用例"); + expect(content).toContain("1. 摘要怎么生成?"); + expect(content).not.toContain("较早版本"); + + const sources = typeof out === "string" ? undefined : out.sources; + expect(sources).toEqual([{ uploadUuid: UUID, title: "云盘架构说明.pdf" }]); + expect(client.calls).toEqual([{ uid: "42", uploadUuid: UUID }]); + }); + + it("marks a stale summary as generated from an older revision", async () => { + const client = fakeClient({ ...fullResponse, stale: true }); + const tool = createCloudDocSummaryTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + const content = typeof out === "string" ? out : out.content; + expect(content).toContain("较早版本"); + expect(content).toContain("重新入库"); + }); + + it("returns a plain notice when no summary has been generated yet", async () => { + const client = fakeClient({ + upload_uuid: UUID, + available: false, + stale: false, + model: "", + summary: null, + original_name: "旧文档.docx", + }); + const tool = createCloudDocSummaryTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + const content = typeof out === "string" ? out : out.content; + expect(content).toContain("摘要尚未生成"); + expect(content).not.toContain("工具执行失败"); + const sources = typeof out === "string" ? undefined : out.sources; + expect(sources).toEqual([{ uploadUuid: UUID, title: "旧文档.docx" }]); + }); + + it("turns a service failure into a readable message instead of throwing", async () => { + const client = fakeClient(new AppCloudError("云盘摘要服务不可用", 503)); + const tool = createCloudDocSummaryTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + const content = typeof out === "string" ? out : out.content; + expect(content).toContain("暂不可用"); + expect(content).toContain("云盘摘要服务不可用"); + }); + + it("answers with a not-configured notice and skips the call when app-cloud is unconfigured", async () => { + const client = fakeClient(fullResponse, { configured: false }); + const tool = createCloudDocSummaryTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + const content = typeof out === "string" ? out : out.content; + expect(content).toContain("未配置"); + expect(client.calls).toEqual([]); + }); + + it("refuses to read without a user identity (uid is never model-supplied)", async () => { + const client = fakeClient(fullResponse); + const tool = createCloudDocSummaryTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx(undefined)); + const content = typeof out === "string" ? out : out.content; + expect(content).toContain("没有用户身份"); + expect(client.calls).toEqual([]); + }); + + it("validates uploadUuid length (16-128) and accepts no uid field", () => { + const tool = createCloudDocSummaryTool(fakeClient(fullResponse)); + const schema = tool.inputSchema as { safeParse(value: unknown): { success: boolean; data?: Record } }; + + expect(schema.safeParse({ uploadUuid: "a".repeat(15) }).success).toBe(false); + expect(schema.safeParse({ uploadUuid: "a".repeat(16) }).success).toBe(true); + expect(schema.safeParse({ uploadUuid: "a".repeat(128) }).success).toBe(true); + expect(schema.safeParse({ uploadUuid: "a".repeat(129) }).success).toBe(false); + expect(schema.safeParse({ uploadUuid: 42 }).success).toBe(false); + expect(schema.safeParse({}).success).toBe(false); + + const parsed = schema.safeParse({ uploadUuid: UUID, uid: "999" }); + expect(parsed.success).toBe(true); + expect(parsed.data).not.toHaveProperty("uid"); + }); + + it("formats an empty (but generated) summary with explicit placeholders", () => { + const text = formatCloudSummary({ + overview: "", + topics: [], + entities: [], + key_points: [], + todos: [], + answers_questions: [], + language: "", + size_chars: 0, + }); + expect(text).toContain("概览:\n(无)"); + expect(text).toContain("主题:(无)"); + expect(text).toContain("关键点:\n(无)"); + }); +}); diff --git a/app-ts/test/cloud-semantic-tools.test.ts b/app-ts/test/cloud-semantic-tools.test.ts new file mode 100644 index 00000000..cb5172fa --- /dev/null +++ b/app-ts/test/cloud-semantic-tools.test.ts @@ -0,0 +1,390 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AppCloudClient, + AppCloudError, + type CloudSemanticHit, + type CloudSemanticSearchResponse, + type CloudSimilarResponse, +} from "../src/auth/app-cloud-client.js"; +import { + createCloudSemanticSearchTool, + createCloudSimilarFilesTool, +} from "../src/tools/cloud/index.js"; +import { ResultCollector } from "../src/context/types.js"; +import type { ToolRunContext } from "../src/context/types.js"; + +/** + * Offline tests for the cloud-drive retrieval tools: the app-cloud transport is + * stubbed and the tools run against a fake client, so URL/header shape, + * formatting, identity rules and degradation paths are covered without a Go + * service. + */ + +const UUID = "1f2e3d4c-5b6a-4789-8abc-def012345678"; +const UUID2 = "2a3b4c5d-6e7f-4809-9abc-def012345679"; +const BASE = "http://cloud.test:8007/"; + +function stubFetch(status: number, body: unknown) { + const fn = vi.fn(async () => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fn); + return fn; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function makeCtx(uid: string | undefined): ToolRunContext { + return { uid, scope: {}, collector: new ResultCollector() }; +} + +function hitBody(overrides: Partial = {}): CloudSemanticHit { + return { + upload_uuid: UUID, + original_name: "云盘架构说明.pdf", + title: "云盘架构", + mime_type: "application/pdf", + file_size: 2048, + score: 0.87, + snippets: ["上传后异步生成摘要", "向量写入 Milvus"], + ...overrides, + }; +} + +const searchBody: CloudSemanticSearchResponse = { + mode: "semantic", + total: 2, + results: [ + hitBody({ snippets: ["片段一", "片段二", "片段三", "片段四", "片段五"] }), + hitBody({ + upload_uuid: UUID2, + original_name: "会议纪要.md", + title: "", + mime_type: "text/markdown", + score: 0.42, + snippets: [], + }), + ], +}; + +type SemanticCall = { uid: string; query: string; opts?: { folderId?: number; limit?: number } }; +type SimilarCall = { uid: string; uploadUuid: string; limit?: number }; + +/** Fake client: records calls, returns fixed payloads or throws fixed errors. */ +function fakeSearchClient( + semantic: CloudSemanticSearchResponse | Error, + similar: CloudSimilarResponse | Error = { items: [] }, + opts: { configured?: boolean } = {}, +) { + const semanticCalls: SemanticCall[] = []; + const similarCalls: SimilarCall[] = []; + return { + configured: opts.configured ?? true, + semanticCalls, + similarCalls, + async searchSemantic(uid: string, query: string, callOpts?: { folderId?: number; limit?: number }) { + semanticCalls.push({ uid, query, opts: callOpts }); + if (semantic instanceof Error) throw semantic; + return semantic; + }, + async getSimilar(uid: string, uploadUuid: string, limit?: number) { + similarCalls.push({ uid, uploadUuid, limit }); + if (similar instanceof Error) throw similar; + return similar; + }, + }; +} + +function toolText(out: { content: string } | string): string { + return typeof out === "string" ? out : out.content; +} + +function toolSources(out: { sources?: Array<{ uploadUuid?: string; title?: string }> } | string) { + return typeof out === "string" ? undefined : out.sources; +} + +// --------------------------------------------------------------------------- +// Client transport +// --------------------------------------------------------------------------- + +describe("AppCloudClient.searchSemantic", () => { + it("calls /cloud/search/semantic with encoded query + folder/limit and the identity header", async () => { + const fetchMock = stubFetch(200, searchBody); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const out = await client.searchSemantic("42", "云盘 架构", { folderId: 7, limit: 5 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/cloud/search/semantic"); + expect(parsed.searchParams.get("q")).toBe("云盘 架构"); + expect(parsed.searchParams.get("folderId")).toBe("7"); + expect(parsed.searchParams.get("limit")).toBe("5"); + const headers = new Headers(init.headers); + expect(headers.get("X-Uid")).toBe("42"); + expect(headers.get("apikey")).toBe("internal-key"); + + expect(out.mode).toBe("semantic"); + expect(out.total).toBe(2); + expect(out.results).toHaveLength(2); + expect(out.results[0]).toMatchObject({ + upload_uuid: UUID, + original_name: "云盘架构说明.pdf", + title: "云盘架构", + mime_type: "application/pdf", + file_size: 2048, + score: 0.87, + }); + expect(out.results[0]?.snippets).toEqual(["片段一", "片段二", "片段三", "片段四", "片段五"]); + expect(out.results[1]?.title).toBe(""); + }); + + it("omits optional query parameters and defaults total to the result count", async () => { + const fetchMock = stubFetch(200, { mode: "semantic", results: [hitBody()] }); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const out = await client.searchSemantic("42", "milvus"); + + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.searchParams.has("folderId")).toBe(false); + expect(parsed.searchParams.has("limit")).toBe(false); + expect(out.total).toBe(1); + expect(out.mode).toBe("semantic"); + }); + + it("drops malformed result entries instead of failing the whole response", async () => { + stubFetch(200, { mode: "semantic", total: 2, results: [hitBody(), { upload_uuid: "" }, null] }); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const out = await client.searchSemantic("42", "milvus"); + expect(out.results).toHaveLength(1); + }); + + it("maps service failures to AppCloudError with status and detail", async () => { + stubFetch(503, { detail: "语义检索暂不可用" }); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const err = await client.searchSemantic("42", "milvus").catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppCloudError); + expect((err as AppCloudError).status).toBe(503); + expect((err as AppCloudError).message).toContain("语义检索暂不可用"); + }); + + it("wraps network failures in AppCloudError", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED"))); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const err = await client.searchSemantic("42", "milvus").catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppCloudError); + expect((err as AppCloudError).message).toContain("ECONNREFUSED"); + }); +}); + +describe("AppCloudClient.getSimilar", () => { + it("calls /internal/cloud/similar with apikey + X-Uid + query identity and parses items", async () => { + const fetchMock = stubFetch(200, { items: [hitBody({ score: 0.77 })] }); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + const out = await client.getSimilar("42", UUID, 3); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/internal/cloud/similar"); + expect(parsed.searchParams.get("uid")).toBe("42"); + expect(parsed.searchParams.get("upload_uuid")).toBe(UUID); + expect(parsed.searchParams.get("limit")).toBe("3"); + const headers = new Headers(init.headers); + expect(headers.get("apikey")).toBe("internal-key"); + expect(headers.get("X-Uid")).toBe("42"); + + expect(out.items).toHaveLength(1); + expect(out.items[0]?.score).toBe(0.77); + }); + + it("leaves the limit to the service when omitted and maps a 404 body", async () => { + const fetchMock = stubFetch(200, { items: [] }); + const client = new AppCloudClient({ baseUrl: BASE, apiKey: "internal-key", timeoutMs: 800 }); + await client.getSimilar("42", UUID); + const [url] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(new URL(url).searchParams.has("limit")).toBe(false); + + stubFetch(404, { detail: "File not found" }); + const err = await client.getSimilar("42", UUID).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppCloudError); + expect((err as AppCloudError).status).toBe(404); + expect((err as AppCloudError).message).toContain("File not found"); + }); +}); + +// --------------------------------------------------------------------------- +// cloud_semantic_search +// --------------------------------------------------------------------------- + +describe("cloud_semantic_search tool", () => { + it("formats file-level results (name, score, up to 3 snippets, uploadUuid) and emits sources", async () => { + const client = fakeSearchClient(searchBody); + const tool = createCloudSemanticSearchTool(client); + const out = await tool.execute({ query: "云盘架构" }, makeCtx("42")); + const content = toolText(out); + + expect(content).toContain("【云盘语义检索】"); + expect(content).toContain("云盘架构"); + expect(content).toContain("1. 云盘架构(score=0.8700)"); + expect(content).toContain(`uploadUuid:${UUID}`); + expect(content).toContain("片段一"); + expect(content).toContain("片段三"); + expect(content).not.toContain("片段四"); // only the first 3 snippets reach the model + expect(content).toContain("2. 会议纪要.md(score=0.4200)"); + + expect(toolSources(out)).toEqual([ + { uploadUuid: UUID, title: "云盘架构" }, + { uploadUuid: UUID2, title: "会议纪要.md" }, // title falls back to the file name + ]); + expect(client.semanticCalls).toEqual([{ uid: "42", query: "云盘架构", opts: {} }]); + }); + + it("reports the service-side total when more files matched than were returned", async () => { + const client = fakeSearchClient({ mode: "semantic", total: 7, results: searchBody.results }); + const tool = createCloudSemanticSearchTool(client); + const out = await tool.execute({ query: "架构" }, makeCtx("42")); + expect(toolText(out)).toContain("共 7 个,展示前 2 个"); + }); + + it("forwards the folder scope and limit from validated arguments", async () => { + const client = fakeSearchClient(searchBody); + const tool = createCloudSemanticSearchTool(client); + await tool.execute({ query: "架构", folderId: 7, limit: 5 }, makeCtx("42")); + expect(client.semanticCalls).toEqual([{ uid: "42", query: "架构", opts: { folderId: 7, limit: 5 } }]); + }); + + it("answers with a clear notice when nothing matches", async () => { + const client = fakeSearchClient({ mode: "semantic", results: [], total: 0 }); + const tool = createCloudSemanticSearchTool(client); + const out = await tool.execute({ query: "不存在的内容" }, makeCtx("42")); + const content = toolText(out); + expect(content).toContain("没有找到"); + expect(content).toContain("vector_search"); + expect(toolSources(out)).toEqual([]); + }); + + it("turns a degraded search service into readable text instead of throwing", async () => { + const client = fakeSearchClient(new AppCloudError("语义检索暂不可用", 503)); + const tool = createCloudSemanticSearchTool(client); + const out = await tool.execute({ query: "架构" }, makeCtx("42")); + expect(toolText(out)).toContain("暂不可用"); + expect(toolText(out)).toContain("语义检索暂不可用"); + }); + + it("skips the call when app-cloud is unconfigured", async () => { + const client = fakeSearchClient(searchBody, { items: [] }, { configured: false }); + const tool = createCloudSemanticSearchTool(client); + const out = await tool.execute({ query: "架构" }, makeCtx("42")); + expect(toolText(out)).toContain("未配置"); + expect(client.semanticCalls).toEqual([]); + }); + + it("refuses to search without a user identity", async () => { + const client = fakeSearchClient(searchBody); + const tool = createCloudSemanticSearchTool(client); + const out = await tool.execute({ query: "架构" }, makeCtx(undefined)); + expect(toolText(out)).toContain("没有用户身份"); + expect(client.semanticCalls).toEqual([]); + }); + + it("validates query/folderId/limit and never accepts a uid argument", () => { + const tool = createCloudSemanticSearchTool(fakeSearchClient(searchBody)); + const schema = tool.inputSchema as { safeParse(value: unknown): { success: boolean; data?: Record } }; + + expect(schema.safeParse({ query: "" }).success).toBe(false); + expect(schema.safeParse({ query: "a".repeat(500) }).success).toBe(true); + expect(schema.safeParse({ query: "a".repeat(501) }).success).toBe(false); + expect(schema.safeParse({ query: "a", folderId: 0 }).success).toBe(false); + expect(schema.safeParse({ query: "a", folderId: -1 }).success).toBe(false); + expect(schema.safeParse({ query: "a", folderId: 1.5 }).success).toBe(false); + expect(schema.safeParse({ query: "a", folderId: 1 }).success).toBe(true); + expect(schema.safeParse({ query: "a", limit: 0 }).success).toBe(false); + expect(schema.safeParse({ query: "a", limit: 21 }).success).toBe(false); + expect(schema.safeParse({ query: "a", limit: 1 }).success).toBe(true); + expect(schema.safeParse({ query: "a", limit: 20 }).success).toBe(true); + + const parsed = schema.safeParse({ query: "架构", uid: "999" }); + expect(parsed.success).toBe(true); + expect(parsed.data).not.toHaveProperty("uid"); + }); +}); + +// --------------------------------------------------------------------------- +// cloud_similar_files +// --------------------------------------------------------------------------- + +describe("cloud_similar_files tool", () => { + it("lists similar files with score/snippets/uploadUuid and emits sources", async () => { + const client = fakeSearchClient(searchBody, { + items: [ + hitBody({ upload_uuid: UUID2, original_name: "同类资料.pdf", title: "", score: 0.66, snippets: ["相近内容"] }), + ], + }); + const tool = createCloudSimilarFilesTool(client); + const out = await tool.execute({ uploadUuid: UUID, limit: 3 }, makeCtx("42")); + const content = toolText(out); + + expect(content).toContain("【相似云盘文件】"); + expect(content).toContain("1. 同类资料.pdf(score=0.6600)"); + expect(content).toContain(`uploadUuid:${UUID2}`); + expect(content).toContain("相近内容"); + expect(toolSources(out)).toEqual([{ uploadUuid: UUID2, title: "同类资料.pdf" }]); + expect(client.similarCalls).toEqual([{ uid: "42", uploadUuid: UUID, limit: 3 }]); + }); + + it("answers with a clear notice when no similar file exists", async () => { + const client = fakeSearchClient(searchBody, { items: [] }); + const tool = createCloudSimilarFilesTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + expect(toolText(out)).toContain("没有找到"); + expect(toolSources(out)).toEqual([]); + }); + + it("turns a degraded service into readable text and never leaks uid into arguments", async () => { + const client = fakeSearchClient(searchBody, new AppCloudError("语义检索暂不可用", 503)); + const tool = createCloudSimilarFilesTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + expect(toolText(out)).toContain("暂不可用"); + expect(client.similarCalls).toEqual([{ uid: "42", uploadUuid: UUID, limit: undefined }]); + }); + + it("skips the call when app-cloud is unconfigured", async () => { + const client = fakeSearchClient(searchBody, { items: [] }, { configured: false }); + const tool = createCloudSimilarFilesTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx("42")); + expect(toolText(out)).toContain("未配置"); + expect(client.similarCalls).toEqual([]); + }); + + it("refuses to look up similar files without a user identity", async () => { + const client = fakeSearchClient(searchBody, { items: [] }); + const tool = createCloudSimilarFilesTool(client); + const out = await tool.execute({ uploadUuid: UUID }, makeCtx(undefined)); + expect(toolText(out)).toContain("没有用户身份"); + expect(client.similarCalls).toEqual([]); + }); + + it("validates uploadUuid/limit and never accepts a uid argument", () => { + const tool = createCloudSimilarFilesTool(fakeSearchClient(searchBody, { items: [] })); + const schema = tool.inputSchema as { safeParse(value: unknown): { success: boolean; data?: Record } }; + + expect(schema.safeParse({ uploadUuid: "a".repeat(15) }).success).toBe(false); + expect(schema.safeParse({ uploadUuid: "a".repeat(16) }).success).toBe(true); + expect(schema.safeParse({ uploadUuid: "a".repeat(128) }).success).toBe(true); + expect(schema.safeParse({ uploadUuid: "a".repeat(129) }).success).toBe(false); + expect(schema.safeParse({ uploadUuid: UUID, limit: 0 }).success).toBe(false); + expect(schema.safeParse({ uploadUuid: UUID, limit: 11 }).success).toBe(false); + expect(schema.safeParse({ uploadUuid: UUID, limit: 1 }).success).toBe(true); + expect(schema.safeParse({ uploadUuid: UUID, limit: 10 }).success).toBe(true); + + const parsed = schema.safeParse({ uploadUuid: UUID, uid: "999" }); + expect(parsed.success).toBe(true); + expect(parsed.data).not.toHaveProperty("uid"); + }); +}); diff --git a/app-ts/test/literature-client.test.ts b/app-ts/test/literature-client.test.ts index 248be568..80770756 100644 --- a/app-ts/test/literature-client.test.ts +++ b/app-ts/test/literature-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { createLiteratureClient } from "../src/services/literature-client.js"; +import { createLiteratureClient } from "../src/auth/literature-client.js"; import { config } from "../src/config/index.js"; type Call = { url: string; init?: RequestInit }; diff --git a/app-ts/test/quiz-generation.test.ts b/app-ts/test/quiz-generation.test.ts index 1ea91fb0..5e53147f 100644 --- a/app-ts/test/quiz-generation.test.ts +++ b/app-ts/test/quiz-generation.test.ts @@ -17,7 +17,7 @@ import { type QuizQuestionDict, } from "../src/services/quiz-generation.js"; import type { SessionSummaryService } from "../src/services/session-summary.js"; -import type { AppQuizClient } from "../src/services/app-quiz-client.js"; +import type { AppQuizClient } from "../src/auth/app-quiz-client.js"; import { PromptRegistry } from "../src/agent/prompts/loader.js"; /** diff --git a/app-ts/test/task-quiz.test.ts b/app-ts/test/task-quiz.test.ts index 2f08e90e..2544a8cf 100644 --- a/app-ts/test/task-quiz.test.ts +++ b/app-ts/test/task-quiz.test.ts @@ -13,9 +13,9 @@ import type { SessionDocumentRepository } from "../src/repository/session-docs.j import { MemoryTaskQuizDocStore } from "../src/repository/memory/task-quiz-docs.js"; import { TaskQuizGenerationService } from "../src/services/task-quiz-generation.js"; import { PromptRegistry } from "../src/agent/prompts/loader.js"; -import type { AppQuizClient } from "../src/services/app-quiz-client.js"; -import type { AppTaskClient } from "../src/services/app-task-client.js"; -import type { AppAuthClient } from "../src/services/app-auth-client.js"; +import type { AppQuizClient } from "../src/auth/app-quiz-client.js"; +import type { AppTaskClient } from "../src/auth/app-task-client.js"; +import type { AppAuthClient } from "../src/auth/app-auth-client.js"; // ---------- fakes ---------- diff --git a/app-ts/test/video-vectorizer.test.ts b/app-ts/test/video-vectorizer.test.ts index 4d764938..c27093af 100644 --- a/app-ts/test/video-vectorizer.test.ts +++ b/app-ts/test/video-vectorizer.test.ts @@ -9,7 +9,7 @@ import type { RAGService } from "../src/rag/service.js"; import type { ChatService } from "../src/services/chat.js"; import type { AgentHarness } from "../src/harness/app.js"; import type { SessionDocumentRepository } from "../src/repository/session-docs.js"; -import type { AppBiliClient, ScopeFolder } from "../src/services/app-bili-client.js"; +import type { AppBiliClient, ScopeFolder } from "../src/auth/app-bili-client.js"; // ---------- service ---------- diff --git a/deploy/docker/34-app-cloud.yml b/deploy/docker/34-app-cloud.yml index 318da14c..cdb9d84d 100644 --- a/deploy/docker/34-app-cloud.yml +++ b/deploy/docker/34-app-cloud.yml @@ -18,6 +18,8 @@ services: - MINIO__PUBLIC_ENDPOINT=${MINIO__PUBLIC_ENDPOINT:-} - MINIO__PUBLIC_HOST=${MINIO_PUBLIC_HOST:-} - APISIX_CONSUMER_KEY=${APISIX_CONSUMER_KEY:?err_APISIX_CONSUMER_KEY_not_set_in_.env} + # app-ts cloud_doc_summary 的 M2M 共享 key(复用 APISIX consumer key) + - APPCLOUD__INTERNAL__API_KEY=${APISIX_CONSUMER_KEY:-} - AI_GATEWAY__API_KEY=${AI_GATEWAY__API_KEY:-} - AI_GATEWAY__BASE_URL=${AI_GATEWAY__BASE_URL:-} - MILVUS__URI=${MILVUS__URI:-http://milvus:19530} diff --git a/deploy/docker/43-app-ts.yml b/deploy/docker/43-app-ts.yml index 2f71cf8a..90cd4947 100644 --- a/deploy/docker/43-app-ts.yml +++ b/deploy/docker/43-app-ts.yml @@ -16,6 +16,8 @@ services: - REDIS__URL=${REDIS__URL:-redis://:mind-base@redis:6379/1} # acquisition 服务(app-bili):ASR 委托 + 知识范围读 - APP_BILI_BASE_URL=http://app-bili:8010 + # cloud-drive summary M2M (app-cloud /internal/cloud/*) + - APP_CLOUD_BASE_URL=http://app-cloud:8007 # task-quiz 调度 + 鉴权内部查询,经 server1 的 APISIX - APP_TASK_BASE_URL=http://apisix:9080 - APP_AUTH_BASE_URL=http://app-auth:8006 diff --git a/deploy/k8s/30-apps/app-cloud.yaml b/deploy/k8s/30-apps/app-cloud.yaml index 7441251d..40c80878 100644 --- a/deploy/k8s/30-apps/app-cloud.yaml +++ b/deploy/k8s/30-apps/app-cloud.yaml @@ -64,6 +64,10 @@ spec: # AI_GATEWAY__* / 凭证类经 envFrom(mindbase-defaults 兜底 + .env 覆盖) - name: MILVUS__URI value: "http://milvus:19530" + # app-ts cloud_doc_summary M2M key (same shared APISIX consumer key) + - name: APPCLOUD__INTERNAL__API_KEY + valueFrom: + secretKeyRef: { name: mindbase-env, key: APISIX_CONSUMER_KEY, optional: true } - name: APPCLOUD__LOG__OUTPUT value: "both" # 提取码静态加密密钥(与 app-auth 同一 Secret;密钥不进数据库) diff --git a/deploy/k8s/30-apps/app-ts.yaml b/deploy/k8s/30-apps/app-ts.yaml index 62b86516..7e4d2fb1 100644 --- a/deploy/k8s/30-apps/app-ts.yaml +++ b/deploy/k8s/30-apps/app-ts.yaml @@ -48,6 +48,8 @@ spec: value: "0.0.0.0" - name: APP_BILI_BASE_URL value: "http://app-bili:8010" + - name: APP_CLOUD_BASE_URL + value: "http://app-cloud:8007" - name: APP_TASK_BASE_URL value: "http://apisix:9080" - name: APP_AUTH_BASE_URL diff --git a/deploy/swarm/docker-stack.yml b/deploy/swarm/docker-stack.yml index 98d7a72e..3332cb3e 100644 --- a/deploy/swarm/docker-stack.yml +++ b/deploy/swarm/docker-stack.yml @@ -572,6 +572,7 @@ services: - MINIO__PUBLIC_ENDPOINT=${MINIO__PUBLIC_ENDPOINT:-} - MINIO__PUBLIC_HOST=${MINIO_PUBLIC_HOST:-} - APISIX_CONSUMER_KEY=${APISIX_CONSUMER_KEY:-} + - APPCLOUD__INTERNAL__API_KEY=${APISIX_CONSUMER_KEY:-} - AI_GATEWAY__API_KEY=${AI_GATEWAY__API_KEY:-} - AI_GATEWAY__BASE_URL=${AI_GATEWAY__BASE_URL:-} - MILVUS__URI=${MILVUS__URI:-http://milvus:19530} @@ -734,6 +735,7 @@ services: - SERVER__HOST=0.0.0.0 - REDIS__URL=${REDIS__URL:-redis://:mind-base@redis:6379/1} - APP_BILI_BASE_URL=http://app-bili:8010 + - APP_CLOUD_BASE_URL=http://app-cloud:8007 - APP_TASK_BASE_URL=http://apisix:9080 - APP_AUTH_BASE_URL=http://app-auth:8006 - QUIZ_EXECUTOR_URL=http://apisix:9080/internal/quiz/generate-llm diff --git a/docker-compose.client.yml b/docker-compose.client.yml index 031a7283..53b8708f 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -390,6 +390,7 @@ services: - SERVER__HOST=0.0.0.0 - REDIS__URL=${REDIS__URL:-redis://:mind-base@redis:6379/1} - APP_BILI_BASE_URL=http://app-bili:8010 + - APP_CLOUD_BASE_URL=http://app-cloud:8007 - APP_TASK_BASE_URL=http://apisix:9080 - APP_AUTH_BASE_URL=http://app-auth:8006 - QUIZ_EXECUTOR_URL=http://apisix:9080/internal/quiz/generate-llm @@ -536,6 +537,7 @@ services: - MINIO__PUBLIC_ENDPOINT=${MINIO__PUBLIC_ENDPOINT:-} - MINIO__PUBLIC_HOST=${MINIO_PUBLIC_HOST:-} - APISIX_CONSUMER_KEY=${APISIX_CONSUMER_KEY:?err_APISIX_CONSUMER_KEY_not_set_in_.env} + - APPCLOUD__INTERNAL__API_KEY=${APISIX_CONSUMER_KEY:-} - AI_GATEWAY__API_KEY=${AI_GATEWAY__API_KEY:-} - AI_GATEWAY__BASE_URL=${AI_GATEWAY__BASE_URL:-} - MILVUS__URI=${MILVUS__URI:-http://milvus:19530} diff --git a/docker-compose.yml b/docker-compose.yml index 1330e2c7..7a1cd9b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -334,6 +334,8 @@ services: - REDIS__URL=${REDIS__URL:-redis://:mind-base@redis:6379/1} # acquisition service (Go): ASR delegation + internal knowledge-scope reads - APP_BILI_BASE_URL=http://app-bili:8010 + # cloud-drive intelligence M2M (summary/similar); token = APISIX_CONSUMER_KEY + - APP_CLOUD_BASE_URL=http://app-cloud:8007 # task-quiz flow: scheduler via APISIX + auth internal user lookup - APP_TASK_BASE_URL=http://apisix:9080 - APP_AUTH_BASE_URL=http://app-auth:8006 @@ -812,6 +814,8 @@ services: - MINIO__PUBLIC_HOST=${MINIO_PUBLIC_HOST:-} # Storage quota tier lookup via APISIX key-auth -> app-pay - APISIX_CONSUMER_KEY=${APISIX_CONSUMER_KEY:?err_APISIX_CONSUMER_KEY_not_set_in_.env} + # app-ts cloud_doc_summary tool: /internal/cloud/summary 的共享 key + - APPCLOUD__INTERNAL__API_KEY=${APISIX_CONSUMER_KEY:-} # Vector pipeline (Higress AI gateway; model/dim RED LINE = Milvus rebuild) - AI_GATEWAY__API_KEY=${AI_GATEWAY__API_KEY:-} - AI_GATEWAY__BASE_URL=${AI_GATEWAY__BASE_URL:-} diff --git a/docs/academic-agent.md b/docs/academic-agent.md index 81269ffc..4ae98334 100644 --- a/docs/academic-agent.md +++ b/docs/academic-agent.md @@ -15,7 +15,7 @@ | 能力 | 承载 | 阶段 | |---|---|---| | 研究项目、六阶段状态机、阶段计划与指导建议 | `academic_project` + `academic_events` | Phase 1 | -| 文献检索(Crossref / OpenAlex / arXiv)、BibTeX·CSL 导入、DOI 核验、BibTeX 导出 | `academic_literature` + `services/literature-client.ts` | Phase 1 / 1.5 批量导入与分页 | +| 文献检索(Crossref / OpenAlex / arXiv)、BibTeX·CSL 导入、DOI 核验、BibTeX 导出 | `academic_literature` + `auth/literature-client.ts` | Phase 1 / 1.5 批量导入与分页 | | 论文稿件(开题报告 / 研究计划 / 初稿 / 修改稿 / 投稿信)、章节读写、大纲、Markdown·LaTeX 导出 | `academic_manuscript` | Phase 1 | | 期刊要求查证、最新信息 | `web_search` / `fetch_webpage`(复用通用工具) | Phase 1 | | 多步任务可见化、跨轮偏好 | `plan` / `save_memory`(复用通用工具) | Phase 1 | @@ -86,7 +86,7 @@ └──────────────────────────────────────────────────────────────────────┘ │ 仅 academic_literature 触发外网 ▼ - src/services/literature-client.ts → Crossref / OpenAlex / arXiv + src/auth/literature-client.ts → Crossref / OpenAlex / arXiv ``` ### 2.2 代码位置 @@ -96,7 +96,7 @@ | `src/agent/academic/agent.ts` | academic 子 agent 工厂:只声明身份、工具名单与提示词来源,循环/重试/SSE 由共享 `createReactAgent` 提供 | | `src/agent/manifest.ts` | agent 花名册(名称 + 描述)唯一来源;`delegate_to_agent` 的可用列表自动跟随 | | `src/tools/academic/{project,literature,manuscript}.ts` | 三组工具工厂;返回中文文本 + 结构化 artifact | -| `src/services/literature-client.ts` | 学术域唯一出网边界:Crossref/OpenAlex/arXiv 检索 + DOI 核验,含进程内 TTL 缓存(search 5 分钟 / DOI 核验 1 小时) | +| `src/auth/literature-client.ts` | 学术域唯一出网边界:Crossref/OpenAlex/arXiv 检索 + DOI 核验,含进程内 TTL 缓存(search 5 分钟 / DOI 核验 1 小时) | | `src/repository/academic.ts` | 四组仓储契约(项目 / 文献 / 文档 / 事件)+ 纯函数 helper(唯一语义来源) | | `src/repository/memory/academic.ts` | 内存实现:测试替身 + Mongo 不可达时的降级 | | `src/repository/mongo/academic.ts` | Mongo 适配器:持久化、索引、原子更新 | @@ -462,7 +462,7 @@ Phase 1 的 import 逐条调用 `add`,每条都要重新判定去重与 citeKe ### 6.4 文献检索 TTL 缓存 -同一会话里反复检索同一个词、或对同一篇文献反复 verify 是常见模式。`services/literature-client.ts` 在不引入依赖的前提下加了有界进程内缓存: +同一会话里反复检索同一个词、或对同一篇文献反复 verify 是常见模式。`auth/literature-client.ts` 在不引入依赖的前提下加了有界进程内缓存: | 缓存 | 键 | TTL | 容量 | 说明 | |---|---|---|---|---| diff --git a/docs/api.md b/docs/api.md index 7204cbd0..a269044e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -63,7 +63,7 @@ APISIX :9080(forward-auth / key-auth / 路由 / 限流) | `/board/*` | app-board | 板 CRUD(`If-Match` 乐观锁)/ 置顶 | | `/quiz/*`、`/tasks/*` | app-quiz + app-ts | 题目存储 / 判题 / 错题 / 分享;生成与批改走 app-ts | | `/task-quiz/*` | app-ts + app-task | 对话定义任务;调度与答题入口 | -| `/cloud/*`、`/workspaces/*` | app-cloud | 分片上传 / 文件夹 / 配额 / 回收站 / 分享 / 处理状态(nginx 直连) | +| `/cloud/*`、`/workspaces/*` | app-cloud | 分片上传 / 文件夹 / 配额 / 回收站 / 分享 / 处理状态 / AI 摘要 / 语义检索 / 相似文件(nginx 直连) | | `/skills/*` | app-ts | 技能安装 / 卸载 / 商店 | | `/pay/*` | app-pay | SKU / 订单 / 会员(HTTPS) | | `/preferences/*`、`/wallpaper/*` | app-settings | 偏好 KB 与壁纸 | diff --git a/docs/app-cloud.md b/docs/app-cloud.md index 3b2334bb..4c7a1b9e 100644 --- a/docs/app-cloud.md +++ b/docs/app-cloud.md @@ -1,7 +1,7 @@ # 云盘服务 app-cloud(Go,云盘权威) > **范围**:B 站收藏知识库的云盘域——分块上传、文件夹、存储配额、百度式分享、 -> 回收站、文档解析向量化管线、RAG 工作区。全部 Go + Gin + GORM 实现, +> 回收站、文档解析/OCR 向量化管线、AI 摘要、语义检索、RAG 工作区。全部 Go + Gin + GORM 实现, > schema 所有权自 Python backend 迁入(2026-09-25 cutover,2026-10-06 Python 删除)。 > 状态:**✅ 生产运行 + 多实例就绪**(2026-10-15 完成 ①–⑤ 分布式强化,详见 > `plan/1.0.13-AppCloud-MultiReplica`,本地设计文档,不入 git)。 @@ -74,13 +74,32 @@ PUT 分块(浏览器直传 MinIO) → complete(POST /cloud/upload/{uuid}/c ``` 入队:上传完成钩子 / POST process / reprocess → upsert 任务行(只重置非 processing 行) 认领:SELECT … FOR UPDATE SKIP LOCKED(任意副本并发安全)→ processing -执行:MinIO 下载 → 解析(pdf/Office/md/html/text;扫描/加密件→not_supported) +执行:MinIO 下载 → 解析(pdf/Office/md/html/text;图片/截图/扫描 PDF 走 vision OCR; + 加密/确实无文字件→not_supported) → 语义分块(600-900 字符 overlap 80-150)→ Higress embedding(text-embedding-v4, 1024 维) → Milvus 写入 → count 一致性校验(不符回滚防孤儿向量)→ done/failed 自愈:janitor 每分钟回收 >10min 的 stale claim(崩溃/被杀自恢复,幂等重跑) ``` -- 视频语义:可上传/预览/播放,向量化依赖 Mongo 既有 ASR 文本(无自动 ASR 链路) +- 视频语义:可上传/预览/播放,向量化依赖 Mongo 既有 ASR 文本(**云盘尚无自动 ASR 链路**;后续补齐 + 视频转写后才能进入摘要/检索)。 +- **图片/截图/扫描 PDF**:vision OCR(默认模型 `z-ai/glm-5.3-flash`,复用 embedding 的网关连接); + 扫描 PDF 用 pdfcpu 提取页内嵌图片逐张 OCR,限制页数(默认 20)/图片数(40)/单图大小(10MiB); + OCR 成功正文照常写入 Mongo/Milvus,`doc_parser=source=ocr`。 +- **L1 摘要**:每篇文档生成 overview/topics/entities/key_points/todos/answers_questions 并持久化; + `GET /cloud/video/{uuid}/summary` 返回 available/stale/model/summary,文档更新后 content_hash + 不一致会标记 stale。默认模型 `z-ai/glm-5.3-flash`(配置 `pipeline.summary.model`)。 +- **语义检索与相似文件**:`GET /cloud/search/semantic?q=&folderId=&limit=`(文件夹子树范围)、 + `GET /cloud/video/{uuid}/similar?limit=`;基于 cloud_drive 向量按 upload_uuid 聚合去重; + 向量/embedding 不可用返回 503,不影响原文件名搜索 `GET /cloud/search`。 +- **M2M(app-ts)**:`GET /internal/cloud/summary`、`/internal/cloud/similar`,用 + `internal.api_key`(compose 复用 APISIX_CONSUMER_KEY)鉴权;app-ts 的 `cloud_doc_summary` + 工具经此读取摘要,uid 由服务端会话注入,LLM 不能指定 uid。 +- **文件级 AI**:云盘详情页展示摘要/AI 标签/关键点/待办/可回答问题与相似文件;「问这份文件」进入 + `/chat?upload_uuid=`,聊天只以该文件为检索范围;聊天来源支持 uploadUuid 深链回云盘详情。 +- **配置**:`pipeline.ocr.*`(enabled/model/max_image_bytes/max_pdf_pages/max_images/timeout_seconds; + OCR 与摘要/embedding 共用 Higress 网关连接,没有独立的 base_url/api_key)、 + `pipeline.summary.model`、`internal.api_key`。 - **红线**:embedding 模型/维度变更 = `cloud_drive` 全量重建(启动 schema 守护会拒绝服务) - **启动容错(2026-10)**:Milvus 连接失败不再无限阻塞启动——10s 有界拨号后**降级**(上传标记 not_supported,日志 ERROR 响亮告警);但 **schema 形状漂移(缺字段/dim 不符)仍然 fail loud 退出**(防检索被污染)。Mongo 不可用依旧优雅降级。 @@ -97,7 +116,8 @@ PUT 分块(浏览器直传 MinIO) → complete(POST /cloud/upload/{uuid}/c ## 七、配置与运行 -- 配置唯一入口 `default.yaml`(嵌入二进制)+ `APPCLOUD__` env 覆盖;敏感项复用共享 env +- 配置唯一入口 `default.yaml`(嵌入二进制)+ `APPCLOUD__` env 覆盖;OCR/摘要模型默认 + `z-ai/glm-5.3-flash`(需在 Higress 可路由);敏感项复用共享 env (`MINIO__*`/`REDIS__URL`/`MONGO__URI`/`AI_GATEWAY__*`/`MILVUS__*`/`APISIX_CONSUMER_KEY`) - 本地:`go run ./app-go/app-cloud`(读项目根 .env);compose profile `["", "cloud", "full"]` - 扩容:`docker compose up -d --scale app-cloud=2`(宿主 8007/8008);详见 [deployment.md](deployment.md) diff --git a/docs/features.md b/docs/features.md index ef677149..98665fee 100644 --- a/docs/features.md +++ b/docs/features.md @@ -141,8 +141,9 @@ MindBase 的能力围绕三条主线: **入口**:导航「云盘」(`/cloud-drive`) -- 文件夹树、分片直传(单文件最大 5GB)、搜索、排序、批量处理、配额用量条、回收站(软删 30 天)。 -- **处理管道**:上传后异步解析(pdf/docx/doc/html/md/txt + OCR)→ 切块向量化 → 可检索;状态 `pending → processing → done/failed/not_supported`。 +- 文件夹树、分片直传(单文件最大 5GB)、文件名/语义搜索、排序、批量处理、配额用量条、回收站(软删 30 天)。 +- **处理管道**:上传后异步解析(pdf/docx/doc/html/md/txt + 图片/截图/扫描 PDF vision OCR)→ 切块向量化 → 可检索;状态 `pending → processing → done/failed/not_supported`。 +- **AI 能力**:每篇文档生成摘要/AI 标签/关键点/待办/可回答问题;详情页展示摘要与相似文件,可「问这份文件」进入限定范围的对话;语义检索支持当前文件夹范围;聊天来源可深链回云盘文件,agent 可用 `cloud_doc_summary` 读取摘要。 - **配额**:免费 10GB / VIP / SVIP,档位来自 app-pay;初始化即原子预留,不超卖。 - **分享**:提取码 / 有效期 / 下载上限 / 短时效直链,免登录访问页;合规确认 + 文件夹分享 +「保存到我的云盘」(引用,不复制对象、不计配额)。 - **多实例就绪**:outbox 任务队列 + 原子配额预留 + 租约锁后台任务。 diff --git a/frontendv2/app/chat/page.tsx b/frontendv2/app/chat/page.tsx index d196ea93..da6d854e 100644 --- a/frontendv2/app/chat/page.tsx +++ b/frontendv2/app/chat/page.tsx @@ -6,16 +6,24 @@ import { ChatView } from "@/components/chat/chat-view"; /** * Chat page - 对话主页。 * - * 支持 /chat?academic_project=(由科研工作台详情页「在对话中继续」跳入): - * 项目 id 透传给 ChatView,请求体带 academic_project_id,后端把受控项目 section - * 注入系统提示。Next.js 16 的 searchParams 是 Promise,客户端页面用 use() 解包 + * 支持两种带上下文的入口: + * - /chat?academic_project=(科研工作台详情页「在对话中继续」):透传项目 id, + * 请求体带 academic_project_id,后端把受控项目 section 注入系统提示; + * - /chat?upload_uuid=(云盘文件详情「问这份文件」):透传文件 uuid, + * 请求体带 scope.uploadUuids,回答只基于该云盘文件。 + * Next.js 16 的 searchParams 是 Promise,客户端页面用 use() 解包 * (与 app/graph/page.tsx 同一写法)。 */ export default function ChatPage({ searchParams, }: { - searchParams: Promise<{ academic_project?: string }>; + searchParams: Promise<{ academic_project?: string; upload_uuid?: string }>; }) { - const { academic_project } = use(searchParams); - return ; + const { academic_project, upload_uuid } = use(searchParams); + return ( + + ); } diff --git a/frontendv2/components/chat/chat-message.tsx b/frontendv2/components/chat/chat-message.tsx index 93dbb852..ca83912c 100644 --- a/frontendv2/components/chat/chat-message.tsx +++ b/frontendv2/components/chat/chat-message.tsx @@ -14,6 +14,7 @@ import { AlertCircle, Sparkles, Pencil, + ArrowRight, } from "lucide-react"; import { isTypedArtifact, type ChatArtifact, type ChatInteraction, type ChatPlan, type ChatSource } from "@/lib/chat-stream"; import type { ReasoningStep } from "./types"; @@ -58,18 +59,26 @@ interface ChatMessageProps { onEditCancel?: () => void; } -// Extract a readable domain from a source URL for the citation card. -function domainOf(url?: string): string { - if (!url) return "bilibili.com"; +// 云盘文件引用按站内深链处理(同标签页打开);其余来源是站外链接。 +function isInternalSource(src: { uploadUuid?: string }): boolean { + return !!src.uploadUuid; +} + +// Extract a readable domain / label for the citation card. +function domainOf(src: { url?: string; uploadUuid?: string }): string { + if (isInternalSource(src)) return "我的云盘"; + if (!src.url) return "bilibili.com"; try { - const u = new URL(url); + const u = new URL(src.url); return u.hostname.replace(/^www\./, ""); } catch { - return url.slice(0, 40); + return src.url.slice(0, 40); } } -function sourceHref(src: { url?: string; bvid?: string }): string { +function sourceHref(src: { url?: string; bvid?: string; uploadUuid?: string }): string { + // 文件引用优先:uploadUuid 存在时进入云盘详情,不生成 bilibili 外链 + if (src.uploadUuid) return `/cloud-drive/${src.uploadUuid}`; return src.url || (src.bvid ? `https://www.bilibili.com/video/${src.bvid}` : "#"); } @@ -569,11 +578,15 @@ function ChatMessage({ - ))} @@ -660,13 +673,14 @@ function ChatMessage({
引用来源
{safeSources.slice(0, 6).map((source, i) => { - const domain = domainOf(source.url); + const domain = domainOf(source); + const internal = isInternalSource(source); return ( @@ -676,7 +690,11 @@ function ChatMessage({ {source.title} {domain} - ); })} diff --git a/frontendv2/components/chat/chat-view.tsx b/frontendv2/components/chat/chat-view.tsx index 1df097e2..7bb56539 100644 --- a/frontendv2/components/chat/chat-view.tsx +++ b/frontendv2/components/chat/chat-view.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; -import { ChevronDown, FlaskConical, X } from "lucide-react"; +import { ChevronDown, FileText, FlaskConical, X } from "lucide-react"; import { ChatSidebar } from "./chat-sidebar"; import { ChatHeader } from "./chat-header"; import ChatMessage from "./chat-message"; @@ -35,7 +35,16 @@ function toUIMessage(m: ApiChatMessage): ChatMessageData { role: m.role === "system" ? "assistant" : m.role, content: m.content, // Backend may return null; normalize to array. - sources: Array.isArray(m.sources) ? m.sources : undefined, + // sources 里的云盘引用(upload_uuid / uploadUuid)统一成 ChatSource.uploadUuid, + // 保证历史消息里的文件引用也能渲染站内深链。 + sources: Array.isArray(m.sources) + ? m.sources.map((s) => ({ + title: s.title, + url: s.url, + bvid: s.bvid, + uploadUuid: s.uploadUuid ?? s.upload_uuid, + })) + : undefined, // 用户消息附图:MinIO 预签名 URL(新)或内联 data URL(旧消息)——不映射的话 // 重新加载历史后图片全部消失(只在发送当轮可见)。 images: Array.isArray(m.images) ? m.images : undefined, @@ -86,9 +95,14 @@ interface ChatViewProps { * 缺省 = 普通聊天;传入后底部请求体带 academic_project_id,后端把项目注入受控系统提示。 */ academicProjectId?: string; + /** + * 云盘文件级问答(来自 /chat?upload_uuid=,由云盘详情「问这份文件」跳入)。 + * 传入后请求体带 scope.uploadUuids=[uuid],回答只基于该文件;缺省为普通聊天。 + */ + uploadUuid?: string; } -export function ChatView({ academicProjectId }: ChatViewProps = {}) { +export function ChatView({ academicProjectId, uploadUuid }: ChatViewProps = {}) { const [sidebarOpen, setSidebarOpen] = useState(true); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); @@ -390,6 +404,8 @@ export function ChatView({ academicProjectId }: ChatViewProps = {}) { ...(images && images.length > 0 ? { images } : {}), // 科研项目上下文:仅在 URL 带入项目时附加,普通聊天请求体保持不变。 ...(academicProjectId ? { academic_project_id: academicProjectId } : {}), + // 文件级问答:限定只基于该云盘文件检索/回答;普通聊天不带 scope。 + ...(uploadUuid ? { scope: { uploadUuids: [uploadUuid] } } : {}), }, controller.signal ); @@ -580,7 +596,7 @@ export function ChatView({ academicProjectId }: ChatViewProps = {}) { void refreshQuota(); } }, - [activeSessionId, updateActiveSession, refreshSessions, backfillMessageIds, academicProjectId] + [activeSessionId, updateActiveSession, refreshSessions, backfillMessageIds, academicProjectId, uploadUuid] ); // ---- Send a message (skillIds: forced-inject skills for this turn only) ---- @@ -1055,6 +1071,24 @@ export function ChatView({ academicProjectId }: ChatViewProps = {}) { {/* Input area - bottom padding keeps clear of the iOS home indicator */}
+ {/* 文件级问答提示:/chat?upload_uuid= 进入时只基于该文件回答 */} + {uploadUuid && ( +
+
+ )} {(() => { const chatKind = quotaSummary?.enabled ? quotaSummary.kinds.find((k) => k.kind === "chat") diff --git a/frontendv2/components/cloud-drive/cloud-drive-view.tsx b/frontendv2/components/cloud-drive/cloud-drive-view.tsx index 04773446..7bb41b83 100644 --- a/frontendv2/components/cloud-drive/cloud-drive-view.tsx +++ b/frontendv2/components/cloud-drive/cloud-drive-view.tsx @@ -19,11 +19,19 @@ import { type CloudVideoItem, type CloudVideoDetailResponse, type RefItem, + type SemanticHit, } from "@/lib/api/cloud"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { cn } from "@/lib/utils"; import { FolderSidebar } from "./folder-sidebar"; -import { FileToolbar, type ViewMode, type SortField, type SortOrder } from "./file-toolbar"; +import { + FileToolbar, + type ViewMode, + type SortField, + type SortOrder, + type SearchMode, +} from "./file-toolbar"; +import { SemanticResults } from "./semantic-results"; import { FileList } from "./file-list"; import { FileGrid } from "./file-grid"; import { FileInspector } from "./file-inspector"; @@ -32,6 +40,7 @@ import { ShareDialog } from "./share-dialog"; import { formatBytes, type CloudQuotaResponse, type CloudTrashItem } from "@/lib/api/cloud"; import { UploadDropzone, UploadProgressPill } from "./upload-dropzone"; import { FileListSkeleton, NoFiles } from "./empty-states"; +import { emitAppToast } from "@/lib/app-toast"; const PAGE_SIZE = 50; @@ -62,8 +71,18 @@ export function CloudDriveView() { // ── app-cloud 新能力:配额 / 搜索 / 回收站 / 分享 ── const [quota, setQuota] = useState(null); const [searchQ, setSearchQ] = useState(""); + const [searchMode, setSearchMode] = useState("name"); const [searchResults, setSearchResults] = useState(null); const [searching, setSearching] = useState(false); + // 语义检索:结果与「查询+范围」的 key 绑定,key 不匹配时按加载中处理, + // 避免切换文件夹/模式后短暂显示上一组结果。 + const [semanticResults, setSemanticResults] = useState(null); + const [semanticKey, setSemanticKey] = useState(null); + const [semanticLoading, setSemanticLoading] = useState(false); + const [semanticError, setSemanticError] = useState(null); + const [semanticReloadKey, setSemanticReloadKey] = useState(0); + // 语义模式是否限定在当前文件夹(根目录下不生效) + const [semanticOnlyFolder, setSemanticOnlyFolder] = useState(true); const [trashOpen, setTrashOpen] = useState(false); const [trashItems, setTrashItems] = useState([]); const [trashCount, setTrashCount] = useState(0); @@ -92,6 +111,12 @@ export function CloudDriveView() { // ── Processing poll set ── const [processingUuids, setProcessingUuids] = useState>(new Set()); + // Last known vector status + display name per file. The poll compares + // against this so a terminal transition fires exactly one toast, while + // list reloads (which also set statuses) never spam the user. + const lastVectorStatusRef = useRef>(new Map()); + const fileNameRef = useRef>(new Map()); + const notifiedVectorRef = useRef>(new Set()); // ── Dialogs ── const [deleteFolderTarget, setDeleteFolderTarget] = useState(null); @@ -145,6 +170,22 @@ export function CloudDriveView() { [sort, order] ); + // Keep the status/name cache in sync with whatever the list/detail show; + // the poll uses the previous entry to detect a real terminal transition. + useEffect(() => { + for (const v of videos) { + lastVectorStatusRef.current.set(v.uploadUuid, v.vectorStatus); + fileNameRef.current.set(v.uploadUuid, v.title || v.originalName || "文件"); + } + if (detail) { + lastVectorStatusRef.current.set(detail.uploadUuid, detail.vectorStatus); + fileNameRef.current.set( + detail.uploadUuid, + detail.title || detail.originalName || "文件" + ); + } + }, [videos, detail]); + const refreshDetail = useCallback(async (uuid: string) => { setLoadingDetail(true); try { @@ -216,6 +257,25 @@ export function CloudDriveView() { for (const uuid of ids) { try { const s = await cloudApi.getVideoStatus(uuid); + const previousStatus = lastVectorStatusRef.current.get(uuid); + lastVectorStatusRef.current.set(uuid, s.vectorStatus); + if ( + previousStatus !== s.vectorStatus && + !notifiedVectorRef.current.has(uuid) && + (s.vectorStatus === "done" || + s.vectorStatus === "failed" || + s.vectorStatus === "not_supported") + ) { + notifiedVectorRef.current.add(uuid); + const fileName = fileNameRef.current.get(uuid) || "文件"; + if (s.vectorStatus === "done") { + emitAppToast("success", "「" + fileName + "」已入库,可用于 AI 检索"); + } else if (s.vectorStatus === "failed") { + emitAppToast("error", "「" + fileName + "」入库失败,请重试"); + } else { + emitAppToast("warning", "「" + fileName + "」暂不支持入库(格式或内容无法解析)"); + } + } setVideos((prev) => prev.map((v) => v.uploadUuid === uuid @@ -261,6 +321,14 @@ export function CloudDriveView() { setSelectedRefId(null); }, []); + // 语义结果 / 相似文件可能不在当前文件夹列表里:详情接口按 uuid 独立加载, + // 因此直接打开详情面板即可(不依赖列表项存在)。 + const handleOpenFileDetail = useCallback((uuid: string) => { + setSelectedUuid(uuid); + setDetail(null); + setSelectedRefId(null); + }, []); + // ── cloud_refs handlers(引用不进上传/ASR/向量化菜单)── const handleSelectRef = useCallback((id: number) => { // dead 引用在列表层已被置灰无活动入口;此处只处理 alive。 @@ -325,8 +393,10 @@ export function CloudDriveView() { setUploadDone(false); } await refreshVideos(selectedFolderId, 1, false); - } catch { - // TODO: surface a toast once a host exists. + const doneName = list.length === 1 ? "「" + list[0].name + "」" : list.length + " 个文件"; + emitAppToast("success", doneName + "上传完成,点击「入库」后可生成摘要并加入检索"); + } catch (e) { + emitAppToast("error", e instanceof Error ? "上传失败:" + e.message : "上传失败,请重试"); } finally { setUploading(false); setUploadProgress(0); @@ -393,6 +463,20 @@ export function CloudDriveView() { const handleProcess = useCallback( async (uuid: string) => { + // Local idempotency guard: clicking 入库 repeatedly must not even + // hit the API while the file is queued/running. The backend has a + // second atomic guard for multi-tab/API races. + const knownStatus = lastVectorStatusRef.current.get(uuid); + if ( + processingUuids.has(uuid) || + knownStatus === "pending" || + knownStatus === "processing" + ) { + emitAppToast("info", "该文件正在入库中,请等待完成"); + return; + } + notifiedVectorRef.current.delete(uuid); + lastVectorStatusRef.current.set(uuid, "processing"); setProcessingUuids((prev) => new Set([...prev, uuid])); // Optimistically flip the list + detail to processing. setVideos((prev) => @@ -405,15 +489,63 @@ export function CloudDriveView() { ); try { await cloudApi.triggerProcess(uuid); - } catch { + const name = fileNameRef.current.get(uuid) || "文件"; + emitAppToast("info", "已提交入库,正在处理「" + name + "」"); + } catch (e) { + setProcessingUuids((prev) => { + const next = new Set(prev); + next.delete(uuid); + return next; + }); + emitAppToast( + "error", + e instanceof Error ? "提交入库失败:" + e.message : "提交入库失败,请重试" + ); + } + }, + [processingUuids] + ); + + const handleReprocess = useCallback( + async (uuid: string) => { + const knownStatus = lastVectorStatusRef.current.get(uuid); + if ( + processingUuids.has(uuid) || + knownStatus === "pending" || + knownStatus === "processing" + ) { + emitAppToast("info", "该文件正在入库中,请等待完成"); + return; + } + notifiedVectorRef.current.delete(uuid); + lastVectorStatusRef.current.set(uuid, "processing"); + setProcessingUuids((prev) => new Set([...prev, uuid])); + setVideos((prev) => + prev.map((v) => + v.uploadUuid === uuid ? { ...v, vectorStatus: "processing" } : v + ) + ); + setDetail((prev) => + prev && prev.uploadUuid === uuid ? { ...prev, vectorStatus: "processing" } : prev + ); + try { + await cloudApi.reprocessVideo(uuid); + const name = fileNameRef.current.get(uuid) || "文件"; + emitAppToast("info", "已提交重新入库,正在处理「" + name + "」"); + } catch (e) { setProcessingUuids((prev) => { const next = new Set(prev); next.delete(uuid); return next; }); + lastVectorStatusRef.current.set(uuid, knownStatus || "done"); + emitAppToast( + "error", + e instanceof Error ? "重新入库失败:" + e.message : "重新入库失败,请稍后重试" + ); } }, - [] + [processingUuids] ); // ── app-cloud handlers ── @@ -421,21 +553,62 @@ export function CloudDriveView() { void cloudApi.getQuota().then(setQuota).catch(() => {}); }, []); - // 搜索防抖(清空时由派生值恢复文件夹列表,见 effectiveSearchResults) + // 搜索防抖(清空时由派生值恢复文件夹列表,见 effectiveSearchResults / semanticActive)。 + // 文件名模式沿用 /cloud/search;语义模式调 /cloud/search/semantic, + // 语义后端不可用时只在结果区报错,文件名搜索不受影响。 useEffect(() => { - if (!searchQ.trim()) { - return; + const q = searchQ.trim(); + if (!q) return; + let cancelled = false; + + if (searchMode === "name") { + const t = setTimeout(() => { + setSearching(true); + void cloudApi + .searchFiles(q) + .then((res) => { + if (!cancelled) setSearchResults(res.results); + }) + .catch(() => { + if (!cancelled) setSearchResults([]); + }) + .finally(() => { + if (!cancelled) setSearching(false); + }); + }, 350); + return () => { + cancelled = true; + clearTimeout(t); + }; } + + const folderId = semanticOnlyFolder ? selectedFolderId : null; + const key = `${q}::${folderId ?? "all"}`; const t = setTimeout(() => { - setSearching(true); + setSemanticLoading(true); + setSemanticError(null); void cloudApi - .searchFiles(searchQ.trim()) - .then((res) => setSearchResults(res.results)) - .catch(() => setSearchResults([])) - .finally(() => setSearching(false)); - }, 350); - return () => clearTimeout(t); - }, [searchQ]); + .searchSemantic({ q, folderId, limit: 10 }) + .then((res) => { + if (cancelled) return; + setSemanticResults(res.results); + setSemanticKey(key); + }) + .catch((e) => { + if (cancelled) return; + setSemanticResults([]); + setSemanticKey(key); + setSemanticError(e instanceof Error ? e.message : "语义检索失败"); + }) + .finally(() => { + if (!cancelled) setSemanticLoading(false); + }); + }, 400); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [searchQ, searchMode, semanticOnlyFolder, selectedFolderId, semanticReloadKey]); const refreshTrash = useCallback(async () => { setTrashLoading(true); @@ -479,7 +652,17 @@ export function CloudDriveView() { // ── Derived ── // 搜索词非空时显示搜索结果,清空(或纯空白)时回落到文件夹列表 - const effectiveSearchResults = searchQ.trim() ? searchResults : null; + const trimmedSearchQ = searchQ.trim(); + const searchActive = trimmedSearchQ.length > 0; + const semanticActive = searchActive && searchMode === "semantic"; + const effectiveSearchResults = + searchActive && searchMode === "name" ? searchResults : null; + // 语义结果的 key = 查询词 + 检索范围;不匹配说明请求还没回来(或环境已变) + const semanticScopeKey = `${trimmedSearchQ}::${ + semanticOnlyFolder && selectedFolderId != null ? selectedFolderId : "all" + }`; + const semanticPending = + semanticActive && (semanticLoading || semanticKey !== semanticScopeKey); const totalFiles = folders.reduce((sum, f) => sum + (f.videoCount || 0), 0) + videos.length; const currentFolderName = selectedFolderId == null @@ -548,6 +731,18 @@ export function CloudDriveView() { uploading={uploading} searchValue={searchQ} onSearchChange={setSearchQ} + searchMode={searchMode} + onSearchModeChange={(mode) => { + setSearchMode(mode); + if (mode === "semantic") { + // 重新进入语义模式时清掉上一轮的错误/缓存 key,触发重新检索 + setSemanticKey(null); + setSemanticError(null); + } + }} + semanticOnlyFolder={semanticOnlyFolder} + onSemanticOnlyFolderChange={setSemanticOnlyFolder} + semanticFolderDisabled={selectedFolderId == null} canShare={selectedUuid != null} onShareClick={() => setShareOpen(true)} /> @@ -590,6 +785,15 @@ export function CloudDriveView() { ))} ) + ) : semanticActive ? ( + setSemanticReloadKey((k) => k + 1)} + onOpen={handleOpenFileDetail} + /> ) : effectiveSearchResults != null ? ( searching ? ( @@ -689,8 +893,10 @@ export function CloudDriveView() { detail={detail} processing={processingUuids.has(detail.uploadUuid)} onProcess={(uuid) => void handleProcess(uuid)} + onReprocess={(uuid) => void handleReprocess(uuid)} onDelete={(uuid) => setDeleteFileUuid(uuid)} onBack={() => setSelectedUuid(null)} + onOpenFile={handleOpenFileDetail} /> ) : loadingDetail ? (
diff --git a/frontendv2/components/cloud-drive/file-ai-panel.tsx b/frontendv2/components/cloud-drive/file-ai-panel.tsx new file mode 100644 index 00000000..8ee8f7c4 --- /dev/null +++ b/frontendv2/components/cloud-drive/file-ai-panel.tsx @@ -0,0 +1,379 @@ +"use client"; + +/** + * File AI panel - 文件级 AI 信息。 + * + * 并行加载 AI 摘要与相似文件:摘要展示概览 / AI 标签(主题、实体)/ 关键点 / + * 待办 / 可回答问题;相似文件展示内容相近的其他文件。两部分互不阻塞,任一 + * 请求失败只在该区域提示,不影响文件详情、预览与删除。 + * + * 「问这份文件」进入限定该文件的对话(/chat?upload_uuid=...)。 + */ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { + AlertCircle, + Check, + Loader2, + MessageSquareText, + RefreshCw, + Sparkles, +} from "lucide-react"; +import { + cloudApi, + formatBytes, + type CloudSummary, + type CloudSummaryResponse, + type SemanticHit, +} from "@/lib/api/cloud"; +import { FileIconTile } from "./file-icon"; +import { formatSimilarity } from "./helpers"; + +interface AiState { + uuid: string; + summary: CloudSummaryResponse | null; + similar: SemanticHit[]; + summaryError: string | null; + similarError: string | null; +} + +interface FileAiPanelProps { + uploadUuid: string; + /** 无摘要提示里的「重新入库」动作;缺省只显示文案 */ + onReprocess?: (uuid: string) => void; + /** 相似文件点击后就地打开详情;缺省跳转 /cloud-drive/ */ + onSelectFile?: (uuid: string) => void; +} + +export function FileAiPanel({ uploadUuid, onReprocess, onSelectFile }: FileAiPanelProps) { + const [state, setState] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let cancelled = false; + void (async () => { + const [summary, similar] = await Promise.allSettled([ + cloudApi.getSummary(uploadUuid), + cloudApi.getSimilar(uploadUuid, 5), + ]); + if (cancelled) return; + setState({ + uuid: uploadUuid, + summary: summary.status === "fulfilled" ? summary.value : null, + similar: similar.status === "fulfilled" ? similar.value.items : [], + summaryError: + summary.status === "rejected" + ? errorMessage(summary.reason, "摘要加载失败") + : null, + similarError: + similar.status === "rejected" + ? errorMessage(similar.reason, "相似文件加载失败") + : null, + }); + })(); + return () => { + cancelled = true; + }; + }, [uploadUuid, reloadKey]); + + // 按 uuid 缓存:切换文件时旧文件的数据立即失效,不闪现在新文件上。 + const current = state && state.uuid === uploadUuid ? state : null; + const loading = current == null; + const response = current?.summary ?? null; + const summary = response?.available ? response.summary : null; + + const retry = () => { + setState(null); + setReloadKey((k) => k + 1); + }; + + return ( +
+ {/* AI 摘要 */} +
+ } + title="AI 摘要" + trailing={ + response?.stale ? ( + + 内容已更新,摘要可能过时 + + ) : null + } + /> + {loading ? ( + + ) : current?.summaryError ? ( + + ) : summary ? ( + + ) : ( + onReprocess(uploadUuid) : undefined} + /> + )} +
+ + {/* 文件级 AI 操作 */} + + + 问这份文件 + + + {/* 相似文件 */} +
+ + {loading ? ( + + ) : current?.similarError ? ( + + ) : current && current.similar.length > 0 ? ( +
    + {current.similar.map((hit) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ 暂无可参照的相似文件 +

+ )} +
+
+ ); +} + +function SummaryBody({ + summary, + response, +}: { + summary: CloudSummary; + response: CloudSummaryResponse | null; +}) { + const topics = summary.topics ?? []; + const entities = summary.entities ?? []; + const keyPoints = summary.keyPoints ?? []; + const todos = summary.todos ?? []; + const questions = summary.answersQuestions ?? []; + + return ( +
+ {summary.overview ? ( +

+ {summary.overview} +

+ ) : null} + + {topics.length > 0 || entities.length > 0 ? ( +
+ AI 标签 +
+ {topics.map((topic) => ( + + {topic} + + ))} + {entities.map((entity) => ( + {entity} + ))} +
+
+ ) : null} + + {keyPoints.length > 0 ? ( +
+ 关键点 +
    + {keyPoints.map((point, i) => ( +
  • + + {point} +
  • + ))} +
+
+ ) : null} + + {todos.length > 0 ? ( +
+ 待办 +
    + {todos.map((todo, i) => ( +
  • +
  • + ))} +
+
+ ) : null} + + {questions.length > 0 ? ( +
+ 可回答问题 +
    + {questions.map((question, i) => ( +
  • + {question} +
  • + ))} +
+
+ ) : null} + + {response?.model ? ( +

+ 由 {response.model} 生成 + {summary.sizeChars > 0 ? ` · 约 ${summary.sizeChars} 字` : ""} +

+ ) : null} +
+ ); +} + +function NoSummary({ onReprocess }: { onReprocess?: () => void }) { + return ( +
+

摘要尚未生成,可重新入库

+ {onReprocess ? ( + + ) : null} +
+ ); +} + +function SimilarRow({ + hit, + onSelectFile, +}: { + hit: SemanticHit; + onSelectFile?: (uuid: string) => void; +}) { + const name = hit.title || hit.originalName; + const className = + "flex w-full items-center gap-2.5 rounded-xl border border-border-subtle bg-surface px-2.5 py-2 text-left transition-colors hover:border-border hover:bg-border-subtle/40"; + const content = ( + <> + + + {name} + + {formatBytes(hit.fileSize)} + {hit.snippets && hit.snippets[0] ? ` · ${hit.snippets[0]}` : ""} + + + + {formatSimilarity(hit.score)} + + + ); + + if (onSelectFile) { + return ( + + ); + } + return ( + + {content} + + ); +} + +function SectionTitle({ + title, + icon, + trailing, +}: { + title: string; + icon?: React.ReactNode; + trailing?: React.ReactNode; +}) { + return ( +
+

+ {icon} + {title} +

+ {trailing} +
+ ); +} + +function SubLabel({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function Chip({ children, tone = "plain" }: { children: React.ReactNode; tone?: "plain" | "accent" }) { + return ( + + {children} + + ); +} + +function LoadingRow({ text }: { text: string }) { + return ( +

+

+ ); +} + +function ErrorRow({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+ ); +} + +function errorMessage(reason: unknown, fallback: string): string { + if (reason instanceof Error && reason.message) return reason.message; + return fallback; +} diff --git a/frontendv2/components/cloud-drive/file-inspector.tsx b/frontendv2/components/cloud-drive/file-inspector.tsx index a87098ff..ab31621b 100644 --- a/frontendv2/components/cloud-drive/file-inspector.tsx +++ b/frontendv2/components/cloud-drive/file-inspector.tsx @@ -20,6 +20,7 @@ import { import type { CloudVideoDetailResponse } from "@/lib/api/cloud"; import { formatBytes } from "@/lib/api/cloud"; import { FileIconTile } from "./file-icon"; +import { FileAiPanel } from "./file-ai-panel"; import { AsrStatusBadge, VectorStatusBadge } from "./status-badges"; import { formatDuration, formatRelativeTime } from "./helpers"; @@ -27,16 +28,22 @@ interface FileInspectorProps { detail: CloudVideoDetailResponse; processing: boolean; onProcess: (uuid: string) => void; + /** done 文件的「重新入库」:走 reprocess(删除旧向量后重跑),不是普通 process */ + onReprocess?: (uuid: string) => void; onDelete: (uuid: string) => void; onBack: () => void; + /** 相似文件点击:就地打开另一个文件的详情 */ + onOpenFile?: (uuid: string) => void; } export function FileInspector({ detail, processing, onProcess, + onReprocess, onDelete, onBack, + onOpenFile, }: FileInspectorProps) { const isVideo = detail.mimeType.startsWith("video/"); const isVectorizable = detail.vectorStatus !== "not_supported"; @@ -88,7 +95,7 @@ export function FileInspector({ {done ? (
+ +
+ + {/* AI 摘要 / 标签 / 关键点 / 待办 / 可回答问题 / 相似文件 */} +
+ +
{/* Status */}
diff --git a/frontendv2/components/cloud-drive/file-toolbar.tsx b/frontendv2/components/cloud-drive/file-toolbar.tsx index 64875daf..f4f73b9d 100644 --- a/frontendv2/components/cloud-drive/file-toolbar.tsx +++ b/frontendv2/components/cloud-drive/file-toolbar.tsx @@ -27,6 +27,8 @@ import { cn } from "@/lib/utils"; export type ViewMode = "list" | "grid"; export type SortField = "original_name" | "file_size" | "created_at"; export type SortOrder = "asc" | "desc"; +/** 搜索模式:文件名匹配 / 语义检索 */ +export type SearchMode = "name" | "semantic"; interface FileToolbarProps { title: string; @@ -42,6 +44,14 @@ interface FileToolbarProps { /** app-cloud additions */ searchValue?: string; onSearchChange?: (q: string) => void; + /** 搜索模式切换(文件名 / 语义);缺省不渲染切换控件 */ + searchMode?: SearchMode; + onSearchModeChange?: (mode: SearchMode) => void; + /** 语义模式下「仅当前文件夹」勾选 */ + semanticOnlyFolder?: boolean; + onSemanticOnlyFolderChange?: (value: boolean) => void; + /** 根目录时无法限定文件夹,勾选框置灰 */ + semanticFolderDisabled?: boolean; canShare?: boolean; onShareClick?: () => void; } @@ -64,6 +74,11 @@ export function FileToolbar({ uploading, searchValue, onSearchChange, + searchMode = "name", + onSearchModeChange, + semanticOnlyFolder, + onSemanticOnlyFolderChange, + semanticFolderDisabled, canShare, onShareClick, }: FileToolbarProps) { @@ -96,24 +111,67 @@ export function FileToolbar({ )} - {onSearchChange && ( -
- - onSearchChange(e.target.value)} - placeholder="搜索文件…" - className="h-10 w-full rounded-full border border-border-subtle bg-surface pl-8 pr-7 text-[16px] text-foreground outline-none transition-all placeholder:text-tertiary focus:border-accent sm:h-8 sm:w-40 sm:text-[12px] sm:focus:w-56" - /> - {searchValue ? ( - + ) : null} +
+ )} + {onSearchModeChange && ( +
+ onSearchModeChange("name")} + label="文件名" + /> + onSearchModeChange("semantic")} + label="语义" + /> +
+ )} + {searchMode === "semantic" && onSemanticOnlyFolderChange && ( + + )}
)}

@@ -253,6 +311,38 @@ export function FileToolbar({ ); } +/** 搜索模式按钮(与视图切换同款分段样式,各自独立的滑动指示器) */ +function ModeBtn({ + active, + onClick, + label, +}: { + active: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} + function SegBtn({ active, onClick, diff --git a/frontendv2/components/cloud-drive/file-viewer.tsx b/frontendv2/components/cloud-drive/file-viewer.tsx index ed70abc9..1aadc37a 100644 --- a/frontendv2/components/cloud-drive/file-viewer.tsx +++ b/frontendv2/components/cloud-drive/file-viewer.tsx @@ -23,6 +23,7 @@ import { Markdown } from "@/components/markdown"; import { ArrowLeft, Loader2, AlertCircle, Download, FileText } from "lucide-react"; import { cloudApi, formatBytes, type CloudRawFileResponse } from "@/lib/api/cloud"; import { FileIconTile } from "./file-icon"; +import { FileAiPanel } from "./file-ai-panel"; /** * Normalize the presigned MinIO URL to a same-origin path when it goes @@ -154,6 +155,11 @@ export function FileViewer() { {/* Body */}
+ {uuid ? ( +
+ +
+ ) : null}
diff --git a/frontendv2/components/cloud-drive/helpers.ts b/frontendv2/components/cloud-drive/helpers.ts index 3721c65f..afc99471 100644 --- a/frontendv2/components/cloud-drive/helpers.ts +++ b/frontendv2/components/cloud-drive/helpers.ts @@ -16,6 +16,13 @@ export function formatDuration(seconds: number | null | undefined): string { return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } +/** Format a similarity score as a percentage (cosine scores are 0..1). */ +export function formatSimilarity(score: number): string { + if (!Number.isFinite(score)) return "-"; + const pct = score <= 1 ? score * 100 : score; + return `${Math.round(pct)}%`; +} + /** Format an ISO timestamp as a relative Chinese string. */ export function formatRelativeTime(iso: string | null | undefined): string { if (!iso) return ""; diff --git a/frontendv2/components/cloud-drive/semantic-results.tsx b/frontendv2/components/cloud-drive/semantic-results.tsx new file mode 100644 index 00000000..9b06a4ca --- /dev/null +++ b/frontendv2/components/cloud-drive/semantic-results.tsx @@ -0,0 +1,128 @@ +"use client"; + +/** + * Semantic search results - 语义检索结果卡片。 + * + * 每张卡片展示文件名、命中片段、相似度;点击就地打开该文件的详情。 + * 加载 / 空态 / 错误(如语义服务暂不可用)各自独立呈现,文件名字符串搜索不受影响。 + */ +import { AlertCircle, Loader2, RefreshCw, SearchX } from "lucide-react"; +import { formatBytes, type SemanticHit } from "@/lib/api/cloud"; +import { FileIconTile } from "./file-icon"; +import { formatSimilarity } from "./helpers"; + +interface SemanticResultsProps { + hits: SemanticHit[]; + loading: boolean; + error: string | null; + /** 检索范围是否限定在当前文件夹 */ + onlyFolder: boolean; + onRetry: () => void; + onOpen: (uuid: string) => void; +} + +export function SemanticResults({ + hits, + loading, + error, + onlyFolder, + onRetry, + onOpen, +}: SemanticResultsProps) { + if (loading) { + return ( +
+
+ ); + } + + if (error) { + return ( +
+ + +

+ 语义检索暂不可用 +

+

{error}

+ +
+ ); + } + + if (hits.length === 0) { + return ( +
+ + +

没有语义相关的文件

+

+ 换个问法,或切换为文件名搜索试试。 +

+
+ ); + } + + return ( +
+

+ 语义检索 · {hits.length} 个结果 + {onlyFolder ? "(仅当前文件夹)" : ""} +

+
    + {hits.map((hit) => { + const name = hit.title || hit.originalName; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/frontendv2/components/cloud-drive/status-badges.tsx b/frontendv2/components/cloud-drive/status-badges.tsx index b1efe48c..2046d055 100644 --- a/frontendv2/components/cloud-drive/status-badges.tsx +++ b/frontendv2/components/cloud-drive/status-badges.tsx @@ -22,6 +22,8 @@ const ASR_META: Record = { processing: { label: "转写中", dotClass: "bg-warning animate-pulse", textClass: "text-warning" }, done: { label: "已转写", dotClass: "bg-success", textClass: "text-success" }, failed: { label: "失败", dotClass: "bg-danger", textClass: "text-danger" }, + // 非音视频文件没有转写阶段(服务端写 not_supported) + not_supported: { label: "无需转写", dotClass: "bg-tertiary", textClass: "text-tertiary" }, }; const VECTOR_META: Record = { diff --git a/frontendv2/lib/api/chat.ts b/frontendv2/lib/api/chat.ts index c1ff7ff7..de17a0b3 100644 --- a/frontendv2/lib/api/chat.ts +++ b/frontendv2/lib/api/chat.ts @@ -30,7 +30,15 @@ export interface ChatMessage { role: "user" | "assistant" | "system"; content: string; status: "pending" | "completed" | "failed"; - sources?: Array<{ bvid: string; title: string; url?: string }>; + sources?: Array<{ + /** 云盘文件引用没有 bvid;两种引用形状共用这个来源结构 */ + bvid?: string; + title: string; + url?: string; + /** 云盘文件引用(app-cloud upload_uuid;历史字段可能是 snake_case) */ + upload_uuid?: string; + uploadUuid?: string; + }>; /** attached images: presigned MinIO URLs (durable) or legacy inline data URLs */ images?: string[]; // Binary outputs (e.g. run_code images) persisted with the message; @@ -97,6 +105,9 @@ export interface ChatRequestPayload { board_uuid?: string; // 板聊:请求限定在某块板子上(后端直路由 board agent) board_anchor_text?: string; // 板聊:画布当前选中节点纯文本(作为建议锚点上下文) academic_project_id?: string; // 科研项目上下文:后端解析项目并注入受控项目 section(/research → /chat) + // 检索范围:uploadUuids 非空时后端只在这些云盘文件内检索/回答 + //(/chat?upload_uuid= 进入文件级问答)。 + scope?: { uploadUuids?: string[] }; } export const chatApi = { diff --git a/frontendv2/lib/api/cloud.ts b/frontendv2/lib/api/cloud.ts index f75b4763..fe443504 100644 --- a/frontendv2/lib/api/cloud.ts +++ b/frontendv2/lib/api/cloud.ts @@ -136,6 +136,11 @@ export interface CloudVideoProcessResponse { vectorTaskId?: string | null; } +export interface CloudVideoReprocessResponse { + uploadUuid: string; + taskId?: string; +} + export interface CloudVideoStatusResponse { asrStatus: string; asrProgress: number; @@ -176,6 +181,58 @@ export interface CloudRawFileResponse { viewMode: CloudViewMode; } +// ── AI 摘要 / 语义检索 / 相似文件(app-cloud 智能能力)── + +/** + * 文件 AI 摘要正文。字段全部为可选语义的展示字段: + * topics/entities 是 AI 标签,keyPoints 是关键点,todos 是可执行待办, + * answersQuestions 是该文件能够回答的问题。 + */ +export interface CloudSummary { + overview: string; + topics: string[]; + entities: string[]; + keyPoints: string[]; + todos: string[]; + answersQuestions: string[]; + language: string; + sizeChars: number; +} + +/** + * 单个文件的摘要可用性。 + * available=false(summary=null)表示尚未生成或生成失败; + * stale=true 表示内容已变化(如重新上传/重新入库后),摘要可能过时。 + */ +export interface CloudSummaryResponse { + uploadUuid: string; + available: boolean; + stale: boolean; + model: string | null; + summary: CloudSummary | null; +} + +/** 语义/混合检索的一条命中:文件元数据 + 相似度 + 命中片段。 */ +export interface SemanticHit { + uploadUuid: string; + originalName: string; + title: string | null; + mimeType: string; + fileSize: number; + score: number; + snippets: string[]; +} + +export interface SemanticSearchResponse { + mode: "semantic"; + results: SemanticHit[]; + total: number; +} + +export interface SimilarResponse { + items: SemanticHit[]; +} + // ── New (app-cloud Go service): quota / search / trash / share ── export interface CloudQuotaResponse { @@ -445,6 +502,12 @@ export const cloudApi = { headers: getAuthHeaders(), }), + reprocessVideo: (uploadUuid: string) => + request(`/cloud/video/${uploadUuid}/reprocess`, { + method: "POST", + headers: getAuthHeaders(), + }), + getVideoStatus: (uploadUuid: string) => request(`/cloud/video/${uploadUuid}/status`, { headers: getAuthHeaders(), @@ -489,6 +552,40 @@ export const cloudApi = { return snakeToCamel<{ results: CloudVideoItem[]; total: number }>(raw); }, + // ── AI 摘要 / 语义检索 / 相似文件 ── + /** 单文件 AI 摘要(无摘要时 available=false,不报错)。 */ + getSummary: async (uploadUuid: string) => { + const raw = await request( + `/cloud/video/${uploadUuid}/summary`, + { headers: getAuthHeaders() }, + ); + return snakeToCamel(raw); + }, + + /** + * 语义/混合检索。folderId 有值时只在该文件夹(含子文件夹)内检索; + * 语义后端不可用时返回 503(调用方降级为文件名搜索)。 + */ + searchSemantic: async (params: { q: string; folderId?: number | null; limit?: number }) => { + const qs = new URLSearchParams({ q: params.q }); + if (params.folderId != null) qs.set("folderId", String(params.folderId)); + if (params.limit != null) qs.set("limit", String(params.limit)); + const raw = await request( + `/cloud/search/semantic?${qs.toString()}`, + { headers: getAuthHeaders() }, + ); + return snakeToCamel(raw); + }, + + /** 与指定文件内容相似的其他文件(排除自身)。 */ + getSimilar: async (uploadUuid: string, limit = 5) => { + const raw = await request( + `/cloud/video/${uploadUuid}/similar?limit=${limit}`, + { headers: getAuthHeaders() }, + ); + return snakeToCamel(raw); + }, + listTrash: async () => { const raw = await request("/cloud/trash", { headers: getAuthHeaders() }); return snakeToCamel(raw); diff --git a/frontendv2/lib/chat-stream.ts b/frontendv2/lib/chat-stream.ts index c04df482..67a7db0f 100644 --- a/frontendv2/lib/chat-stream.ts +++ b/frontendv2/lib/chat-stream.ts @@ -7,6 +7,9 @@ export interface ChatSource { title: string; url?: string; bvid?: string; + // 云盘文件引用(app-cloud upload_uuid)。有值时按站内深链处理: + // 点击进入 /cloud-drive/,不生成 bilibili 外链。 + uploadUuid?: string; } // File-style artifact (image / downloadable file) produced by a sub-agent or a From 48d3d260a1e44636a8534b2bdaf6a161ae8105f0 Mon Sep 17 00:00:00 2001 From: atoncooper Date: Thu, 5 Nov 2026 17:47:41 +0800 Subject: [PATCH 2/2] fix(frontend): use webpack build to avoid Next 16 Turbopack font bug --- frontendv2/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontendv2/package.json b/frontendv2/package.json index c352542f..1173bd6b 100644 --- a/frontendv2/package.json +++ b/frontendv2/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "eslint", "prebuild": "node scripts/copy-smm-umd.mjs"