merge: fix/fix-latest-upload-summary-2026-09-23 (latest-upload summary + parse wait)
This commit is contained in:
@@ -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「修复「总结我刚刚上传的文档」误报」
|
||||
@@ -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。
|
||||
@@ -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
|
||||
|
||||
@@ -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: "逆压电效应是指对压电陶瓷施加交变电场后产生机械振动;变幅杆通过截面变化实现阻抗匹配与振幅放大。",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -146,6 +146,8 @@ const (
|
||||
|
||||
// 法定文档治理与权限槽位
|
||||
SlotTargetDoc = "target_doc"
|
||||
// TargetDocLatestUpload 是「刚刚/最近上传」指代的哨兵目标,由服务端解析为项目最新 CAS 资产。
|
||||
TargetDocLatestUpload = "@latest_upload"
|
||||
SlotRejectReason = "reject_reason"
|
||||
SlotTargetSection = "target_section"
|
||||
SlotProposalAction = "proposal_action"
|
||||
|
||||
@@ -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{
|
||||
"能做什么", "可以做什么", "有什么功能", "怎么用", "如何使用",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, /解析中/);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ export const PHASE_META: Record<string, { icon: string; label: string }> = {
|
||||
stage_completed: { icon: '🎉', label: '阶段完成' },
|
||||
step_failed: { icon: '⚠️', label: '步骤异常' },
|
||||
task_failed: { icon: '⚠️', label: '任务异常' },
|
||||
document_parsing: { icon: '📄', label: '文档解析' },
|
||||
document_summarizing: { icon: '📝', label: '正文总结' },
|
||||
};
|
||||
|
||||
/** 收口型 phase:不追加新节点,仅把当前 running 条目置为 completed */
|
||||
|
||||
Reference in New Issue
Block a user