diff --git a/.doc/ops/short-term/2026-09-23-latest-upload-doc-summary.md b/.doc/ops/short-term/2026-09-23-latest-upload-doc-summary.md new file mode 100644 index 00000000..cc8094a3 --- /dev/null +++ b/.doc/ops/short-term/2026-09-23-latest-upload-doc-summary.md @@ -0,0 +1,73 @@ +--- +title: "修复「总结我刚刚上传的文档」误报未找到" +task_id: "fix-latest-upload-summary-2026-09-23" +status: done +created: 2026-09-23 +updated: 2026-09-23 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @ 2ce8688db4f49fdd116799b9d36ef7ea802afc33" +branch: feature/fix-latest-upload-summary-2026-09-23 +worktree: .worktrees/fix-latest-upload-summary-2026-09-23 +base_dev_sha: "2ce8688db4f49fdd116799b9d36ef7ea802afc33" +write_scope: + - internal/intent + - internal/server + - internal/coordinator + - .doc/ops + - .doc/test-reports +allowed_environments: [] +head_sha: "a3b5d4291b82c1483e5405e34d429bda4548e6f6" +mutex: "intent+chat-doc-summary" +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: 独立维护者 +cleanup_trigger: tip 为 local_dev 祖先且无进程后 remove +remote_sync: not_requested +--- + +# 修复「总结我刚刚上传的文档」误报未找到 + +## 用户目标与授权 + +- 用户目标:test 环境上传文档后说「总结一下我刚刚上传的这个文档」,助手误报「未找到可访问的目标文档」。 +- 授权来源:本会话计划确认(诊断 + 实施;支持最近上传附件 + MinerU 正文)。 +- 交付终点:feature 分支实现与单测通过;dev 集成/test 部署另授。 +- 禁止项:不 push;不操作 test/main 环境;不改 freeform/CAS 台账边界。 + +## 范围与非目标 + +- 允许写入:意图识别上传指代、`DocumentSummaryReader` 接入最新 CAS 资产、协调层解析状态话术、相关测试与本任务文。 +- 非目标:点名文件名总结、MinerU 密钥修复、dev 合入、test 部署。 + +## 步骤与验收 + +1. [x] 创建 worktree 并登记任务 +2. [x] 意图层 `@latest_upload` 哨兵 +3. [x] 服务端最新上传资产 + EnsureParsed + 状态错误 +4. [x] 协调层 typed 错误话术 +5. [x] 单测 + arch-check + go vet + go test(聚焦) + +## 验证与证据 + +| 范围 / 被测 SHA | 命令与环境 | 预期 | 实际 / 判定 | 证据 / 脱敏说明 | +|---|---|---|---|---| +| intent + coordinator | `go test ./internal/intent/ ./internal/coordinator/ -count=1` | PASS | PASS(含 LatestUpload / UploadParseState / NoUploadAsset) | 本机 worktree | +| server 聚焦 | `go test ./internal/server/ -run 'SelectReadable\|CrossWorkflow\|PickLatest\|ResolveLatest\|UploadProject'` | PASS | PASS | 本机 worktree;全文 `./internal/server` 会跑无关 `TestPiAgent_*` E2E(缺 KB 钥 FAIL,非本任务范围) | +| 架构 | `./scripts/arch-check.sh --mode block` | PASS | PASS | production gate passed | +| vet | `go vet ./internal/intent/... ./internal/coordinator/... ./internal/server/...` | PASS | PASS | | + +## 收口与交接 + +- 剩余工作:待独立集成认领合入 `dev`;合入后 test 部署另授才能在用户复现场景验证。 +- 本地提交:见本分支 tip;dev 集成:pending;部署:not_required(本任务);远端:not_requested。 +- cleanup:owner 退出后由独立维护者复查 tip 为 local_dev 祖先后 remove。 +- PDF/Office 若仍为 MinerU `pending/failed`,助手会明确提示解析中/失败,不再误报「未找到」。 + +## 相关链接 + +- CURRENT 认领行:`fix-latest-upload-summary-2026-09-23` +- 计划:会话附着 plan「修复「总结我刚刚上传的文档」误报」 diff --git a/.doc/ops/short-term/2026-09-23-latest-upload-parse-wait.md b/.doc/ops/short-term/2026-09-23-latest-upload-parse-wait.md new file mode 100644 index 00000000..9868652a --- /dev/null +++ b/.doc/ops/short-term/2026-09-23-latest-upload-parse-wait.md @@ -0,0 +1,55 @@ +--- +title: "上传文档解析中可见进度与同轮等待" +task_id: "fix-latest-upload-parse-wait-2026-09-23" +parent_task_id: "fix-latest-upload-summary-2026-09-23" +status: done +created: 2026-09-23 +updated: 2026-09-23 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @ 2ce8688db4f49fdd116799b9d36ef7ea802afc33" +branch: feature/fix-latest-upload-summary-2026-09-23 +worktree: .worktrees/fix-latest-upload-summary-2026-09-23 +base_dev_sha: "2ce8688db4f49fdd116799b9d36ef7ea802afc33" +write_scope: + - internal/server + - internal/coordinator + - internal/materialindex + - web/src + - .doc/ops +allowed_environments: [] +head_sha: "1f37f90b4b8eabc4d8eddd70de23f080bf842502" +mutex: "intent+chat-doc-summary" +review_state: pending +integration_state: pending +deployment: not_required +cleanup: pending +cleanup_owner: 独立维护者 +cleanup_trigger: tip 为 local_dev 祖先且无进程后 remove +remote_sync: not_requested +--- + +# 上传文档解析中可见进度与同轮等待 + +父任务:[2026-09-23-latest-upload-doc-summary.md](./2026-09-23-latest-upload-doc-summary.md)。本追问:解析中发 `document_parsing` phase,同轮等待并锁定发送。 + +## 改动摘要 + +- `materialindex.ParseWaitLimit()`:暴露等待上限。 +- `waitForUploadParse`:同轮每 2s 轮询并 `EmitPhase("document_parsing", …)`;超时 `slow`;失败/完成分派。 +- `PHASE_META`:`document_parsing` / `document_summarizing`。 +- 前端发送锁:复用既有 `isStreamingActive`(phase → streaming_status → 禁发)。 + +## 验证与证据 + +| 范围 | 命令 | 判定 | +|---|---|---| +| server | `go test ./internal/server/ -run 'ResolveLatest\|WaitForUploadParse\|PickLatest\|SelectReadable\|CrossWorkflow'` | PASS | +| vet | `go vet ./internal/materialindex/... ./internal/server/... ./internal/coordinator/...` | PASS | +| arch | `./scripts/arch-check.sh --mode block` | PASS | +| web | `npx tsx --test src/engine/phaseEventMapper.test.ts` | PASS(16) | + +## 收口 + +- 本地提交见本分支 tip;dev 集成 / 部署另授;不 push。 diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 3bf4c892..f51c7fd6 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -120,6 +120,69 @@ func DocViewDeniedGuidance(err error) string { return "" } +// ErrNoUploadAsset 表示项目内尚无可总结的上传附件。 +type ErrNoUploadAsset struct { + Message string +} + +func (e *ErrNoUploadAsset) Error() string { + if e == nil || strings.TrimSpace(e.Message) == "" { + return "no upload asset in project" + } + return e.Message +} + +// NoUploadAssetGuidance 返回无上传附件时的话术。 +func NoUploadAssetGuidance(err error) string { + var target *ErrNoUploadAsset + if errors.As(err, &target) && target != nil { + if msg := strings.TrimSpace(target.Message); msg != "" { + return msg + } + return "当前项目还没有上传过附件。请先在对话中上传文件后再试总结。" + } + return "" +} + +// ErrUploadParseState 表示最近上传附件仍在解析中或解析失败,尚无可总结正文。 +type ErrUploadParseState struct { + State string + Filename string + Message string +} + +func (e *ErrUploadParseState) Error() string { + if e == nil { + return "upload parse state unavailable" + } + if msg := strings.TrimSpace(e.Message); msg != "" { + return msg + } + name := strings.TrimSpace(e.Filename) + if name == "" { + name = "该附件" + } + switch strings.TrimSpace(e.State) { + case "pending", "running": + return fmt.Sprintf("《%s》仍在解析中,请稍后再试总结。", name) + case "failed": + return fmt.Sprintf("《%s》解析失败。可在附件卡片点击重新解析后再试总结。", name) + case "slow": + return fmt.Sprintf("《%s》解析耗时较长(仍在后台进行),请稍后再让我总结。", name) + default: + return fmt.Sprintf("《%s》暂时无法读取正文,请稍后再试。", name) + } +} + +// UploadParseStateGuidance 返回上传解析状态提示话术。 +func UploadParseStateGuidance(err error) string { + var target *ErrUploadParseState + if errors.As(err, &target) && target != nil { + return target.Error() + } + return "" +} + // UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费) type UserProjectContext struct { mu sync.Mutex diff --git a/internal/coordinator/dialogue_llm_test.go b/internal/coordinator/dialogue_llm_test.go index 51255550..ac714dca 100644 --- a/internal/coordinator/dialogue_llm_test.go +++ b/internal/coordinator/dialogue_llm_test.go @@ -122,6 +122,66 @@ func TestDialogueLLM_DocumentSummaryTreatsBodyAsData(t *testing.T) { } } +func TestDocumentSummary_UploadParseStateGuidance(t *testing.T) { + engine := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{ + DocumentSummaryReader: func(_ context.Context, target string) (*ReadableDocument, error) { + if target != intent.TargetDocLatestUpload { + t.Fatalf("target=%q", target) + } + return nil, &ErrUploadParseState{State: "pending", Filename: "冲击波说明书.pdf"} + }, + } + rec := &intent.IntentRecognitionResult{ + RawInput: "总结一下我刚刚上传的这个文档", + Branch: intent.BranchNewIntentPreempt, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentQueryDocStatus, + FilledSlots: map[string]string{ + intent.SlotQueryTopic: "doc_summary", + intent.SlotTargetDoc: intent.TargetDocLatestUpload, + }, + }, + } + res, err := engine.ProcessIntent(context.Background(), rec, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if !strings.Contains(res.NaturalReply, "解析中") { + t.Fatalf("want pending guidance, got %q", res.NaturalReply) + } + if strings.Contains(res.NaturalReply, "未找到可访问的目标文档") { + t.Fatalf("must not fall back to missing-doc template: %q", res.NaturalReply) + } +} + +func TestDocumentSummary_NoUploadAssetGuidance(t *testing.T) { + engine := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{ + DocumentSummaryReader: func(_ context.Context, _ string) (*ReadableDocument, error) { + return nil, &ErrNoUploadAsset{} + }, + } + rec := &intent.IntentRecognitionResult{ + RawInput: "总结一下我刚刚上传的这个文档", + Branch: intent.BranchNewIntentPreempt, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentQueryDocStatus, + FilledSlots: map[string]string{ + intent.SlotQueryTopic: "doc_summary", + intent.SlotTargetDoc: intent.TargetDocLatestUpload, + }, + }, + } + res, err := engine.ProcessIntent(context.Background(), rec, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if !strings.Contains(res.NaturalReply, "还没有上传") { + t.Fatalf("want no-upload guidance, got %q", res.NaturalReply) + } +} + func TestDialogueLLM_AmbiguousFallbackUsesBackend(t *testing.T) { backend := &scriptedDialogueBackend{ reply: "逆压电效应是指对压电陶瓷施加交变电场后产生机械振动;变幅杆通过截面变化实现阻抗匹配与振幅放大。", diff --git a/internal/coordinator/document_summary.go b/internal/coordinator/document_summary.go index 3ea421fe..86570156 100644 --- a/internal/coordinator/document_summary.go +++ b/internal/coordinator/document_summary.go @@ -34,6 +34,12 @@ func (e *CoordinatorEngine) handleDocumentSummary( if guidance := DocViewDeniedGuidance(err); guidance != "" { return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil } + if guidance := UploadParseStateGuidance(err); guidance != "" { + return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil + } + if guidance := NoUploadAssetGuidance(err); guidance != "" { + return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil + } if owner := strings.TrimSpace(cand.FilledSlots[intent.SlotCrossWorkflowOwner]); owner != "" { active := "" if userCtx != nil { diff --git a/internal/intent/types.go b/internal/intent/types.go index 57491da4..b493c869 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -146,6 +146,8 @@ const ( // 法定文档治理与权限槽位 SlotTargetDoc = "target_doc" + // TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。 + TargetDocLatestUpload = "@latest_upload" SlotRejectReason = "reject_reason" SlotTargetSection = "target_section" SlotProposalAction = "proposal_action" diff --git a/internal/intent/workflow_matcher.go b/internal/intent/workflow_matcher.go index 32daa7c0..16cc8051 100644 --- a/internal/intent/workflow_matcher.go +++ b/internal/intent/workflow_matcher.go @@ -191,6 +191,17 @@ func matchQueryDocStatus(input string, snap *ContextSnapshot) *CandidateIntent { } } if isSummary { + if isLatestUploadReference(input) { + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.97, + FilledSlots: map[string]string{ + SlotQueryTopic: "doc_summary", + SlotTargetDoc: TargetDocLatestUpload, + }, + Reasoning: "用户请求只读总结刚刚上传的附件正文", + } + } target := extractDocName(input) if target != "" || strings.Contains(input, "文档") || strings.Contains(input, "报告") { slots := map[string]string{SlotQueryTopic: "doc_summary"} @@ -241,6 +252,31 @@ func matchQueryDocStatus(input string, snap *ContextSnapshot) *CandidateIntent { } } +// isLatestUploadReference 识别「刚刚/最近上传」类时间指代(只读总结目标)。 +func isLatestUploadReference(input string) bool { + input = strings.TrimSpace(input) + if input == "" { + return false + } + hasUploadNoun := strings.Contains(input, "文档") || + strings.Contains(input, "文件") || + strings.Contains(input, "附件") || + strings.Contains(input, "材料") + if !hasUploadNoun { + return false + } + markers := []string{ + "刚刚上传", "刚上传", "最近上传", "刚才上传", + "我上传的", "上传的这个", "上传的那个", "上传的文档", "上传的文件", "上传的附件", + } + for _, m := range markers { + if strings.Contains(input, m) { + return true + } + } + return false +} + func matchHelpCapabilities(input string) *CandidateIntent { helpMarkers := []string{ "能做什么", "可以做什么", "有什么功能", "怎么用", "如何使用", diff --git a/internal/intent/workflow_matcher_test.go b/internal/intent/workflow_matcher_test.go index 41b50173..02210e0e 100644 --- a/internal/intent/workflow_matcher_test.go +++ b/internal/intent/workflow_matcher_test.go @@ -56,6 +56,35 @@ func TestWorkflowMatcher_DocumentSummaryIsReadonly(t *testing.T) { } } +func TestWorkflowMatcher_LatestUploadSummarySentinel(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "总结一下我刚刚上传的这个文档", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent != intent.IntentQueryDocStatus { + t.Fatalf("expected QUERY_DOC_STATUS, got %s", res.TopIntent.Intent) + } + if got := res.TopIntent.FilledSlots[intent.SlotQueryTopic]; got != "doc_summary" { + t.Fatalf("query_topic=%q want doc_summary", got) + } + if got := res.TopIntent.FilledSlots[intent.SlotTargetDoc]; got != intent.TargetDocLatestUpload { + t.Fatalf("target_doc=%q want %q", got, intent.TargetDocLatestUpload) + } +} + +func TestWorkflowMatcher_LatestUploadWithWriteVerbNotSummary(t *testing.T) { + rec := intent.NewRecognizer() + res, err := rec.Recognize(context.Background(), "根据我刚刚上传的文档生成调研报告", nil) + if err != nil { + t.Fatalf("recognize: %v", err) + } + if res.TopIntent.Intent == intent.IntentQueryDocStatus && + res.TopIntent.FilledSlots[intent.SlotQueryTopic] == "doc_summary" { + t.Fatalf("write+upload must not be classified as doc_summary, got %+v", res.TopIntent) + } +} + func TestWorkflowMatcher_HelpNotCasualChat(t *testing.T) { rec := intent.NewRecognizer() res, err := rec.Recognize(context.Background(), "这个系统能做什么?", nil) diff --git a/internal/materialindex/pipeline.go b/internal/materialindex/pipeline.go index 1fbcdad8..aa7d34bb 100644 --- a/internal/materialindex/pipeline.go +++ b/internal/materialindex/pipeline.go @@ -147,6 +147,14 @@ func (p *Pipeline) RAGTopK() int { return p.ragTopK } +// ParseWaitLimit returns the bounded wait for pending/running MinerU parses. +func (p *Pipeline) ParseWaitLimit() time.Duration { + if p == nil || p.waitLimit <= 0 { + return defaultWait + } + return p.waitLimit +} + // GetParseState returns parse state for a blob (empty string if unknown). func (p *Pipeline) GetParseState(ctx context.Context, blobSHA string) string { rec, err := p.getRecord(ctx, blobSHA) diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index 932d95e4..b1a7c1a1 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -3,15 +3,21 @@ package server import ( "context" "fmt" + "path/filepath" "strings" + "time" "unicode" "chat-agent-system/internal/auth" + "chat-agent-system/internal/bizdev" "chat-agent-system/internal/chat" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/storage" + "chat-agent-system/internal/stream" "chat-agent-system/internal/workflowscope" ) @@ -38,6 +44,9 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil { return nil, err } + if strings.TrimSpace(target) == intent.TargetDocLatestUpload { + return s.resolveLatestUploadDocument(ctx, projectID) + } listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) if err != nil { return nil, err @@ -88,6 +97,209 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth } } +// resolveLatestUploadDocument 读取项目内最新上传的 CAS 资产正文(供 @latest_upload 哨兵)。 +func (s *Server) resolveLatestUploadDocument(ctx context.Context, projectID int64) (*coordinator.ReadableDocument, error) { + if s == nil || s.cas == nil { + return nil, &coordinator.ErrNoUploadAsset{ + Message: "当前项目还没有上传过附件。请先在对话中上传文件后再试总结。", + } + } + refs, err := s.cas.ListProjectRefs(ctx, projectID) + if err != nil { + return nil, err + } + latest := pickLatestFileReference(refs) + if latest == nil { + return nil, &coordinator.ErrNoUploadAsset{ + Message: "当前项目还没有上传过附件。请先在对话中上传文件后再试总结。", + } + } + + matIdx := bizdev.Snapshot(s.svc).MaterialIndex + state := "" + if matIdx != nil { + state = strings.TrimSpace(matIdx.GetParseState(ctx, latest.BlobSHA256)) + } + switch state { + case "failed": + return nil, &coordinator.ErrUploadParseState{ + State: state, + Filename: latest.FileName, + } + case "pending", "running": + limit := 90 * time.Second + if s.cfg != nil && s.cfg.MineruTimeout > 0 { + limit = s.cfg.MineruTimeout + } + if matIdx != nil { + if w := matIdx.ParseWaitLimit(); w > 0 { + limit = w + } + } + finalState := waitForUploadParse(ctx, func() string { + if matIdx == nil { + return "" + } + return strings.TrimSpace(matIdx.GetParseState(ctx, latest.BlobSHA256)) + }, latest.FileName, limit) + switch finalState { + case "done": + // continue to load content + case "failed": + return nil, &coordinator.ErrUploadParseState{ + State: "failed", + Filename: latest.FileName, + } + case "slow", "pending", "running": + return nil, &coordinator.ErrUploadParseState{ + State: "slow", + Filename: latest.FileName, + Message: fmt.Sprintf( + "《%s》解析耗时较长(仍在后台进行),请稍后再让我总结。", + defaultUploadTitle(latest.FileName), + ), + } + default: + if ctx.Err() != nil { + return nil, ctx.Err() + } + // unknown / empty after wait: fall through and try EnsureParsed + } + } + + data, ref, err := s.cas.ReadRefBytes(ctx, latest.ID) + if err != nil || ref == nil { + return nil, fmt.Errorf("load latest upload asset: %w", err) + } + markdown := "" + if matIdx != nil { + md, _, parseErr := matIdx.EnsureParsed(ctx, ref.FileName, ref.BlobSHA256, data) + if parseErr != nil { + return nil, parseErr + } + markdown = strings.TrimSpace(md) + } else { + md, extractErr := squad.ExtractTextWithAnchors(ref.FileName, data) + if extractErr != nil { + return nil, extractErr + } + markdown = strings.TrimSpace(md) + } + if markdown == "" { + return nil, &coordinator.ErrUploadParseState{ + State: "empty", + Filename: ref.FileName, + Message: fmt.Sprintf("《%s》暂无可提取的正文,无法总结。", defaultUploadTitle(ref.FileName)), + } + } + + title := defaultUploadTitle(ref.FileName) + return &coordinator.ReadableDocument{ + DocID: fmt.Sprintf("asset:%d", ref.ID), + Title: title, + TaskKey: "uploaded_material", + LifecycleState: "uploaded", + Version: 1, + Markdown: markdown, + IsTemplateOnly: false, + }, nil +} + +var uploadParsePollInterval = 2 * time.Second + +// waitForUploadParse 在同轮对话内有界等待 MinerU 解析,并周期性发 document_parsing phase。 +// stateFn 返回当前解析状态;终态 "done"/"failed" 立即返回;超时返回 "slow"。 +func waitForUploadParse(ctx context.Context, stateFn func() string, fileName string, limit time.Duration) string { + if limit <= 0 { + limit = 90 * time.Second + } + title := defaultUploadTitle(fileName) + started := time.Now() + emitUploadParsePhase(ctx, title, 0, 5) + + ticker := time.NewTicker(uploadParsePollInterval) + defer ticker.Stop() + deadline := time.NewTimer(limit) + defer deadline.Stop() + + for { + state := "" + if stateFn != nil { + state = strings.TrimSpace(stateFn()) + } + switch state { + case "done", "failed": + return state + } + + select { + case <-ctx.Done(): + return state + case <-deadline.C: + final := "" + if stateFn != nil { + final = strings.TrimSpace(stateFn()) + } + if final == "done" || final == "failed" { + return final + } + return "slow" + case <-ticker.C: + elapsed := time.Since(started) + progress := int(elapsed.Seconds() / limit.Seconds() * 100) + if progress > 95 { + progress = 95 + } + if progress < 5 { + progress = 5 + } + emitUploadParsePhase(ctx, title, int(elapsed.Seconds()), progress) + } + } +} + +func emitUploadParsePhase(ctx context.Context, title string, elapsedSec, progress int) { + st := stream.FromContext(ctx) + if st == nil { + return + } + msg := fmt.Sprintf("《%s》已排队解析,正在等待 MinerU…", title) + if elapsedSec > 0 { + msg = fmt.Sprintf("《%s》正在解析中…已等待 %ds", title, elapsedSec) + } + st.EmitPhase("document_parsing", msg, "assistant", 0, 0, progress) +} + +func pickLatestFileReference(refs []*storage.FileReference) *storage.FileReference { + var latest *storage.FileReference + for _, ref := range refs { + if ref == nil { + continue + } + if latest == nil { + latest = ref + continue + } + if ref.CreatedAt.After(latest.CreatedAt) || + (ref.CreatedAt.Equal(latest.CreatedAt) && ref.ID > latest.ID) { + latest = ref + } + } + return latest +} + +func defaultUploadTitle(fileName string) string { + base := filepath.Base(strings.TrimSpace(fileName)) + if base == "" || base == "." { + return "上传附件" + } + stem := strings.TrimSuffix(base, filepath.Ext(base)) + if strings.TrimSpace(stem) == "" { + return base + } + return stem +} + // activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。 func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) { if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 { diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index c277d0d1..85368d41 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -1,13 +1,20 @@ package server import ( + "context" "errors" "strings" + "sync/atomic" "testing" + "time" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/storage" + "chat-agent-system/internal/stream" "chat-agent-system/internal/workflowscope" ) @@ -119,3 +126,137 @@ func TestCrossWorkflowTaskVisible(t *testing.T) { t.Fatal("unrestricted access must allow") } } + +func TestPickLatestFileReference(t *testing.T) { + older := &storage.FileReference{ID: 1, FileName: "a.txt", CreatedAt: time.Unix(100, 0)} + newer := &storage.FileReference{ID: 2, FileName: "b.txt", CreatedAt: time.Unix(200, 0)} + got := pickLatestFileReference([]*storage.FileReference{older, newer, nil}) + if got == nil || got.ID != 2 { + t.Fatalf("want id=2, got %+v", got) + } + sameTimeHigherID := &storage.FileReference{ID: 9, FileName: "c.txt", CreatedAt: time.Unix(200, 0)} + got = pickLatestFileReference([]*storage.FileReference{newer, sameTimeHigherID}) + if got == nil || got.ID != 9 { + t.Fatalf("same created_at should prefer higher id, got %+v", got) + } +} + +func TestResolveLatestUploadDocument_TextAsset(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + if srv.cas == nil { + t.Fatal("expected CAS") + } + projectID := int64(42) + userID := int64(7) + _, err := srv.cas.StoreFile(context.Background(), projectID, userID, "shockwave.txt", "material", + []byte("冲击波治疗仪核心适应症:软组织损伤与疼痛管理。\n关键约束:治疗强度需医师确认。"), "text/plain") + if err != nil { + t.Fatalf("store: %v", err) + } + + doc, err := srv.resolveLatestUploadDocument(context.Background(), projectID) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if doc == nil || !strings.Contains(doc.Markdown, "冲击波治疗仪") { + t.Fatalf("unexpected doc: %+v", doc) + } + if doc.Title != "shockwave" { + t.Fatalf("title=%q", doc.Title) + } + if doc.LifecycleState != "uploaded" { + t.Fatalf("lifecycle=%q", doc.LifecycleState) + } +} + +func TestResolveLatestUploadDocument_NoAsset(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + _, err := srv.resolveLatestUploadDocument(context.Background(), 99) + if err == nil { + t.Fatal("expected ErrNoUploadAsset") + } + var noAsset *coordinator.ErrNoUploadAsset + if !errors.As(err, &noAsset) { + t.Fatalf("want ErrNoUploadAsset, got %T %v", err, err) + } +} + +func TestWaitForUploadParse_PendingThenDone(t *testing.T) { + prev := uploadParsePollInterval + uploadParsePollInterval = 15 * time.Millisecond + t.Cleanup(func() { uploadParsePollInterval = prev }) + + var calls atomic.Int32 + stateFn := func() string { + n := calls.Add(1) + if n >= 3 { + return "done" + } + return "pending" + } + + var phases []string + ctx, st := stream.WithListener(context.Background(), false, func(ev stream.StreamEvent) { + if ev.Type == stream.EventPhase && ev.Phase != nil { + phases = append(phases, ev.Phase.PhaseID+":"+ev.Phase.Message) + } + }) + _ = st + + got := waitForUploadParse(ctx, stateFn, "冲击波说明书.pdf", 2*time.Second) + if got != "done" { + t.Fatalf("got %q want done", got) + } + if calls.Load() < 3 { + t.Fatalf("expected multiple polls, calls=%d", calls.Load()) + } + if len(phases) == 0 { + t.Fatal("expected document_parsing phase events") + } + if !strings.Contains(phases[0], "document_parsing:") { + t.Fatalf("first phase=%q", phases[0]) + } + if !strings.Contains(phases[0], "MinerU") && !strings.Contains(phases[0], "解析") { + t.Fatalf("initial phase should mention parse wait: %q", phases[0]) + } +} + +func TestWaitForUploadParse_TimeoutSlow(t *testing.T) { + prev := uploadParsePollInterval + uploadParsePollInterval = 10 * time.Millisecond + t.Cleanup(func() { uploadParsePollInterval = prev }) + + got := waitForUploadParse(context.Background(), func() string { return "pending" }, "spec.pdf", 40*time.Millisecond) + if got != "slow" { + t.Fatalf("got %q want slow", got) + } +} + +func TestWaitForUploadParse_ContextCancel(t *testing.T) { + prev := uploadParsePollInterval + uploadParsePollInterval = 50 * time.Millisecond + t.Cleanup(func() { uploadParsePollInterval = prev }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got := waitForUploadParse(ctx, func() string { return "running" }, "spec.pdf", time.Second) + if got != "running" && got != "" { + // cancelled before/at select: returns last observed state + t.Fatalf("got %q on cancel", got) + } +} + +func TestWaitForUploadParse_Failed(t *testing.T) { + prev := uploadParsePollInterval + uploadParsePollInterval = 10 * time.Millisecond + t.Cleanup(func() { uploadParsePollInterval = prev }) + + got := waitForUploadParse(context.Background(), func() string { return "failed" }, "spec.pdf", time.Second) + if got != "failed" { + t.Fatalf("got %q want failed", got) + } +} diff --git a/web/src/engine/phaseEventMapper.test.ts b/web/src/engine/phaseEventMapper.test.ts index 639461d8..491f50c4 100644 --- a/web/src/engine/phaseEventMapper.test.ts +++ b/web/src/engine/phaseEventMapper.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { applyPhaseEvent } from './phaseEventMapper'; +import { applyPhaseEvent, PHASE_META } from './phaseEventMapper'; test('applyPhaseEvent: appends first phase as running node', () => { const strip = applyPhaseEvent([], { phase_id: 'squad_assembly', message: '组建小队' }); @@ -126,3 +126,17 @@ test('applyPhaseEvent: stores detail from phase.message', () => { const strip = applyPhaseEvent([], { phase_id: 'squad_assembly', message: '正在组建真实小队...' }); assert.equal(strip[0].detail, '正在组建真实小队...'); }); + +test('PHASE_META: document_parsing and document_summarizing are registered', () => { + assert.equal(PHASE_META.document_parsing.icon, '📄'); + assert.equal(PHASE_META.document_parsing.label, '文档解析'); + assert.equal(PHASE_META.document_summarizing.icon, '📝'); + assert.equal(PHASE_META.document_summarizing.label, '正文总结'); + const strip = applyPhaseEvent([], { + phase_id: 'document_parsing', + message: '《说明书》正在解析中…已等待 4s', + progress: 20, + }); + assert.equal(strip[0].icon, '📄'); + assert.match(strip[0].label, /解析中/); +}); diff --git a/web/src/engine/phaseEventMapper.ts b/web/src/engine/phaseEventMapper.ts index 341c7645..2bf5c611 100644 --- a/web/src/engine/phaseEventMapper.ts +++ b/web/src/engine/phaseEventMapper.ts @@ -24,6 +24,8 @@ export const PHASE_META: Record = { stage_completed: { icon: '🎉', label: '阶段完成' }, step_failed: { icon: '⚠️', label: '步骤异常' }, task_failed: { icon: '⚠️', label: '任务异常' }, + document_parsing: { icon: '📄', label: '文档解析' }, + document_summarizing: { icon: '📝', label: '正文总结' }, }; /** 收口型 phase:不追加新节点,仅把当前 running 条目置为 completed */