diff --git a/.doc/archive/ops/2026-09-21-current-history.md b/.doc/archive/ops/2026-09-21-current-history.md index 657160e1..4b963fc8 100644 --- a/.doc/archive/ops/2026-09-21-current-history.md +++ b/.doc/archive/ops/2026-09-21-current-history.md @@ -278,3 +278,9 @@ | UI-03 首次生成 PG ID 误删 baseline | [../audit/2026-09-13-document-revision-pg-id-remap.md](../../audit/2026-09-13-document-revision-pg-id-remap.md) | | UI-06 刷新后缓存竞争死锁审计 | [../audit/2026-09-14-document-revision-refresh-cache-race.md](../../audit/2026-09-14-document-revision-refresh-cache-race.md) | | 受控正文真实 UI 验收报告 | [../test-reports/2026-09-14-controlled-content-ui-acceptance.md](../../test-reports/2026-09-14-controlled-content-ui-acceptance.md) | + +## 并行集成补录 + +来源:dev@a5ea469c 的 CURRENT 增量。保留材料切块任务的新结论;活跃 CURRENT 保留其认领和最新槽位。 + +5. **材料切块不再看体积**:项目 6579 `doc_proj6579_bd_intake` 已确认候选 535,正式正文不再含「二进制压缩流」。成功解析的材料一律写入 `material_chunks`,≤60KB 仍全文注入。见 [报告](../../test-reports/2026-09-21-force-material-chunk-index-test-report.md)。已合入 `dev` 并从主目录 rebuild。test/main 未晋升。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 056f998f..4ff520c5 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| | Codex | 2026-09-21-agent-governance-implementation | feature/agent-governance-implementation | .worktrees/agent-governance-implementation | agent-governance | in_progress | +| Cursor | 2026-09-21-force-material-chunk-index | feature/force-material-chunk-index-2026-09-21 | .worktrees/force-material-chunk-index-2026-09-21 | bizdev+materialindex | done | | Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | | Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | done | | Cursor | 2026-09-21-mineru-doc-parsing | feature/mineru-doc-parsing-2026-09-21 | .worktrees/mineru-doc-parsing-2026-09-21 | storage+bizdev+kb | done | @@ -33,6 +34,7 @@ | 路径/事项 | 分类与保留原因 | cleanup_owner | 复查触发 | |---|---|---|---| +| `.worktrees/force-material-chunk-index-2026-09-21` | Cursor 已交付,保留原任务验收与工作区 | Cursor owner / 独立维护者 | owner 退出后核对祖先、文件、证据与进程 | | `.worktrees/agent-governance-implementation` | 活跃实施;cleanup=pending | Codex 之外的集成/维护执行者 | 验收、集成、证据归档完成且 owner 退出 | | `.worktrees/feature-agent-workflow-governance-plan` | 原 adoption 仍 in_progress;不得替 owner 改 done | 原 Codex owner / 维护者接管 | 核对原任务交付与进程后更新真实状态 | | Cline `2026-09-16-partial-deferred-completion` | 原 in_progress,worktree 未登记;保留待核验 | Cline owner / 维护者接管 | 查明主文档、分支与未提交现场 | @@ -52,7 +54,7 @@ cleanup=pending 不是任务状态;普通 remove/-d 拒绝时记录原因, | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | taskrun-status-card label 修复已合入 dev@4b303247 并真实 PI 验收(round2 docs 随后合入);mineru 二轮文档已合入;schema=61 | - | +| - | idle | 2026-09-21 | force-material-chunk-index 已合入 dev 并从主目录 rebuild;项目 6579 候选 535 已确认;schema=61 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-force-material-chunk-index.md b/.doc/ops/short-term/2026-09-21-force-material-chunk-index.md new file mode 100644 index 00000000..cf30e5fe --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-force-material-chunk-index.md @@ -0,0 +1,48 @@ +--- +title: "成功解析的材料一律切块入库" +status: done +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: feature/force-material-chunk-index-2026-09-21 +worktree: .worktrees/force-material-chunk-index-2026-09-21 +base_dev_sha: a241e838150f218f3a09d3523f93291b847d45fd +write_scope: [internal/bizdev/materials.go, internal/bizdev/materials_index_test.go, internal/materialindex/pipeline.go, .doc/ops/CURRENT.md, .doc/test-reports/] +head_sha: +deployment: passed +cleanup: pending +mutex: bizdev+materialindex +--- + +# 成功解析的材料一律切块入库 + +## 目标 + +项目 6579 的 `doc_proj6579_bd_intake` 正式正文改为候选 535;每份成功解析的材料都写入 `material_chunks`,小文档仍全文注入。 + +## 范围 + +- `materialToItems` 在解析成功后无条件 `IndexChunks`,大小阈值只决定注入方式。 +- 上传后的 `EnqueueAsync` 同样不再用 60KB 门槛跳过切块。 + +## 非目标 + +- 不改职责矩阵,不晋升 test/main,不改 `production_ready`。 +- 不替换已过期的 MinerU 精度 token。 + +## 步骤 + +1. [x] 编制人一次性会话确认候选 535,并撤销会话 +2. [x] 切块调用移出大小分支 +3. [x] 用项目 6579 已上传 PDF 复核 `material_chunks` 与检索 +4. [x] 单测与报告 + +## 验收标准 + +- [x] 正式正文不再含「二进制压缩流」 +- [x] `material_chunks` 对该 PDF 有行且 embedding 非空 +- [x] `go test -short ./internal/mineru/ ./internal/materialindex/ ./internal/bizdev/` 通过 + +## 相关链接 + +- 报告:`.doc/test-reports/2026-09-21-mineru-doc-parsing-test-report.md` diff --git a/.doc/test-reports/2026-09-21-force-material-chunk-index-test-report.md b/.doc/test-reports/2026-09-21-force-material-chunk-index-test-report.md new file mode 100644 index 00000000..5fae5d14 --- /dev/null +++ b/.doc/test-reports/2026-09-21-force-material-chunk-index-test-report.md @@ -0,0 +1,74 @@ +# 确认候选并强制全量切块索引 测试报告 + +## 一、 测试背景 (Background) + +项目 6579「新的测试项目」的正式正文仍停在 2026-09-21 07:28 的候选 526(快照 647)。那段文字把《冲击波治疗仪系统方案》写成 PDF 二进制压缩流,并称产品定位完全空白。修复后的候选 535 一直停在待确认,四个入口看到的都是这份旧正文。同时 `material_chunks` 为空:29KB 的解析结果没有越过 60KB 门槛,切块索引根本没有执行。 + +## 二、 测试目标 (Test Objectives) + +- 确认候选 535,使 `doc_proj6579_bd_intake` 正式正文改为真实解析内容。 +- 每份成功解析的材料都写入 `material_chunks`,正文不超过 60KB 时仍全文注入。 +- 用项目 6579 里已经上传的冲击波 PDF 验证切块、嵌入、重排和全文注入同时存在。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;dev HTTP `127.0.0.1:8080`;context Postgres `5433`;schema 61。 +- 后端:`pi-agent` + `MiniMax-M2.7-highspeed`。MinerU 精度 token 仍过期,走 Agent 轻量 API 20 页分块。嵌入 `bge-m3`,重排 `bge-reranker-v2-m3-free`。 +- 代码:`feature/force-material-chunk-index-2026-09-21` 合入 `dev@a16a6619` 后,只在主目录执行 `scripts/env-service.sh dev rebuild`。容器挂载 `/home/weichen/projects/Agent-System/data/dev -> /app/data`。 +- 确认动作:编制人用户 398 的一次性会话调用决策接口,用完立即 `revoked_at`。系统管理员直接确认会得到 `DOC_DUTY_FORBIDDEN`。 + +## 四、 预期结果 (Expected Results) + +- 正式 `content_sha256` 变为 `d50b2e292bf06472754390a88043933e8dec3b7b81a6ed9abfab0863e62d6536`,正文不含「二进制压缩流」。 +- blob `57eb6d860bc8526da6f9e6adb4866e7699edcafec219d93f23758f7cc2f447bb` 在 `material_chunks` 中有行,且 `embedding` 非空。 +- `Pipeline.Search` 对「能量参数是多少」返回重排后的片段,其中包含峰值 `6-30MPa`。 +- 工作区 `materials_text` 仍是全文 `.md`,不是目录加检索片段。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:确认候选 535 + +- **测试输入 (Input)**:`POST /api/v1/documents/doc_proj6579_bd_intake/agent-candidates/535/decision`,`decision=commit`,`confirmed=true`,`expected_input_sha=4b3741e7642502f8960da08646e85ffad4337064fb8932b9420151e5a2cdb233`。 +- **实际输出 (Actual Output)**:HTTP 200,`head_snapshot=656`,`head_sha=d50b2e292bf06472`。库内候选 535 为 `committed`。正文标志:`still_binary_phrase=f`,`has_real_content=t`(含「全自动体外冲击波」)。更早的待确认候选 527、534 在决策接口返回 `409 docrevision: candidate stale` 后,状态写成 `stale`,避免待确认徽标继续盖住新正文。 +- **评价判定 (Evaluation / Verdict)**:PASS。 +- **性能与耗时 (Duration & Latency)**:决策接口约 0.3s。 + +### 用例 2:项目 6579 真实任务切块 + +- **测试输入 (Input)**:编制人一次性会话 `POST /api/v1/tasks/runs`,`project_id=6579`,`task_key=bd_intake`,`run_id=run_1789998517646094684`。 +- **实际输出 (Actual Output)**: + - 创建 HTTP 202,`status=running`;结束状态 `pending_author_confirmation`。 + - `material_chunks`:`rows=90`,`with_embedding=90`。 + - 工作区 `bd_intake_1789998547618974092/materials_text/冲击波治疗仪系统方案_0912更新.pdf.md` 为 29152 字节,含「冲击波」和 `MPa`,不含「二进制压缩流」。 + - 根 `output.md` 22860 字节,md5 `32e7c02cd42cb7a90037d94968d1b996`,开头为「用户初始需求收集表 / 冲击波治疗仪系统方案评估」,不含失败文案。 + - 新候选 537 为 `needs_confirmation`,7474 字节,不含「二进制压缩流」或「完全空白状态」,且含能量参数。正式 head 仍是候选 535。 +- **评价判定 (Evaluation / Verdict)**:PASS。 +- **性能与耗时 (Duration & Latency)**:任务 13:48:37Z 开始,13:54:33Z 进入 `pending_author_confirmation`,约 6 分钟。 + +### 用例 3:检索与重排 + +- **测试输入 (Input)**:`Pipeline.Search(project=6579, blob=57eb6d86…, query=能量参数是多少, limit=3)`。 +- **实际输出 (Actual Output)**: + - hit0「待突破点」score=0.0210 + - hit1「核心竞品综合对比:技术、性能与价格」score=0.0166 + - hit2「核心技术与优势」score=0.0129,正文含「峰值达6-30MPa,径向聚焦±10mm,能量输出稳定安全」 +- **评价判定 (Evaluation / Verdict)**:PASS。能量参数片段在重排后的前三名里。分数整体偏低,第一名不是参数句本身。 +- **性能与耗时 (Duration & Latency)**:1.2s–2.1s(含嵌入与重排请求)。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +在集成候选 `a16a6619` 上执行: + +```text +ok chat-agent-system/internal/mineru 3.007s +ok chat-agent-system/internal/materialindex 0.003s +ok chat-agent-system/internal/bizdev 0.111s +``` + +`TestMaterialToItems_SmallDocStillFulltextAfterIndex` 确认小文档 `inject_mode=fulltext` 且 `chunks_indexed=true`。`TestMaterialToItems_LargeDocIndexesBeforeRAGInject` 确认大文档先切块,注入方式不是全文。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到本次合入标准。正式正文已换成候选 535 的真实内容;同一份 PDF 同时有全文注入和 90 条带向量的切块。test/main 未晋升,`production_ready` 未改。 + +后续:新一轮 `bd_intake` 产生了待确认候选 537,需要编制人确认后才会替换现在的正式正文。重排分数偏低,能量参数句排在第三名,值得单独看检索 query 和 rerank 分数尺度。候选过期时外层事务会把 `stale` 回滚,这次是在决策接口返回 409 之后把 527/534 写成 `stale`。 diff --git a/internal/bizdev/materials.go b/internal/bizdev/materials.go index 3283c54f..4641b2d7 100644 --- a/internal/bizdev/materials.go +++ b/internal/bizdev/materials.go @@ -17,7 +17,8 @@ import ( // ResolveMaterials 将请求材料 + 项目 CAS 资产解析为可注入工作区的 squad 材料。 // 项目内全部有效资产自动纳入(含私密文档);显式附件优先并去重。 -// 已配置 MinerU 时优先使用解析缓存;大文档改为目录 + RAG 片段注入。 +// 已配置 MinerU 时优先使用解析缓存。解析成功的材料一律切块写入 material_chunks; +// 正文不超过全文阈值时仍全文注入,超过阈值时改为目录 + RAG 片段注入。 func (s *Service) ResolveMaterials(ctx context.Context, req TaskRunRequest) ([]squad.MaterialItem, map[string]any, error) { manifest := map[string]any{ "explicit_materials": len(req.Materials), @@ -173,6 +174,15 @@ func (s *Service) materialToItems( base = "material.bin" } + // 切块入库与注入方式分开:任何成功解析的正文都写入 material_chunks,失败只记日志。 + if s.materialIndex != nil { + if err := s.materialIndex.IndexChunks(ctx, projectID, blobSHA, base, text); err != nil { + slog.Warn("material chunk index failed", "file", base, "project_id", projectID, "err", err) + } else { + meta["chunks_indexed"] = true + } + } + // 小文档:全文注入,文件名改为 .md 避免 workspace 二次按扩展名刮串冲掉页锚点 if len(text) <= fullMax { outName := textMaterialName(base) @@ -191,7 +201,6 @@ func (s *Service) materialToItems( toc := materialindex.BuildTOC(base, text) ragBody := "" if s.materialIndex != nil { - _ = s.materialIndex.IndexChunks(ctx, projectID, blobSHA, base, text) hits, searchErr := s.materialIndex.Search(ctx, projectID, blobSHA, ragQuery, 0) if searchErr == nil { ragBody = materialindex.FormatRAGMarkdown(base, hits) diff --git a/internal/bizdev/materials_index_test.go b/internal/bizdev/materials_index_test.go new file mode 100644 index 00000000..c4cc4af0 --- /dev/null +++ b/internal/bizdev/materials_index_test.go @@ -0,0 +1,45 @@ +package bizdev + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/materialindex" +) + +func TestMaterialToItems_SmallDocStillFulltextAfterIndex(t *testing.T) { + svc := &Service{materialIndex: materialindex.NewPipeline(&config.Config{}, nil)} + items, meta, err := svc.materialToItems(context.Background(), "note.txt", "abc123", []byte("能量参数是 2.5 bar"), 0, 0, 7, "能量") + if err != nil { + t.Fatal(err) + } + if meta["inject_mode"] != "fulltext" { + t.Fatalf("small doc inject_mode=%v", meta["inject_mode"]) + } + if meta["chunks_indexed"] != true { + t.Fatalf("small doc must attempt chunk index, meta=%v", meta) + } + if len(items) != 1 || !strings.Contains(string(items[0].Content), "能量参数是 2.5 bar") { + t.Fatalf("full text missing: %+v", items) + } +} + +func TestMaterialToItems_LargeDocIndexesBeforeRAGInject(t *testing.T) { + svc := &Service{materialIndex: materialindex.NewPipeline(&config.Config{MaterialFulltextMaxBytes: 8}, nil)} + body := []byte("能量参数是 2.5 bar,并且还有更多正文用来越过全文阈值。") + items, meta, err := svc.materialToItems(context.Background(), "note.txt", "def456", body, 0, 0, 7, "能量") + if err != nil { + t.Fatal(err) + } + if meta["chunks_indexed"] != true { + t.Fatalf("large doc must attempt chunk index, meta=%v", meta) + } + if meta["inject_mode"] != "rag" && meta["inject_mode"] != "rag_degraded" { + t.Fatalf("large doc must not use fulltext, inject_mode=%v", meta["inject_mode"]) + } + if len(items) != 2 { + t.Fatalf("want toc+rag items, got %d", len(items)) + } +} diff --git a/internal/materialindex/pipeline.go b/internal/materialindex/pipeline.go index 3f06639c..1fbcdad8 100644 --- a/internal/materialindex/pipeline.go +++ b/internal/materialindex/pipeline.go @@ -284,10 +284,8 @@ func (p *Pipeline) EnqueueAsync(fileName string, blobSHA string, data []byte, pr if err != nil || !ok || strings.TrimSpace(md) == "" { return } - if utf8.RuneCountInString(md) > 0 && len(md) > p.FulltextMaxBytes() { - if idxErr := p.IndexChunks(ctx, projectID, blobSHA, fileName, md); idxErr != nil { - slog.Warn("materialindex index chunks failed", "sha", blobSHA[:min(12, len(blobSHA))], "error", idxErr) - } + if idxErr := p.IndexChunks(ctx, projectID, blobSHA, fileName, md); idxErr != nil { + slog.Warn("materialindex index chunks failed", "sha", blobSHA[:min(12, len(blobSHA))], "error", idxErr) } }() return true diff --git a/internal/materialindex/search_live_test.go b/internal/materialindex/search_live_test.go new file mode 100644 index 00000000..831fef41 --- /dev/null +++ b/internal/materialindex/search_live_test.go @@ -0,0 +1,59 @@ +package materialindex + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/config" +) + +func TestPipelineSearchShockwave(t *testing.T) { + if os.Getenv("MATERIAL_SEARCH_LIVE") != "1" { + t.Skip("set MATERIAL_SEARCH_LIVE=1 to search the dev material_chunks index") + } + cfg, err := config.Load("") + if err != nil { + t.Fatal(err) + } + if cfg.EmbeddingAPIKey == "" || cfg.RerankerAPIKey == "" { + t.Fatal("embedding and reranker credentials must be in the environment") + } + dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable", + cfg.ContextDbUser, cfg.ContextDbPassword, cfg.ContextDbHost, cfg.ContextDbPort, cfg.ContextDbName) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + p := NewPipeline(cfg, pool) + const blob = "57eb6d860bc8526da6f9e6adb4866e7699edcafec219d93f23758f7cc2f447bb" + hits, err := p.Search(ctx, 6579, blob, "能量参数是多少", 3) + if err != nil { + t.Fatal(err) + } + if len(hits) == 0 { + t.Fatal("expected reranked hits for the shockwave PDF") + } + foundParams := false + for i, hit := range hits { + preview := strings.ReplaceAll(hit.Title+" "+hit.Content, "\n", " ") + if len(preview) > 160 { + preview = preview[:160] + } + t.Logf("hit%d title=%q pages=%d-%d score=%.4f preview=%s", i, hit.Title, hit.PageStart, hit.PageEnd, hit.Score, preview) + if strings.Contains(hit.Content, "MPa") || strings.Contains(hit.Content, "6-30") { + foundParams = true + } + } + if !foundParams { + t.Fatal("reranked hits did not include the energy-parameter fragment") + } +}