2026-07-05 20:43:52 +08:00
|
|
|
|
//
|
|
|
|
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
|
|
//
|
|
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
//
|
|
|
|
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
|
// See the License for the specific language governing permissions and
|
|
|
|
|
|
// limitations under the License.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
package component
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
2026-08-18 11:38:52 +08:00
|
|
|
|
"fmt"
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
|
"testing"
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
"time"
|
2026-07-05 20:43:52 +08:00
|
|
|
|
|
|
|
|
|
|
eschema "github.com/cloudwego/eino/schema"
|
2026-08-12 15:45:01 +08:00
|
|
|
|
"github.com/glebarez/sqlite"
|
|
|
|
|
|
"gorm.io/gorm"
|
2026-07-05 20:43:52 +08:00
|
|
|
|
|
|
|
|
|
|
"ragflow/internal/agent/runtime"
|
2026-07-29 21:06:48 +08:00
|
|
|
|
"ragflow/internal/common"
|
2026-08-12 15:45:01 +08:00
|
|
|
|
"ragflow/internal/dao"
|
|
|
|
|
|
"ragflow/internal/entity"
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"ragflow/internal/ingestion/component/schema"
|
2026-08-12 15:45:01 +08:00
|
|
|
|
"ragflow/internal/tokenizer"
|
2026-07-29 21:06:48 +08:00
|
|
|
|
"ragflow/internal/utility"
|
2026-07-05 20:43:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// stubExtractorChatInvoker is the test seam for the package-level
|
|
|
|
|
|
// extractorChatInvoker. It records every call (for assertions) and
|
|
|
|
|
|
// returns canned responses configured per-test. Concurrent-safe so
|
2026-07-13 13:51:40 +08:00
|
|
|
|
// it can backstop concurrent test cases without rewriting.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
type stubExtractorChatInvoker struct {
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
|
|
|
|
|
|
// responses is consumed in order; remaining entries are returned
|
|
|
|
|
|
// as the wrap-error. tests set entries == call count they expect.
|
|
|
|
|
|
responses []stubResponse
|
|
|
|
|
|
|
2026-08-17 15:19:47 +08:00
|
|
|
|
// requests records every call in order. Callers read via lastRequest().
|
|
|
|
|
|
requests []extractorChatRequest
|
|
|
|
|
|
calls atomic.Int32
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// stubResponse couples a Content value and an Err. tests populate
|
|
|
|
|
|
// either field — Err takes precedence over Content when non-nil.
|
|
|
|
|
|
type stubResponse struct {
|
|
|
|
|
|
Content string
|
|
|
|
|
|
Err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *stubExtractorChatInvoker) Chat(_ context.Context, req extractorChatRequest) (*extractorChatResponse, error) {
|
|
|
|
|
|
s.calls.Add(1)
|
|
|
|
|
|
s.mu.Lock()
|
2026-08-17 15:19:47 +08:00
|
|
|
|
s.requests = append(s.requests, req)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
var resp stubResponse
|
|
|
|
|
|
if len(s.responses) > 0 {
|
|
|
|
|
|
resp = s.responses[0]
|
|
|
|
|
|
s.responses = s.responses[1:]
|
|
|
|
|
|
}
|
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
if resp.Err != nil {
|
|
|
|
|
|
return nil, resp.Err
|
|
|
|
|
|
}
|
|
|
|
|
|
return &extractorChatResponse{Content: resp.Content}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 15:19:47 +08:00
|
|
|
|
// lastRequest returns the most recent recorded request. Callers must hold s.mu.
|
|
|
|
|
|
func (s *stubExtractorChatInvoker) lastRequest() extractorChatRequest {
|
|
|
|
|
|
if len(s.requests) == 0 {
|
|
|
|
|
|
return extractorChatRequest{}
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.requests[len(s.requests)-1]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func (s *stubExtractorChatInvoker) Calls() int32 { return s.calls.Load() }
|
|
|
|
|
|
|
|
|
|
|
|
// withStubChatInvoker installs a stub invoker for the duration of
|
|
|
|
|
|
// the test and restores the production invoker on cleanup.
|
|
|
|
|
|
func withStubChatInvoker(t *testing.T, responses ...stubResponse) *stubExtractorChatInvoker {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
prev := defaultExtractorChatInvoker
|
|
|
|
|
|
stub := &stubExtractorChatInvoker{responses: responses}
|
|
|
|
|
|
SetExtractorChatInvoker(stub)
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
|
SetExtractorChatInvoker(prev)
|
|
|
|
|
|
})
|
|
|
|
|
|
return stub
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_Registered verifies the init() registration
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// is visible to the runtime registry.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Registered(t *testing.T) {
|
|
|
|
|
|
factory, cat, md, ok := runtime.DefaultRegistry.Lookup("Extractor")
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatal("Extractor not registered in runtime.DefaultRegistry")
|
|
|
|
|
|
}
|
|
|
|
|
|
if cat != runtime.CategoryIngestion {
|
|
|
|
|
|
t.Errorf("category = %q, want %q", cat, runtime.CategoryIngestion)
|
|
|
|
|
|
}
|
|
|
|
|
|
if factory == nil {
|
|
|
|
|
|
t.Error("factory is nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if md.Inputs == nil || len(md.Inputs) == 0 {
|
|
|
|
|
|
t.Errorf("metadata.Inputs empty: %v", md.Inputs)
|
|
|
|
|
|
}
|
|
|
|
|
|
if md.Outputs == nil || len(md.Outputs) == 0 {
|
|
|
|
|
|
t.Errorf("metadata.Outputs empty: %v", md.Outputs)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, has := md.Outputs["chunks"]; !has {
|
|
|
|
|
|
t.Errorf("metadata.Outputs missing %q", "chunks")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, has := md.Outputs["output_format"]; !has {
|
|
|
|
|
|
t.Errorf("metadata.Outputs missing %q", "output_format")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_Invoke_HappyPath covers the per-chunk
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// auto-extraction (e.g. summary).
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_HappyPath(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "answer for chunk 1"},
|
|
|
|
|
|
stubResponse{Content: "answer for chunk 2"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "gpt-4o-mini",
|
|
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-07-27 10:20:16 +08:00
|
|
|
|
out, err := c.Invoke(t.Context(), nil, map[string]any{
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"text": "first text"},
|
|
|
|
|
|
{"text": "second text"},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("chunks key missing or wrong shape: %T", out["chunks"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(chunks) != 2 {
|
|
|
|
|
|
t.Fatalf("chunks len = %d, want 2", len(chunks))
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
s0, _ := chunks[0]["summary"].(string)
|
|
|
|
|
|
s1, _ := chunks[1]["summary"].(string)
|
|
|
|
|
|
if !((s0 == "answer for chunk 1" && s1 == "answer for chunk 2") || (s0 == "answer for chunk 2" && s1 == "answer for chunk 1")) {
|
|
|
|
|
|
t.Errorf("unexpected summaries: chunk0=%q, chunk1=%q", s0, s1)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
if out["output_format"] != "chunks" {
|
|
|
|
|
|
t.Errorf("output_format = %v, want chunks", out["output_format"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_Invoke_LLMError verifies a mock LLM
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// error is surfaced through Invoke with the component-name prefix.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_LLMError(t *testing.T) {
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
prevMax, prevDelay := extractorRetryMax, extractorRetryDelay
|
|
|
|
|
|
extractorRetryMax, extractorRetryDelay = 3, time.Millisecond
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
|
extractorRetryMax, extractorRetryDelay = prevMax, prevDelay
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
errSentinel := errors.New("upstream llm unavailable")
|
2026-07-05 20:43:52 +08:00
|
|
|
|
withStubChatInvoker(t,
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
stubResponse{Err: errSentinel},
|
|
|
|
|
|
stubResponse{Err: errSentinel},
|
|
|
|
|
|
stubResponse{Err: errSentinel},
|
|
|
|
|
|
stubResponse{Err: errSentinel},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "gpt-4o-mini",
|
|
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-07-27 10:20:16 +08:00
|
|
|
|
_, err := c.Invoke(t.Context(), nil, map[string]any{
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"chunks": []map[string]any{{"text": "x"}},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("Invoke returned nil error")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.HasPrefix(err.Error(), "extractor:") {
|
|
|
|
|
|
t.Errorf("error should be wrapped with 'extractor:' prefix, got %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "upstream llm unavailable") {
|
|
|
|
|
|
t.Errorf("error should chain underlying error, got %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
// TestExtractorComponent_Invoke_RetrySucceeds verifies that a transient
|
|
|
|
|
|
// LLM error is retried (RetryWithBackoff), and the invocation succeeds
|
|
|
|
|
|
// once the LLM recovers.
|
|
|
|
|
|
func TestExtractorComponent_Invoke_RetrySucceeds(t *testing.T) {
|
|
|
|
|
|
prevMax, prevDelay := extractorRetryMax, extractorRetryDelay
|
|
|
|
|
|
extractorRetryMax, extractorRetryDelay = 3, time.Millisecond
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
|
extractorRetryMax, extractorRetryDelay = prevMax, prevDelay
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Err: errors.New("transient")},
|
|
|
|
|
|
stubResponse{Err: errors.New("transient")},
|
|
|
|
|
|
stubResponse{Content: "recovered"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "gpt-4o-mini",
|
|
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}}
|
|
|
|
|
|
out, err := c.Invoke(t.Context(), nil, map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{{"text": "x"}},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
chunks, _ := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if s, _ := chunks[0]["summary"].(string); s != "recovered" {
|
|
|
|
|
|
t.Errorf("summary = %q, want recovered", s)
|
|
|
|
|
|
}
|
|
|
|
|
|
if calls := stub.Calls(); calls != 3 {
|
|
|
|
|
|
t.Errorf("calls = %d, want 3 (2 transient + 1 success)", calls)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 20:43:52 +08:00
|
|
|
|
// TestExtractorComponent_Invoke_UnknownProvider asserts the
|
|
|
|
|
|
// production (eino) chat invoker handles an unregistered driver
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// without panicking.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_UnknownProvider(t *testing.T) {
|
|
|
|
|
|
inv := &einoExtractorChatInvoker{}
|
|
|
|
|
|
resp, err := inv.Chat(context.Background(), extractorChatRequest{
|
|
|
|
|
|
Driver: "definitely-not-a-real-provider-xyz",
|
|
|
|
|
|
ModelName: "anything",
|
|
|
|
|
|
})
|
|
|
|
|
|
if err == nil && resp == nil {
|
|
|
|
|
|
t.Fatal("production invoker returned nil error AND nil response for unknown driver — silent no-op")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if !strings.Contains(err.Error(), "definitely-not-a-real-provider-xyz") &&
|
|
|
|
|
|
!strings.Contains(err.Error(), "no driver") &&
|
|
|
|
|
|
!strings.Contains(err.Error(), "unknown") &&
|
|
|
|
|
|
!strings.Contains(err.Error(), "not implemented") {
|
|
|
|
|
|
t.Errorf("unknown-driver error should mention the driver name or a typed/typed-sentinel substring; got: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_Invoke_EmptyChunksReturnsEmpty verifies that
|
|
|
|
|
|
// when len(in.chunks) == 0, Invoke immediately returns empty chunks.
|
|
|
|
|
|
func TestExtractorComponent_Invoke_EmptyChunksReturnsEmpty(t *testing.T) {
|
2026-07-05 20:43:52 +08:00
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-07-27 10:20:16 +08:00
|
|
|
|
out, err := c.Invoke(t.Context(), nil, map[string]any{})
|
2026-07-05 20:43:52 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("chunks missing or wrong shape")
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if len(chunks) != 0 {
|
|
|
|
|
|
t.Fatalf("chunks len = %d, want 0", len(chunks))
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_Invoke_JSONListInput verifies that the chunks list
|
|
|
|
|
|
// can be provided under the "json" key.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_JSONListInput(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t,
|
2026-08-19 15:02:46 +08:00
|
|
|
|
stubResponse{Content: "json chunk summary"},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-07-27 10:20:16 +08:00
|
|
|
|
out, err := c.Invoke(t.Context(), nil, map[string]any{
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"json": []map[string]any{{"text": "json payload chunk"}},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("chunks malformed: %v", out["chunks"])
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if chunks[0]["summary"] != "json chunk summary" {
|
|
|
|
|
|
t.Errorf("summary = %v, want %q", chunks[0]["summary"], "json chunk summary")
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_Invoke_PerCallLLMIDOverride verifies an
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// inputs["llm_id"] override wins over Param.LLMID.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_PerCallLLMIDOverride(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
2026-08-19 15:02:46 +08:00
|
|
|
|
stubResponse{Content: "summary result"},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "static-llm",
|
|
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-07-27 10:20:16 +08:00
|
|
|
|
_, err := c.Invoke(t.Context(), nil, map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"chunks": []map[string]any{{"text": "sample text"}},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"llm_id": "override-llm",
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
stub.mu.Lock()
|
|
|
|
|
|
defer stub.mu.Unlock()
|
2026-08-17 15:19:47 +08:00
|
|
|
|
if stub.lastRequest().ModelName != "override-llm" {
|
|
|
|
|
|
t.Errorf("ModelName = %q, want override-llm", stub.lastRequest().ModelName)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_Invoke_CompositeLLMID verifies the
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// composite "gpt-4o-mini@openai" form is split into driver and model.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_CompositeLLMID(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
2026-08-19 15:02:46 +08:00
|
|
|
|
stubResponse{Content: "summary result"},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "gpt-4o-mini@openai",
|
|
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if _, err := c.Invoke(t.Context(), nil, map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{{"text": "sample text"}},
|
|
|
|
|
|
}); err != nil {
|
2026-07-05 20:43:52 +08:00
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
stub.mu.Lock()
|
|
|
|
|
|
defer stub.mu.Unlock()
|
2026-08-17 15:19:47 +08:00
|
|
|
|
if stub.lastRequest().Driver != "openai" {
|
|
|
|
|
|
t.Errorf("Driver = %q, want openai", stub.lastRequest().Driver)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
2026-08-17 15:19:47 +08:00
|
|
|
|
if stub.lastRequest().ModelName != "gpt-4o-mini" {
|
|
|
|
|
|
t.Errorf("ModelName = %q, want gpt-4o-mini", stub.lastRequest().ModelName)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_Invoke_ChunkIndexInError verifies the
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// error message includes the failing chunk index.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_ChunkIndexInError(t *testing.T) {
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
prevMax, prevDelay := extractorRetryMax, extractorRetryDelay
|
|
|
|
|
|
extractorRetryMax, extractorRetryDelay = 3, time.Millisecond
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
|
extractorRetryMax, extractorRetryDelay = prevMax, prevDelay
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
errBoom := errors.New("chunk-1-boom")
|
2026-07-05 20:43:52 +08:00
|
|
|
|
withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "ok for chunk 0"},
|
2026-08-19 15:02:46 +08:00
|
|
|
|
stubResponse{Err: errBoom},
|
|
|
|
|
|
stubResponse{Err: errBoom},
|
|
|
|
|
|
stubResponse{Err: errBoom},
|
|
|
|
|
|
stubResponse{Err: errBoom},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
2026-07-27 10:20:16 +08:00
|
|
|
|
_, err := c.Invoke(t.Context(), nil, map[string]any{
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"text": "first"},
|
|
|
|
|
|
{"text": "second"},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("Invoke returned nil error")
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if !strings.Contains(err.Error(), "chunk 0") && !strings.Contains(err.Error(), "chunk 1") {
|
|
|
|
|
|
t.Errorf("error should mention failing chunk index: %v", err)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "chunk-1-boom") {
|
|
|
|
|
|
t.Errorf("error should chain underlying error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorComponent_NewExtractorComponent_ParamCheck(t *testing.T) {
|
2026-07-17 14:40:09 +08:00
|
|
|
|
c, err := NewExtractorComponent(map[string]any{})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("expected nil error, got %v", err)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
2026-07-17 14:40:09 +08:00
|
|
|
|
if c == nil {
|
|
|
|
|
|
t.Fatal("expected non-nil component")
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorComponent_NewExtractorComponent_Happy(t *testing.T) {
|
|
|
|
|
|
c, err := NewExtractorComponent(map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"llm_id": "openai/gpt-4o-mini",
|
|
|
|
|
|
"summary": map[string]any{
|
|
|
|
|
|
"enabled": true,
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
ext := c.(*ExtractorComponent)
|
|
|
|
|
|
if !ext.Param.Summary.Enabled || ext.Param.LLMID != "openai/gpt-4o-mini" {
|
|
|
|
|
|
t.Errorf("unexpected params: %+v", ext.Param)
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_InputsOutputs_NonEmpty verifies Inputs and Outputs shapes.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_InputsOutputs_NonEmpty(t *testing.T) {
|
|
|
|
|
|
c := &ExtractorComponent{}
|
|
|
|
|
|
ins := c.Inputs()
|
|
|
|
|
|
outs := c.Outputs()
|
|
|
|
|
|
if len(ins) == 0 {
|
|
|
|
|
|
t.Error("Inputs() returned empty map")
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if _, ok := ins["chunks"]; !ok {
|
|
|
|
|
|
t.Errorf("Inputs() missing %q", "chunks")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := ins["llm_id"]; !ok {
|
|
|
|
|
|
t.Errorf("Inputs() missing %q", "llm_id")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := ins["prompt"]; ok {
|
|
|
|
|
|
t.Errorf("Inputs() should not contain deprecated %q", "prompt")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := ins["system_prompt"]; ok {
|
|
|
|
|
|
t.Errorf("Inputs() should not contain deprecated %q", "system_prompt")
|
|
|
|
|
|
}
|
2026-07-05 20:43:52 +08:00
|
|
|
|
if len(outs) == 0 {
|
|
|
|
|
|
t.Error("Outputs() returned empty map")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := outs["chunks"]; !ok {
|
|
|
|
|
|
t.Errorf("Outputs() missing %q", "chunks")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := outs["output_format"]; !ok {
|
|
|
|
|
|
t.Errorf("Outputs() missing %q", "output_format")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestSplitExtractorLLID covers the composite-id parser in isolation.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestSplitExtractorLLID(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
in string
|
|
|
|
|
|
wantModel string
|
|
|
|
|
|
wantProvider string
|
|
|
|
|
|
wantOK bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"gpt-4o-mini@openai", "gpt-4o-mini", "openai", true},
|
|
|
|
|
|
{"bare-model", "bare-model", "", false},
|
|
|
|
|
|
{"trailing@", "trailing", "", true},
|
|
|
|
|
|
{"@leading", "", "leading", true},
|
|
|
|
|
|
{"", "", "", false},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
|
t.Run(tc.in, func(t *testing.T) {
|
2026-07-17 14:40:09 +08:00
|
|
|
|
model, provider, ok := splitExtractorLLIDPair(tc.in)
|
2026-07-05 20:43:52 +08:00
|
|
|
|
if ok != tc.wantOK {
|
|
|
|
|
|
t.Errorf("ok = %v, want %v", ok, tc.wantOK)
|
|
|
|
|
|
}
|
|
|
|
|
|
if model != tc.wantModel {
|
|
|
|
|
|
t.Errorf("model = %q, want %q", model, tc.wantModel)
|
|
|
|
|
|
}
|
|
|
|
|
|
if provider != tc.wantProvider {
|
|
|
|
|
|
t.Errorf("provider = %q, want %q", provider, tc.wantProvider)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestTryParseJSONObject covers the JSON parser.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestTryParseJSONObject(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
in string
|
|
|
|
|
|
wantOK bool
|
2026-08-19 15:02:46 +08:00
|
|
|
|
wantKey string
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}{
|
|
|
|
|
|
{name: "object", in: `{"a":1}`, wantOK: true, wantKey: "a"},
|
|
|
|
|
|
{name: "object with fence", in: "```json\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
|
|
|
|
|
|
{name: "fence without json tag", in: "```\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
|
2026-07-29 21:06:48 +08:00
|
|
|
|
{name: "json tag on own line", in: "```\njson\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
|
|
|
|
|
|
{name: "JSON tag on own line", in: "```\nJSON\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
|
|
|
|
|
|
{name: "leading prose no fence", in: "Here is the result: {\"a\":1}", wantOK: false},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
{name: "plain string", in: "hello", wantOK: false},
|
|
|
|
|
|
{name: "array", in: `[1,2]`, wantOK: false},
|
|
|
|
|
|
{name: "empty object", in: `{}`, wantOK: false},
|
|
|
|
|
|
{name: "empty", in: ``, wantOK: false},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
parsed, ok := tryParseJSONObject(tc.in)
|
|
|
|
|
|
if ok != tc.wantOK {
|
|
|
|
|
|
t.Fatalf("ok = %v, want %v (got %v)", ok, tc.wantOK, parsed)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ok && tc.wantKey != "" {
|
|
|
|
|
|
if _, has := parsed[tc.wantKey]; !has {
|
|
|
|
|
|
t.Errorf("parsed map missing %q: %v", tc.wantKey, parsed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestCleanExtractionResult covers think tag and error marker stripping.
|
2026-07-29 21:06:48 +08:00
|
|
|
|
func TestCleanExtractionResult(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
in string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "plain", in: `{"a":1}`, want: `{"a":1}`},
|
|
|
|
|
|
{name: "thinks stripped", in: "let me think<think>reasoning</think>\n{\"a\":1}", want: `{"a":1}`},
|
|
|
|
|
|
{name: "thinks no json", in: "thinking</think>no json here", want: "no json here"},
|
|
|
|
|
|
{name: "error marker rejected", in: "**ERROR** could not extract", want: ""},
|
|
|
|
|
|
{name: "error after think", in: "x</think>**ERROR** boom", want: ""},
|
|
|
|
|
|
{name: "whitespace trimmed", in: " {\"a\":1} ", want: `{"a":1}`},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
if got := cleanExtractionResult(tc.in); got != tc.want {
|
|
|
|
|
|
t.Errorf("cleanExtractionResult(%q) = %q, want %q", tc.in, got, tc.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// newMetadataExtractor returns an ExtractorComponent wired for doc-level
|
|
|
|
|
|
// metadata extraction with the given field definitions.
|
|
|
|
|
|
func newMetadataExtractor(fields ...common.MetadataFieldDef) *ExtractorComponent {
|
|
|
|
|
|
return &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
Metadata: schema.MetadataExtractConfig{
|
|
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Metadata: fields,
|
|
|
|
|
|
},
|
2026-07-29 21:06:48 +08:00
|
|
|
|
}}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_runEnableMetadata_MergesIntoChunkMetadata verifies a
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// JSON object from the LLM is parsed and merged into the chunk's metadata map.
|
2026-07-29 21:06:48 +08:00
|
|
|
|
func TestExtractorComponent_runEnableMetadata_MergesIntoChunkMetadata(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: `{"category":"finance","region":"east"}`})
|
|
|
|
|
|
c := newMetadataExtractor(
|
|
|
|
|
|
common.MetadataFieldDef{Key: "category", Type: "string"},
|
|
|
|
|
|
common.MetadataFieldDef{Key: "region", Type: "string"},
|
|
|
|
|
|
)
|
|
|
|
|
|
ck := map[string]any{}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
|
|
|
|
|
|
t.Fatalf("runEnableMetadata: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
meta, ok := ck["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck[metadata] missing or wrong type: %T", ck["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta["category"] != "finance" || meta["region"] != "east" {
|
|
|
|
|
|
t.Errorf("metadata = %v, want category=finance region=east", meta)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_runEnableMetadata_StripsJSONFence verifies the
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// extraction path tolerates a fenced ```json response.
|
2026-07-29 21:06:48 +08:00
|
|
|
|
func TestExtractorComponent_runEnableMetadata_StripsJSONFence(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: "```json\n{\"category\":\"law\"}\n```"})
|
|
|
|
|
|
c := newMetadataExtractor(common.MetadataFieldDef{Key: "category", Type: "string"})
|
|
|
|
|
|
ck := map[string]any{}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
|
|
|
|
|
|
t.Fatalf("runEnableMetadata: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
meta, ok := ck["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck[metadata] missing: %T", ck["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta["category"] != "law" {
|
|
|
|
|
|
t.Errorf("metadata = %v, want category=law", meta)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 19:39:49 +08:00
|
|
|
|
// TestExtractorComponent_runEnableMetadata_MidTextThink verifies the full
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// metadata path tolerates a mid-text reasoning block preceded by a preamble.
|
2026-08-12 19:39:49 +08:00
|
|
|
|
func TestExtractorComponent_runEnableMetadata_MidTextThink(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: `preamble<think>reasoning</think>{"category":"finance"}`})
|
|
|
|
|
|
c := newMetadataExtractor(common.MetadataFieldDef{Key: "category", Type: "string"})
|
|
|
|
|
|
ck := map[string]any{}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
|
|
|
|
|
|
t.Fatalf("runEnableMetadata: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
meta, ok := ck["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck[metadata] missing: %T", ck["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta["category"] != "finance" {
|
|
|
|
|
|
t.Errorf("metadata = %v, want category=finance", meta)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 21:06:48 +08:00
|
|
|
|
// TestExtractorComponent_runEnableMetadata_DegradesGracefully verifies that an
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// empty / **ERROR** / unparseable / think-only LLM response does NOT block ingestion.
|
2026-07-29 21:06:48 +08:00
|
|
|
|
func TestExtractorComponent_runEnableMetadata_DegradesGracefully(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
content string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"empty", ""},
|
|
|
|
|
|
{"error_marker", "**ERROR** something went wrong"},
|
|
|
|
|
|
{"garbage", "I could not find any metadata in this text."},
|
|
|
|
|
|
{"not_json", "{\"category\": } partial"},
|
|
|
|
|
|
{"think_only", "<think>let me think</think>"},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: tc.content})
|
|
|
|
|
|
c := newMetadataExtractor(common.MetadataFieldDef{Key: "category", Type: "string"})
|
|
|
|
|
|
ck := map[string]any{"metadata": map[string]any{"preexisting": "keep"}}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, tc.name); err != nil {
|
|
|
|
|
|
t.Fatalf("runEnableMetadata returned error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
meta, ok := ck["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck[metadata] should remain a map, got %T", ck["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta["preexisting"] != "keep" {
|
|
|
|
|
|
t.Errorf("preexisting metadata must be preserved: %v", meta)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, has := meta["category"]; has {
|
|
|
|
|
|
t.Errorf("category should not be set on degraded response: %v", meta)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_runEnableMetadata_CrossChunkUnion simulates two chunks
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// whose extraction returns overlapping list values for the same key.
|
2026-07-29 21:06:48 +08:00
|
|
|
|
func TestExtractorComponent_runEnableMetadata_CrossChunkUnion(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: `{"people":["关羽","张辽"]}`},
|
|
|
|
|
|
stubResponse{Content: `{"people":["张辽","刘备"]}`},
|
|
|
|
|
|
)
|
|
|
|
|
|
c := newMetadataExtractor(common.MetadataFieldDef{Key: "people", Type: "string"})
|
|
|
|
|
|
ck1 := map[string]any{}
|
|
|
|
|
|
ck2 := map[string]any{}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck1, "chunk one"); err != nil {
|
|
|
|
|
|
t.Fatalf("ck1: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck2, "chunk two"); err != nil {
|
|
|
|
|
|
t.Fatalf("ck2: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
m1, ok := ck1["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck1[metadata] missing: %T", ck1["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
m2, ok := ck2["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck2[metadata] missing: %T", ck2["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
docMeta := map[string]any{}
|
|
|
|
|
|
docMeta = utility.UpdateMetadataTo(docMeta, m1)
|
|
|
|
|
|
docMeta = utility.UpdateMetadataTo(docMeta, m2)
|
|
|
|
|
|
people, ok := docMeta["people"].([]string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("people = %T, want []string", docMeta["people"])
|
|
|
|
|
|
}
|
|
|
|
|
|
want := map[string]bool{"关羽": true, "张辽": true, "刘备": true}
|
|
|
|
|
|
if len(people) != len(want) {
|
|
|
|
|
|
t.Fatalf("people = %v, want union of %v", people, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, p := range people {
|
|
|
|
|
|
if !want[p] {
|
|
|
|
|
|
t.Errorf("unexpected person %q", p)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorComponent_runEnableMetadata_CombinedValueSplit verifies a value
|
|
|
|
|
|
// the LLM combines with Chinese/comma delimiters is split when passed through
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// common.SplitCombinedMetadataValues.
|
2026-07-29 21:06:48 +08:00
|
|
|
|
func TestExtractorComponent_runEnableMetadata_CombinedValueSplit(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: `{"people":["关羽、张辽、刘备"]}`})
|
|
|
|
|
|
c := newMetadataExtractor(common.MetadataFieldDef{Key: "people", Type: "string"})
|
|
|
|
|
|
ck := map[string]any{}
|
|
|
|
|
|
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
|
|
|
|
|
|
t.Fatalf("runEnableMetadata: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
rawMeta, ok := ck["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("ck[metadata] missing: %T", ck["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
raw, ok := rawMeta["people"].([]any)
|
|
|
|
|
|
if !ok || len(raw) != 1 {
|
|
|
|
|
|
t.Fatalf("raw people = %v, want 1 combined element", rawMeta["people"])
|
|
|
|
|
|
}
|
|
|
|
|
|
split := common.SplitCombinedMetadataValues(ck["metadata"].(map[string]any))
|
|
|
|
|
|
people, ok := split["people"].([]string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("people = %T, want []string", split["people"])
|
|
|
|
|
|
}
|
|
|
|
|
|
want := map[string]bool{"关羽": true, "张辽": true, "刘备": true}
|
|
|
|
|
|
if len(people) != len(want) {
|
|
|
|
|
|
t.Fatalf("people = %v, want 3 split elements", people)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, p := range people {
|
|
|
|
|
|
if !want[p] {
|
|
|
|
|
|
t.Errorf("unexpected %q", p)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 20:43:52 +08:00
|
|
|
|
// TestExtractorComponent_ConcurrentInvoke verifies the chat
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// invoker swap is safe under concurrent Invoke calls.
|
2026-07-05 20:43:52 +08:00
|
|
|
|
func TestExtractorComponent_ConcurrentInvoke(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "1"},
|
|
|
|
|
|
stubResponse{Content: "2"},
|
|
|
|
|
|
stubResponse{Content: "3"},
|
|
|
|
|
|
stubResponse{Content: "4"},
|
|
|
|
|
|
)
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
Summary: schema.SummaryExtractConfig{Enabled: true},
|
2026-07-05 20:43:52 +08:00
|
|
|
|
}}
|
|
|
|
|
|
chunks := []map[string]any{
|
|
|
|
|
|
{"text": "a"}, {"text": "b"}, {"text": "c"}, {"text": "d"},
|
|
|
|
|
|
}
|
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
|
errs := make(chan error, len(chunks))
|
|
|
|
|
|
for _, ck := range chunks {
|
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
defer wg.Done()
|
2026-07-27 10:20:16 +08:00
|
|
|
|
_, err := c.Invoke(t.Context(), nil, map[string]any{
|
2026-07-05 20:43:52 +08:00
|
|
|
|
"chunks": []map[string]any{ck},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
errs <- err
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
close(errs)
|
|
|
|
|
|
for err := range errs {
|
|
|
|
|
|
t.Errorf("Invoke error under concurrency: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var _ = eschema.Message{}
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
|
|
|
|
|
|
// TestIsBareTenantModelID verifies UUID detection.
|
|
|
|
|
|
func TestIsBareTenantModelID(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
input string
|
|
|
|
|
|
want bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"9e819c2442b14f9dab46062916e29195", true},
|
|
|
|
|
|
{"ABCDEFabcdef01234567890123456789", true},
|
2026-08-19 15:02:46 +08:00
|
|
|
|
{"9e819c2442b14f9dab46062916e2919", false},
|
|
|
|
|
|
{"9e819c2442b14f9dab46062916e29195X", false},
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
{"gpt-4o-mini@openai", false},
|
|
|
|
|
|
{"", false},
|
|
|
|
|
|
{"not-a-uuid", false},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tc := range tests {
|
|
|
|
|
|
got := isBareTenantModelID(tc.input)
|
|
|
|
|
|
if got != tc.want {
|
|
|
|
|
|
t.Errorf("isBareTenantModelID(%q) = %v, want %v", tc.input, got, tc.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestResolveExtractorChatTarget_AtSplitFallback verifies the @ split fallback.
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
func TestResolveExtractorChatTarget_AtSplitFallback(t *testing.T) {
|
2026-07-27 21:19:39 +08:00
|
|
|
|
ctx := t.Context()
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
driver, modelName, apiKey, baseURL, err := resolveExtractorChatTarget(
|
2026-07-27 21:19:39 +08:00
|
|
|
|
ctx, dao.DB, "gpt-4o-mini@openai")
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if driver != "openai" {
|
|
|
|
|
|
t.Errorf("driver = %q, want openai", driver)
|
|
|
|
|
|
}
|
|
|
|
|
|
if modelName != "gpt-4o-mini" {
|
|
|
|
|
|
t.Errorf("modelName = %q, want gpt-4o-mini", modelName)
|
|
|
|
|
|
}
|
|
|
|
|
|
if apiKey != "" || baseURL != "" {
|
|
|
|
|
|
t.Errorf("apiKey/baseURL should be empty in fallback path")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestResolveExtractorChatTarget_NoDriver verifies a non-@ plain string returns no driver.
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
func TestResolveExtractorChatTarget_NoDriver(t *testing.T) {
|
2026-07-27 21:19:39 +08:00
|
|
|
|
ctx := t.Context()
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
driver, modelName, _, _, err := resolveExtractorChatTarget(
|
2026-07-27 21:19:39 +08:00
|
|
|
|
ctx, dao.DB, "plain-name")
|
fix(ingestion): align laws DSL with Python — heading fallback, colon-title, short-line filter, remove_toc, and image extension mapping (#17200)
## Summary
This PR aligns the Go ingestion pipeline's **Laws** DSL template with
the Python implementation by fixing heading-detection gaps, adds
image-extension support, refactors the **Extractor** component's LLM
resolution, hardens heading detection for CJK text, and makes the
Extractor accept the Python DSL prompt key names
(`sys_prompt`/`prompts`) alongside the Go names.
## Changes
### 1. Picture file-type detection (`internal/utility/file.go`)
Adds explicit mapping for common image extensions (png, jpg, jpeg, gif,
bmp, tiff, tif, webp, svg, ico, avif, heic, apng) → `FileTypeVISUAL`,
with regression tests.
### 2. Laws DSL heading-detection alignment
(`internal/ingestion/component/chunker/`)
Four fixes to `resolveTitleLevels`:
| Fix | What changed | Why |
|-----|-------------|-----|
| **DOCX `ck_type` fallback** | `ckType` field on `lineRecord`,
propagated from `ChunkDoc.CKType` in `recordsFromStructured`. When
`ck_type=="heading"`, assign `fallbackLevel`. | office_oxide extracts
DOCX heading metadata, but the info was lost before reaching the heading
detector. Word headings whose text doesn't match any regex (e.g.
"Introduction") were treated as body. |
| **`make_colon_as_title` promotion** | `isColonTitle()`: promotes lines
ending with `:`/`:` that have sentence-ending punctuation before the
colon and ≥32 runes between them. | Mirrors Python's
`make_colon_as_title` in `rag/nlp/__init__.py`. Triple guard prevents
false positives. |
| **Short/numeric line filter** | Lines with ≤1 rune or purely numeric
are pinned to body level. | Mirrors Python `tree_merge`'s filter of
`sections` where `len(...) <= 1` or `re.match(r"[0-9]+$", ...)`. |
| **PDF `remove_toc`** | `"remove_toc": true` added to the PDF parser
setup in `ingestion_pipeline_laws.json`. | The Go PDF parser already
supports TOC removal; the Book template already enables it. |
### 3. Extractor llm_id resolution
(`internal/ingestion/component/extractor.go`)
Refactored to handle both **bare tenant_model UUIDs** and **composite
model@provider** strings via the shared `resolveModelConfig`
(`dispatch_model.go`):
- **`resolveExtractorChatConfig`** — UUID path calls
`resolveModelConfigByID` directly (one DB hit); composite path goes
through `resolveModelConfig`. Added `isBareTenantModelID` pre-check for
clear errors when a UUID doesn't exist.
- **`resolveExtractorChatTarget`** — propagates resolution errors
instead of silently returning empty driver.
- **`Chat()`** — removed `driver = "dummy"` fallback. Missing driver is
now an explicit error.
- **Removed dead code**: `splitExtractorLLID`,
`findExtractorSoleActiveInstance`.
### 4. `InjectExtractorLLMID` — fallback when no user config
(`internal/common/parser_config.go`)
Injects the tenant's global default LLM into extractor components **only
when their `llm_id` is empty**. Preserves user-selected UUID or
model@provider values.
Priority: user-configured llm_id > tenant global default > error (no
silent dummy fallback).
### 5. `ResponseHeaderTimeout` increase
(`internal/entity/models/base_model.go`)
`ResponseHeaderTimeout` 60s → 120s in `NewDriverHTTPClient`. Reasoning
models with large extraction prompts can take longer than 60s to produce
the first response token.
### 6. CJK rune-aware heading detection
(`internal/ingestion/component/chunker/title.go`)
Two byte-vs-rune bugs that only manifest on CJK text:
| Fix | What changed | Why |
|-----|-------------|-----|
| **`isColonTitle` byte offset** | `body[lastPunct+1:]` →
`body[lastPunct+runeLen:]` via `utf8.DecodeRuneInString` |
`strings.LastIndexAny` returns a byte index; `+1` skips only 1 byte,
corrupting multi-byte CJK punctuation (e.g. `。` = 3 bytes) and inflating
the rune count past the 32-rune threshold → false-positive heading
promotion. |
| **Short-line filter byte count** | `len(text) <= 1` →
`utf8.RuneCountInString(text) <= 1` | Go `len` is UTF-8 bytes; a single
CJK char (3 bytes) passed the filter, but Python's `len` returns 1 →
mismatch. |
### 7. `extractor_tag.go` — log error when llm fails
When `resolveExtractorChatTarget` returned an error, `runAutoTags` will
log error.
### 8. Python DSL prompt-key compatibility
(`internal/ingestion/component/extractor.go`)
The Resume DSL template uses Python-side key names (`sys_prompt`,
`prompts`). `NewExtractorComponent` now accepts them as fallbacks
alongside the Go names:
- `system_prompt` (Go) ← `sys_prompt` (Python) as fallback
- `prompt` (Go string) ← `prompts` (Python array `[{"role","content"}]`,
takes `[0].content`) as fallback
Mirrors the alias pattern already in `internal/agent/component/llm.go`.
`resolveInputs` accepts per-call `sys_prompt` override too.
## Remaining gaps vs Python
| Gap | Scope | Impact |
|-----|-------|--------|
| **TOC removal for TXT/MD/HTML** | Python's `remove_contents_table`
works on all text formats; Go's `remove_toc` is PDF-only. | Low —
plain-text documents rarely contain structured TOCs. |
| **Regex pattern details** | Minor differences in quantifiers, missing
H5/H6 markdown patterns, missing 4-level numbering pattern. | Low — Go's
variants are stricter; DOCX headings are covered by `ck_type` fallback.
|
## Testing
- `TestHierarchyTitleChunker_CKTypeHeadingFallback` — DOCX `ck_type`
heading promotion
- `TestHierarchyTitleChunker_ColonTitlePromotion` /
`_ColonTitleShortLine_Negative` — colon-title promotion + guard
- `TestIsColonTitle_CJKEdgeCase` / `TestIsColonTitle_ASCII_NoRegression`
— CJK byte-offset fix + ASCII regression
- `TestHierarchyTitleChunker_ColonTitlePromotion_CJK_EdgeCase` — CJK
colon edge case through full pipeline
- `TestHierarchyTitleChunker_ShortSingleCJKLineFilter` — single CJK char
filtered to body
- `TestHierarchyTitleChunker_ShortNumericLineFilter` — purely numeric
lines filtered
- `TestGetFileType_ImageExtensions` / `_ExistingFormats_NoRegression` —
image extension mapping
- `TestInjectExtractorLLMID_SkipWhenUUID` / `_SkipWhenComposite` /
`_InjectWhenEmpty` — llm_id injection guard
- `TestIsBareTenantModelID` — UUID detection
- `TestResolveExtractorChatTarget_AtSplitFallback` / `_NoDriver` — @
split fallback without DB
- `TestNewExtractorComponent_SysPromptAlias` / `_PromptsArray` /
`_PromptsArray_PromptWins` / `_SystemPromptWinsOverSysPrompt` — Python
key compatibility
- `TestBuildDOCXJSONSections_List` / `_TextBox` / `_MixedWithList` —
DOCX list/text_box parsing
- Full ingestion test suite passes (chunker, pipeline, task, service,
component packages)
2026-07-22 19:14:32 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if driver != "" {
|
|
|
|
|
|
t.Errorf("driver should be empty for plain name, got %q", driver)
|
|
|
|
|
|
}
|
|
|
|
|
|
if modelName != "plain-name" {
|
|
|
|
|
|
t.Errorf("modelName = %q, want plain-name", modelName)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_Invoke_TemperatureSet verifies keyword extraction receives Temperature=0.2.
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_TemperatureSet(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "keyword, extraction"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "gpt-4o-mini",
|
|
|
|
|
|
Keywords: schema.KeywordExtractConfig{TopN: 3},
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}}
|
|
|
|
|
|
_, err := c.Invoke(t.Context(), nil, map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{{"text": "document content"}},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stub.mu.Lock()
|
|
|
|
|
|
defer stub.mu.Unlock()
|
2026-08-17 15:19:47 +08:00
|
|
|
|
if stub.lastRequest().Temperature == nil {
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
t.Fatal("Temperature is nil, want 0.2")
|
|
|
|
|
|
}
|
2026-08-17 15:19:47 +08:00
|
|
|
|
if *stub.lastRequest().Temperature != 0.2 {
|
|
|
|
|
|
t.Errorf("Temperature = %v, want 0.2", *stub.lastRequest().Temperature)
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
if stub.calls.Load() != 1 {
|
|
|
|
|
|
t.Errorf("expected exactly 1 LLM call (keyword), got %d", stub.calls.Load())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestIsRetryableLLMError tests the retry classification heuristic.
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
func TestIsRetryableLLMError(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
err error
|
|
|
|
|
|
want bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "nil is retryable", err: nil, want: true},
|
|
|
|
|
|
{name: "context canceled is terminal", err: context.Canceled, want: false},
|
|
|
|
|
|
{name: "deadline exceeded is terminal", err: context.DeadlineExceeded, want: false},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "wrapped deadline with 400ms must stay retryable",
|
|
|
|
|
|
err: errors.New("context deadline exceeded after 400ms"),
|
|
|
|
|
|
want: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
{name: "429 stays retryable", err: errors.New("429 Too Many Requests"), want: true},
|
|
|
|
|
|
{name: "503 stays retryable", err: errors.New("503 Service Unavailable"), want: true},
|
|
|
|
|
|
{name: "401 unauthorized is terminal", err: errors.New("HTTP 401 Unauthorized"), want: false},
|
|
|
|
|
|
{name: "403 forbidden is terminal", err: errors.New("403 forbidden"), want: false},
|
|
|
|
|
|
{name: "404 not found is terminal", err: errors.New("HTTP 404 Not Found"), want: false},
|
|
|
|
|
|
{name: "405 method not allowed is terminal", err: errors.New("405 Method Not Allowed"), want: false},
|
|
|
|
|
|
{name: "422 unprocessable is terminal", err: errors.New("422 Unprocessable Entity"), want: false},
|
|
|
|
|
|
{name: "bad request is terminal", err: errors.New("400 Bad Request: malformed"), want: false},
|
|
|
|
|
|
{name: "api key phrase is terminal", err: errors.New("invalid api key"), want: false},
|
|
|
|
|
|
{name: "no driver phrase is terminal", err: errors.New("no driver resolved for llm_id"), want: false},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
if got := isRetryableLLMError(tt.err); got != tt.want {
|
|
|
|
|
|
t.Errorf("isRetryableLLMError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestCleanExtractionResult_LastThinkTag verifies think tag removal.
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
func TestCleanExtractionResult_LastThinkTag(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
in string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
{name: "single think block", in: "<think>reasoning</think>the answer", want: "the answer"},
|
|
|
|
|
|
{name: "nested think blocks", in: "<think>outer</think>mid<think>inner</think>final output", want: "final output"},
|
|
|
|
|
|
{name: "no think tag", in: "plain answer", want: "plain answer"},
|
|
|
|
|
|
{name: "think tag without close", in: "<think>unclosed", want: "<think>unclosed"},
|
|
|
|
|
|
{name: "error sentinel", in: "valid output**ERROR**extra", want: ""},
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
got := cleanExtractionResult(tt.in)
|
|
|
|
|
|
if got != tt.want {
|
|
|
|
|
|
t.Errorf("cleanExtractionResult(%q) = %q, want %q", tt.in, got, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestCleanLLMText verifies cleanLLMText reasoning tag and tool call removal.
|
2026-08-10 17:18:24 +08:00
|
|
|
|
func TestCleanLLMText(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
in string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "think block stripped",
|
|
|
|
|
|
in: "<think>reasoning</think>the answer",
|
|
|
|
|
|
want: "the answer",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "close without open kept",
|
|
|
|
|
|
in: "abc</think>def",
|
2026-08-19 15:02:46 +08:00
|
|
|
|
want: "abc</think>def",
|
2026-08-10 17:18:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "prefix before think kept",
|
|
|
|
|
|
in: "prefix<think>reason</think>answer",
|
2026-08-19 15:02:46 +08:00
|
|
|
|
want: "prefix<think>reason</think>answer",
|
2026-08-10 17:18:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "open without close kept",
|
|
|
|
|
|
in: "<think>unclosed",
|
2026-08-19 15:02:46 +08:00
|
|
|
|
want: "<think>unclosed",
|
2026-08-10 17:18:24 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "tool_call block removed",
|
|
|
|
|
|
in: "before<tool_call>{\"name\":\"x\"}</tool_call>after",
|
|
|
|
|
|
want: "beforeafter",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "consecutive tool_call blocks",
|
|
|
|
|
|
in: "a<tool_call>1</tool_call>b<tool_call>2</tool_call>c",
|
|
|
|
|
|
want: "abc",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "think then tool_call",
|
|
|
|
|
|
in: "<think>r</think>out<tool_call>t</tool_call>end",
|
|
|
|
|
|
want: "outend",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "plain text",
|
|
|
|
|
|
in: " plain answer ",
|
|
|
|
|
|
want: "plain answer",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
if got := cleanLLMText(tt.in); got != tt.want {
|
|
|
|
|
|
t.Errorf("cleanLLMText(%q) = %q, want %q", tt.in, got, tt.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_callStructured verifies structured parsing.
|
2026-08-10 17:18:24 +08:00
|
|
|
|
func TestExtractorComponent_callStructured(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: `{"a": 1}`})
|
|
|
|
|
|
c := &ExtractorComponent{}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
got, err := c.callStructured(t.Context(), nil, extractorInputs{llmID: "m"}, "system", "")
|
2026-08-10 17:18:24 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("callStructured: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got["a"].(float64) != 1 {
|
|
|
|
|
|
t.Errorf("parsed = %v, want map with a=1", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Non-JSON response → (nil, nil), not an error.
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: "this is not JSON"})
|
2026-08-19 15:02:46 +08:00
|
|
|
|
got, err = c.callStructured(t.Context(), nil, extractorInputs{llmID: "m"}, "system", "")
|
2026-08-10 17:18:24 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("callStructured on non-JSON: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got != nil {
|
|
|
|
|
|
t.Errorf("non-JSON response should yield nil map, got %v", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_callStructured_MidTextThink verifies think trailing stripping.
|
2026-08-12 19:39:49 +08:00
|
|
|
|
func TestExtractorComponent_callStructured_MidTextThink(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: `preamble<think>reasoning</think>{"a": 1}`})
|
|
|
|
|
|
c := &ExtractorComponent{}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
got, err := c.callStructured(t.Context(), nil, extractorInputs{llmID: "m"}, "system", "")
|
2026-08-12 19:39:49 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("callStructured: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got == nil || got["a"].(float64) != 1 {
|
|
|
|
|
|
t.Errorf("parsed = %v, want map with a=1", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
// TestExtractorComponent_Invoke_ConcurrentKeywordsAndQuestions verifies
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// keyword and question extraction on chunks.
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
func TestExtractorComponent_Invoke_ConcurrentKeywordsAndQuestions(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "alpha, beta"}, // chunk 0 keywords
|
|
|
|
|
|
stubResponse{Content: "what is it?\nwhy?"}, // chunk 0 questions
|
|
|
|
|
|
stubResponse{Content: "gamma, delta"}, // chunk 1 keywords
|
|
|
|
|
|
stubResponse{Content: "how?\nwhen?"}, // chunk 1 questions
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
c := &ExtractorComponent{Param: schema.ExtractorParam{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
LLMID: "gpt-4o-mini",
|
|
|
|
|
|
Keywords: schema.KeywordExtractConfig{TopN: 2},
|
|
|
|
|
|
Questions: schema.QuestionExtractConfig{TopN: 2},
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}}
|
|
|
|
|
|
out, err := c.Invoke(t.Context(), nil, map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"text": "first doc"},
|
|
|
|
|
|
{"text": "second doc"},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 chunks, got %v", out["chunks"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for i, ck := range chunks {
|
|
|
|
|
|
kwds, hasKW := ck["important_kwd"].([]string)
|
|
|
|
|
|
if !hasKW || len(kwds) == 0 {
|
|
|
|
|
|
t.Errorf("chunk %d: missing important_kwd", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
qs, hasQ := ck["question_kwd"].([]string)
|
|
|
|
|
|
if !hasQ || len(qs) == 0 {
|
|
|
|
|
|
t.Errorf("chunk %d: missing question_kwd", i)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if calls := stub.Calls(); calls != 4 {
|
|
|
|
|
|
t.Errorf("expected 4 LLM calls (2 chunks × 2 types), got %d", calls)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestResolveExtractorChatTarget_EmptyLLMID verifies default fallback when llmID is empty.
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
func TestResolveExtractorChatTarget_EmptyLLMID(t *testing.T) {
|
|
|
|
|
|
ctx := t.Context()
|
|
|
|
|
|
driver, modelName, _, _, err := resolveExtractorChatTarget(ctx, dao.DB, "")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if driver != "" {
|
2026-08-19 15:02:46 +08:00
|
|
|
|
t.Logf("resolved empty llmID: driver=%q model=%q", driver, modelName)
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestFitExtractorMessages_RejectsEmptyUserTurn verifies rejection of emptied user turn.
|
|
|
|
|
|
func TestFitExtractorMessages_RejectsEmptyUserTurn(t *testing.T) {
|
|
|
|
|
|
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 500 })
|
|
|
|
|
|
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
|
2026-08-11 11:53:34 +08:00
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
msgs := []eschema.Message{
|
|
|
|
|
|
{Role: eschema.System, Content: strings.Repeat("s ", 1000)},
|
|
|
|
|
|
{Role: eschema.User, Content: strings.Repeat("u ", 400)},
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if _, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs); err == nil {
|
|
|
|
|
|
t.Fatal("expected an error when fitting empties the user turn")
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestFitExtractorMessages_KeepsUserTurn verifies prompt trimming happy path.
|
|
|
|
|
|
func TestFitExtractorMessages_KeepsUserTurn(t *testing.T) {
|
|
|
|
|
|
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 2000 })
|
|
|
|
|
|
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
|
2026-08-11 11:53:34 +08:00
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
msgs := []eschema.Message{
|
|
|
|
|
|
{Role: eschema.System, Content: "you are a helpful assistant"},
|
|
|
|
|
|
{Role: eschema.User, Content: strings.Repeat("u ", 3000)},
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
fitted, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if len(fitted) != 2 {
|
|
|
|
|
|
t.Fatalf("got %d messages, want 2", len(fitted))
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if strings.TrimSpace(fitted[1].Content) == "" {
|
|
|
|
|
|
t.Fatal("user turn was emptied")
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestFitExtractorMessages_NoSystemPromptKeepsUserTurn verifies user-only message fitting.
|
|
|
|
|
|
func TestFitExtractorMessages_NoSystemPromptKeepsUserTurn(t *testing.T) {
|
|
|
|
|
|
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 2000 })
|
|
|
|
|
|
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
|
2026-08-11 11:53:34 +08:00
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
msgs := []eschema.Message{
|
|
|
|
|
|
{Role: eschema.User, Content: strings.Repeat("u ", 3000)},
|
|
|
|
|
|
}
|
|
|
|
|
|
fitted, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs)
|
2026-08-11 11:53:34 +08:00
|
|
|
|
if err != nil {
|
2026-08-19 15:02:46 +08:00
|
|
|
|
t.Fatalf("unexpected error for user-only prompt: %v", err)
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if len(fitted) != 1 || fitted[0].Role != eschema.User {
|
|
|
|
|
|
t.Fatalf("got %d messages, want the single user turn: %+v", len(fitted), fitted)
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if strings.TrimSpace(fitted[0].Content) == "" {
|
|
|
|
|
|
t.Fatal("user turn was emptied")
|
2026-08-11 11:53:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_CallRaw_FitsBeforeInvoke verifies message fitting end to end.
|
|
|
|
|
|
func TestExtractorComponent_CallRaw_FitsBeforeInvoke(t *testing.T) {
|
|
|
|
|
|
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 200 })
|
|
|
|
|
|
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
stub := withStubChatInvoker(t, stubResponse{Content: `{"ok": true}`})
|
|
|
|
|
|
c := &ExtractorComponent{}
|
|
|
|
|
|
|
|
|
|
|
|
chunkBody := strings.Repeat("chunk text with lots of tokens. ", 500)
|
|
|
|
|
|
_, err := c.callText(t.Context(), nil, extractorInputs{
|
|
|
|
|
|
llmID: "test@test",
|
|
|
|
|
|
}, "extract fields", chunkBody)
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
if err != nil {
|
2026-08-19 15:02:46 +08:00
|
|
|
|
t.Fatalf("callText: %v", err)
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stub.mu.Lock()
|
2026-08-19 15:02:46 +08:00
|
|
|
|
req := stub.lastRequest()
|
|
|
|
|
|
stub.mu.Unlock()
|
|
|
|
|
|
if len(req.Messages) == 0 {
|
|
|
|
|
|
t.Fatal("invoker was not called")
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if req.Messages[0].Role != eschema.System || strings.TrimSpace(req.Messages[0].Content) == "" {
|
|
|
|
|
|
t.Fatalf("system prompt lost or emptied before invoke: %+v", req.Messages[0])
|
fix(ingestion): improve Extractor/LLM robustness and Python->Go parity (#17470)
## Summary
This PR hardens the Go ingestion **Extractor** and **LLM retry** paths
and closes several Python->Go parity gaps in the keyword/question/tag
extraction flow.
- **Generic retry utility** (`internal/common/retry.go`):
`RetryWithBackoff` with exponential backoff (default 3 retries, 2s
initial delay, capped at 1m), context-aware sleep, and a `maxRetries<=0`
fast path. Covered by `internal/common/retry_test.go`.
- **LLM retry reuse**: `agent/component/llm_retry.go` now delegates to
`common.RetryWithBackoff` instead of an inline loop (behavior preserved:
ctx cancellation short-circuits the backoff).
- **Extractor LLM calls** (`extractor.go`):
- `call()` now retries transient LLM failures via `RetryWithBackoff`
(retry exhaustion fails the chunk instead of silently skipping).
- Sets `temperature = 0.2`, matching Python `generator.py:230,245`.
- Runs keyword and question extraction **concurrently** per chunk when
both are enabled (`task_executor.py:444-448`), with mutex-guarded map
writes to avoid data races.
- Substitutes `{field_name}` placeholders (including `{chunks}` -> chunk
text) in `prompt`/`system_prompt` before the call, mirroring Python
`string_format` (`extractor.py:102-103`); unmatched placeholders are
left as-is.
- Falls back to the **tenant default chat model** when `llm_id` is empty
(`task_executor.py:573-574`).
- Strips `` **greedily** (`strings.LastIndex`) in
`cleanExtractionResult`.
- **Auto-tagging** (`extractor_tag.go`): drops the `in.llmID != ""`
guards so an empty `llm_id` no longer skips tagging (uses the tenant
default model), and strips `` greedily in `parseTaggerResponse`.
- **Docs**: fixes a misleading `PresentationChunker` docstring that
claimed per-slide `image`/`position` output (the PPTX path emits none —
unlike PDF), and removes a stale `docs/migration_python_go_diff.md`
reference in `media_dispatch.go`.
## Test plan
- `bash build.sh --test ./internal/common/...` — passes (new retry
utility + tests).
- `bash build.sh --test ./internal/ingestion/component/...` — passes
(extractor/chunker/schema).
- `gofmt` and lefthook pre-commit checks pass.
Note: the personal `docs/migration_python_go_diff.md` working notebook
in the tree is intentionally **not** part of this PR.
---------
Co-authored-by: CodeBuddy <noreply@codebuddy.ai>
2026-07-28 19:22:18 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
total := 0
|
|
|
|
|
|
for _, m := range req.Messages {
|
|
|
|
|
|
total += tokenizer.NumTokensFromString(m.Content)
|
2026-08-12 15:45:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
if total > extractorContextFitBudget(200) {
|
|
|
|
|
|
t.Fatalf("sent messages total %d exceed the fitting budget %d", total, extractorContextFitBudget(200))
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(req.Messages[len(req.Messages)-1].Content, "chunk text") {
|
|
|
|
|
|
t.Fatal("chunk text lost from the user turn")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// TestExtractorComponent_CallRaw_CustomContextOverride verifies tenant-configured context override.
|
2026-08-12 19:32:37 +08:00
|
|
|
|
func TestExtractorComponent_CallRaw_CustomContextOverride(t *testing.T) {
|
|
|
|
|
|
db := openExtractorContextTestDB(t)
|
|
|
|
|
|
seedExtractorContextModel(t, db, "")
|
|
|
|
|
|
if err := db.Create(&entity.TenantModelInstance{
|
|
|
|
|
|
ID: "instance-1",
|
|
|
|
|
|
ProviderID: "provider-openai",
|
|
|
|
|
|
InstanceName: "default",
|
|
|
|
|
|
Status: "active",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("create instance: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Model(&entity.TenantModel{}).
|
|
|
|
|
|
Where("id = ?", "0123456789abcdef0123456789abcdef").
|
|
|
|
|
|
Update("extra", `{"max_tokens": 2000}`).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("set model extra: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx := extractorStateCtx(t, "tenant-1")
|
|
|
|
|
|
|
|
|
|
|
|
stub := withStubChatInvoker(t, stubResponse{Content: `{"ok": true}`})
|
|
|
|
|
|
c := &ExtractorComponent{}
|
|
|
|
|
|
_, err := c.callText(ctx, db, extractorInputs{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
llmID: "gpt-4o@OpenAI",
|
|
|
|
|
|
}, "extract fields", strings.Repeat("chunk text with lots of tokens. ", 500))
|
2026-08-12 19:32:37 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("callText: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stub.mu.Lock()
|
2026-08-17 15:19:47 +08:00
|
|
|
|
req := stub.lastRequest()
|
2026-08-12 19:32:37 +08:00
|
|
|
|
stub.mu.Unlock()
|
|
|
|
|
|
if len(req.Messages) == 0 {
|
|
|
|
|
|
t.Fatal("invoker was not called")
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.Messages[0].Role != eschema.System || strings.TrimSpace(req.Messages[0].Content) == "" {
|
|
|
|
|
|
t.Fatalf("system prompt lost or emptied: %+v", req.Messages[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
total := 0
|
|
|
|
|
|
for _, m := range req.Messages {
|
|
|
|
|
|
total += tokenizer.NumTokensFromString(m.Content)
|
|
|
|
|
|
}
|
|
|
|
|
|
if total > 2000 {
|
|
|
|
|
|
t.Fatalf("sent messages total %d exceed the custom 2000-token context window", total)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 15:45:01 +08:00
|
|
|
|
func openExtractorContextTestDB(t *testing.T) *gorm.DB {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("open sqlite: %v", err)
|
|
|
|
|
|
}
|
2026-08-12 19:32:37 +08:00
|
|
|
|
if err := db.AutoMigrate(&entity.Tenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
|
2026-08-12 15:45:01 +08:00
|
|
|
|
t.Fatalf("migrate: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return db
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func seedExtractorContextModel(t *testing.T, db *gorm.DB, tenantLLMID string) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
status := "1"
|
|
|
|
|
|
tenant := entity.Tenant{
|
|
|
|
|
|
ID: "tenant-1",
|
|
|
|
|
|
LLMID: "gpt-4o@openai",
|
|
|
|
|
|
Status: &status,
|
|
|
|
|
|
}
|
|
|
|
|
|
if tenantLLMID != "" {
|
|
|
|
|
|
tenant.TenantLLMID = &tenantLLMID
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&tenant).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("create tenant: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&entity.TenantModelProvider{
|
|
|
|
|
|
ID: "provider-openai",
|
|
|
|
|
|
ProviderName: "OpenAI",
|
|
|
|
|
|
TenantID: "tenant-1",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("create provider: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := db.Create(&entity.TenantModel{
|
|
|
|
|
|
ID: "0123456789abcdef0123456789abcdef",
|
|
|
|
|
|
ProviderID: "provider-openai",
|
|
|
|
|
|
InstanceID: "instance-1",
|
|
|
|
|
|
ModelName: "gpt-4o",
|
|
|
|
|
|
ModelType: int(entity.ModelTypeChat),
|
|
|
|
|
|
Status: "active",
|
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
|
t.Fatalf("create model: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func extractorStateCtx(t *testing.T, tenantID string) context.Context {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
state := runtime.NewCanvasState("run-1", "session-1")
|
|
|
|
|
|
state.SetGlobal("tenant_id", tenantID)
|
|
|
|
|
|
return runtime.WithState(t.Context(), state)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorContextLength_TenantModelUUID(t *testing.T) {
|
|
|
|
|
|
db := openExtractorContextTestDB(t)
|
|
|
|
|
|
seedExtractorContextModel(t, db, "")
|
|
|
|
|
|
ctx := extractorStateCtx(t, "tenant-1")
|
|
|
|
|
|
|
|
|
|
|
|
if got := extractorContextLength(ctx, db, "0123456789abcdef0123456789abcdef"); got != 128000 {
|
|
|
|
|
|
t.Fatalf("extractorContextLength(uuid) = %d, want 128000", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorContextLength_DefaultChatModelPinned(t *testing.T) {
|
|
|
|
|
|
db := openExtractorContextTestDB(t)
|
|
|
|
|
|
seedExtractorContextModel(t, db, "0123456789abcdef0123456789abcdef")
|
|
|
|
|
|
ctx := extractorStateCtx(t, "tenant-1")
|
|
|
|
|
|
|
|
|
|
|
|
if got := extractorContextLength(ctx, db, ""); got != 128000 {
|
|
|
|
|
|
t.Fatalf("extractorContextLength(default pinned uuid) = %d, want 128000", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorContextLength_DefaultChatModelComposite(t *testing.T) {
|
|
|
|
|
|
db := openExtractorContextTestDB(t)
|
|
|
|
|
|
seedExtractorContextModel(t, db, "")
|
|
|
|
|
|
ctx := extractorStateCtx(t, "tenant-1")
|
|
|
|
|
|
|
|
|
|
|
|
if got := extractorContextLength(ctx, db, ""); got != 128000 {
|
|
|
|
|
|
t.Fatalf("extractorContextLength(default composite) = %d, want 128000", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorContextLength_UnknownModelSkips(t *testing.T) {
|
|
|
|
|
|
db := openExtractorContextTestDB(t)
|
|
|
|
|
|
seedExtractorContextModel(t, db, "")
|
|
|
|
|
|
ctx := extractorStateCtx(t, "tenant-1")
|
|
|
|
|
|
|
|
|
|
|
|
if got := extractorContextLength(ctx, db, "no-such-model@no-such-provider"); got != 0 {
|
|
|
|
|
|
t.Fatalf("extractorContextLength(unknown) = %d, want 0", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorContextFitBudget(t *testing.T) {
|
|
|
|
|
|
if got := extractorContextFitBudget(128000); got != 124160 {
|
|
|
|
|
|
t.Fatalf("extractorContextFitBudget(128000) = %d, want 124160", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := extractorContextFitBudget(1); got != 1 {
|
2026-08-19 15:02:46 +08:00
|
|
|
|
t.Fatalf("extractorContextFitBudget(1) = %d, want 1", got)
|
2026-08-12 15:45:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestFitExtractorMessages_RejectsSystemPromptLoss(t *testing.T) {
|
|
|
|
|
|
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 300 })
|
|
|
|
|
|
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
|
|
|
|
|
|
|
|
|
|
|
|
msgs := []eschema.Message{
|
|
|
|
|
|
{Role: eschema.System, Content: strings.Repeat("s ", 5000)},
|
|
|
|
|
|
{Role: eschema.User, Content: strings.Repeat("u ", 400)},
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs); err == nil {
|
|
|
|
|
|
t.Fatal("expected an error when fitting empties the system prompt")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorContextLength_NilDBGraceful(t *testing.T) {
|
|
|
|
|
|
ctx := extractorStateCtx(t, "tenant-1")
|
|
|
|
|
|
if got := extractorContextLength(ctx, nil, ""); got != 0 {
|
2026-08-19 15:02:46 +08:00
|
|
|
|
t.Fatalf("extractorContextLength(nil db, default model) = %d, want 0", got)
|
2026-08-12 15:45:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 15:19:47 +08:00
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
func TestBuildExtractorMessages(t *testing.T) {
|
|
|
|
|
|
msgs := buildExtractorMessages("System rule", "Chunk content")
|
|
|
|
|
|
if len(msgs) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if msgs[0].Role != eschema.System || msgs[0].Content != "System rule" {
|
|
|
|
|
|
t.Errorf("msgs[0] = %#v, want system message", msgs[0])
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if msgs[1].Role != eschema.User || msgs[1].Content != "Chunk content" {
|
|
|
|
|
|
t.Errorf("msgs[1] = %#v, want user message", msgs[1])
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// Empty system prompt omitted
|
|
|
|
|
|
msgsNoSys := buildExtractorMessages("", "Chunk content")
|
|
|
|
|
|
if len(msgsNoSys) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 message, got %d", len(msgsNoSys))
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if msgsNoSys[0].Role != eschema.User || msgsNoSys[0].Content != "Chunk content" {
|
|
|
|
|
|
t.Errorf("msgsNoSys[0] = %#v, want user message", msgsNoSys[0])
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// Empty user chunk text normalized to single space
|
|
|
|
|
|
msgsEmptyUser := buildExtractorMessages("System rule", "")
|
|
|
|
|
|
if len(msgsEmptyUser) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 messages, got %d", len(msgsEmptyUser))
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if msgsEmptyUser[1].Role != eschema.User || msgsEmptyUser[1].Content != " " {
|
|
|
|
|
|
t.Errorf("msgsEmptyUser[1] = %#v, want single space", msgsEmptyUser[1])
|
2026-08-17 15:19:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-18 11:38:52 +08:00
|
|
|
|
|
|
|
|
|
|
func TestExtractorModularParams(t *testing.T) {
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "test-llm-1",
|
|
|
|
|
|
"keywords": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"top_n": 5,
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"questions": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"top_n": 3,
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"tags": map[string]any{
|
|
|
|
|
|
"top_n": 2,
|
|
|
|
|
|
"tag_file_id": "tag_file_abc",
|
|
|
|
|
|
},
|
|
|
|
|
|
"summary": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"enabled": true,
|
|
|
|
|
|
},
|
|
|
|
|
|
"metadata": map[string]any{
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"metadata": []any{
|
|
|
|
|
|
map[string]any{"key": "category", "type": "string"},
|
|
|
|
|
|
},
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ext, ok := comp.(*ExtractorComponent)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected *ExtractorComponent, got %T", comp)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if ext.Param.Keywords.TopN != 5 {
|
2026-08-18 11:38:52 +08:00
|
|
|
|
t.Errorf("Keywords config mismatch: %+v", ext.Param.Keywords)
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if ext.Param.Questions.TopN != 3 {
|
2026-08-18 11:38:52 +08:00
|
|
|
|
t.Errorf("Questions config mismatch: %+v", ext.Param.Questions)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ext.Param.Tags.TopN != 2 || ext.Param.Tags.TagFileID != "tag_file_abc" {
|
|
|
|
|
|
t.Errorf("Tags config mismatch: %+v", ext.Param.Tags)
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if !ext.Param.Summary.Enabled {
|
2026-08-18 11:38:52 +08:00
|
|
|
|
t.Errorf("Summary config mismatch: %+v", ext.Param.Summary)
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if !ext.Param.Metadata.Enabled || len(ext.Param.Metadata.Metadata) != 1 {
|
|
|
|
|
|
t.Errorf("Metadata config mismatch: %+v", ext.Param.Metadata)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorModularPromptsExecution(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "kw1, kw2"},
|
|
|
|
|
|
stubResponse{Content: "question 1?\nquestion 2?"},
|
|
|
|
|
|
stubResponse{Content: "Summary of chunk"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"keywords": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"top_n": 2,
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"questions": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"top_n": 2,
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"summary": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"enabled": true,
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Hello world content"},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk output, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ck := chunks[0]
|
|
|
|
|
|
if kwds, ok := ck["important_kwd"].([]string); !ok || len(kwds) != 2 {
|
|
|
|
|
|
t.Errorf("expected important_kwd = [kw1, kw2], got %v", ck["important_kwd"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if qs, ok := ck["question_kwd"].([]string); !ok || len(qs) != 2 {
|
|
|
|
|
|
t.Errorf("expected question_kwd = [question 1?, question 2?], got %v", ck["question_kwd"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if sum, ok := ck["summary"].(string); !ok || sum != "Summary of chunk" {
|
|
|
|
|
|
t.Errorf("expected summary = 'Summary of chunk', got %v", ck["summary"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reqs := stub.requests
|
|
|
|
|
|
if len(reqs) != 3 {
|
|
|
|
|
|
t.Fatalf("expected 3 LLM calls, got %d", len(reqs))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorModularMetadataConfig(t *testing.T) {
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"metadata": map[string]any{
|
2026-08-18 11:38:52 +08:00
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"metadata": []any{
|
|
|
|
|
|
map[string]any{
|
|
|
|
|
|
"key": "author",
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
"description": "The author name",
|
|
|
|
|
|
"enum": []any{"Alice", "Bob"},
|
|
|
|
|
|
},
|
|
|
|
|
|
map[string]any{
|
|
|
|
|
|
"key": "year",
|
|
|
|
|
|
"type": "integer",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"built_in_metadata": []any{
|
|
|
|
|
|
map[string]any{
|
|
|
|
|
|
"key": "file_name",
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ext, ok := comp.(*ExtractorComponent)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected *ExtractorComponent, got %T", comp)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if !ext.Param.Metadata.Enabled {
|
|
|
|
|
|
t.Errorf("Metadata enabled mismatch: %+v", ext.Param.Metadata.Enabled)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if len(ext.Param.Metadata.Metadata) != 2 {
|
|
|
|
|
|
t.Fatalf("Metadata fields mismatch: %d", len(ext.Param.Metadata.Metadata))
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if ext.Param.Metadata.Metadata[0].Key != "author" || ext.Param.Metadata.Metadata[0].Description != "The author name" || len(ext.Param.Metadata.Metadata[0].Enum) != 2 {
|
|
|
|
|
|
t.Errorf("Metadata field 0 mismatch: %+v", ext.Param.Metadata.Metadata[0])
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-20 15:35:51 +08:00
|
|
|
|
if ext.Param.Metadata.Metadata[1].Key != "year" {
|
|
|
|
|
|
t.Errorf("Metadata field 1 mismatch: %+v", ext.Param.Metadata.Metadata[1])
|
|
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if len(ext.Param.Metadata.BuiltInMetadata) != 1 || ext.Param.Metadata.BuiltInMetadata[0].Key != "file_name" {
|
|
|
|
|
|
t.Errorf("BuiltInMetadata mismatch: %+v", ext.Param.Metadata.BuiltInMetadata)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorModularMetadataExecution(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: `{"author": "Alice", "year": 2026}`},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"metadata": map[string]any{
|
2026-08-18 11:38:52 +08:00
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"metadata": []any{
|
|
|
|
|
|
map[string]any{
|
|
|
|
|
|
"key": "author",
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
},
|
|
|
|
|
|
map[string]any{
|
|
|
|
|
|
"key": "year",
|
|
|
|
|
|
"type": "integer",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Written by Alice in 2026."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk output, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ck := chunks[0]
|
|
|
|
|
|
meta, ok := ck["metadata"].(map[string]any)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
t.Fatalf("expected chunk metadata map, got %T: %v", ck["metadata"], ck["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if meta["author"] != "Alice" {
|
|
|
|
|
|
t.Errorf("expected metadata.author = Alice, got %v", meta["author"])
|
|
|
|
|
|
}
|
|
|
|
|
|
if fmt.Sprintf("%v", meta["year"]) != "2026" {
|
|
|
|
|
|
t.Errorf("expected metadata.year = 2026, got %v", meta["year"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorDefaultSummaryPromptInjection(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t, stubResponse{Content: "A concise summary."})
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"summary": map[string]any{
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "This is a detailed paragraph about artificial intelligence."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk output, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if chunks[0]["summary"] != "A concise summary." {
|
|
|
|
|
|
t.Errorf("expected summary = 'A concise summary.', got %v", chunks[0]["summary"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stub.Calls() != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 LLM call, got %d", stub.Calls())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastReq := stub.lastRequest()
|
|
|
|
|
|
msgs := lastReq.Messages
|
|
|
|
|
|
if len(msgs) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 messages (system + user), got %d: %+v", len(msgs), msgs)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if msgs[0].Role != "system" || !strings.Contains(msgs[0].Content, "You are a precise and faithful text summarizer") {
|
|
|
|
|
|
t.Errorf("expected autoSummaryPrompt in system message, got: %+v", msgs[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if msgs[1].Role != "user" || !strings.Contains(msgs[1].Content, "This is a detailed paragraph about artificial intelligence.") {
|
|
|
|
|
|
t.Errorf("expected chunk text in user message, got: %+v", msgs[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
func TestExtractorCustomSummarySystemPrompt(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t, stubResponse{Content: "Custom summary."})
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"summary": map[string]any{
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"system_prompt": "Custom system prompt for summarization.",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Text to summarize."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk output, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if chunks[0]["summary"] != "Custom summary." {
|
|
|
|
|
|
t.Errorf("expected summary = 'Custom summary.', got %v", chunks[0]["summary"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stub.Calls() != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 LLM call, got %d", stub.Calls())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastReq := stub.lastRequest()
|
|
|
|
|
|
msgs := lastReq.Messages
|
|
|
|
|
|
if len(msgs) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 messages (system + user), got %d: %+v", len(msgs), msgs)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if msgs[0].Role != "system" || msgs[0].Content != "Custom system prompt for summarization." {
|
|
|
|
|
|
t.Errorf("expected custom system prompt in system message, got: %+v", msgs[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorCustomKeywordsAndQuestionsSystemPrompt(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "custom, keywords"},
|
|
|
|
|
|
stubResponse{Content: "Custom question 1?\nCustom question 2?"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"keywords": map[string]any{
|
|
|
|
|
|
"top_n": 2,
|
|
|
|
|
|
"system_prompt": "Custom keywords system prompt.",
|
|
|
|
|
|
},
|
|
|
|
|
|
"questions": map[string]any{
|
|
|
|
|
|
"top_n": 2,
|
|
|
|
|
|
"system_prompt": "Custom questions system prompt.",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Content text."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk output, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reqs := stub.requests
|
|
|
|
|
|
if len(reqs) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 LLM calls, got %d", len(reqs))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if reqs[0].Messages[0].Content != "Custom keywords system prompt." {
|
|
|
|
|
|
t.Errorf("expected custom keywords system prompt, got: %q", reqs[0].Messages[0].Content)
|
|
|
|
|
|
}
|
|
|
|
|
|
if reqs[1].Messages[0].Content != "Custom questions system prompt." {
|
|
|
|
|
|
t.Errorf("expected custom questions system prompt, got: %q", reqs[1].Messages[0].Content)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 19:25:04 -07:00
|
|
|
|
// TestExtractorTopNPlaceholderSubstitution verifies the {{ topn }} placeholder
|
|
|
|
|
|
// in custom keyword/question system prompts is replaced with the configured
|
|
|
|
|
|
// top_n, so the count slider stays authoritative when the frontend pre-fills
|
|
|
|
|
|
// a prompt.
|
|
|
|
|
|
func TestExtractorTopNPlaceholderSubstitution(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "alpha, beta"},
|
|
|
|
|
|
stubResponse{Content: "q1?\nq2?"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"keywords": map[string]any{
|
|
|
|
|
|
"top_n": 9,
|
|
|
|
|
|
"system_prompt": "Give the top {{ topn }} keywords.",
|
|
|
|
|
|
},
|
|
|
|
|
|
"questions": map[string]any{
|
|
|
|
|
|
"top_n": 7,
|
|
|
|
|
|
"system_prompt": "Propose {{topn}} questions.",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Content text."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := comp.Invoke(t.Context(), nil, in); err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reqs := stub.requests
|
|
|
|
|
|
if len(reqs) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 LLM calls, got %d", len(reqs))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if got := reqs[0].Messages[0].Content; got != "Give the top 9 keywords." {
|
|
|
|
|
|
t.Errorf("expected keywords prompt with top_n=9 substituted, got: %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got := reqs[1].Messages[0].Content; got != "Propose 7 questions." {
|
|
|
|
|
|
t.Errorf("expected questions prompt with top_n=7 substituted, got: %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestExtractorDefaultPromptsRenderTopN verifies the built-in keyword/question
|
|
|
|
|
|
// prompts interpolate the configured top_n when no custom prompt is set.
|
|
|
|
|
|
func TestExtractorDefaultPromptsRenderTopN(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t,
|
|
|
|
|
|
stubResponse{Content: "k1, k2"},
|
|
|
|
|
|
stubResponse{Content: "q1?"},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"keywords": map[string]any{"top_n": 4},
|
|
|
|
|
|
"questions": map[string]any{"top_n": 6},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Content text."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := comp.Invoke(t.Context(), nil, in); err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reqs := stub.requests
|
|
|
|
|
|
if len(reqs) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 LLM calls, got %d", len(reqs))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
kwPrompt := reqs[0].Messages[0].Content
|
|
|
|
|
|
if !strings.Contains(kwPrompt, "top 4 important keywords/phrases") {
|
|
|
|
|
|
t.Errorf("expected built-in keywords prompt with top_n=4, got: %q", kwPrompt)
|
|
|
|
|
|
}
|
|
|
|
|
|
qPrompt := reqs[1].Messages[0].Content
|
|
|
|
|
|
if !strings.Contains(qPrompt, "top 6 important questions") {
|
|
|
|
|
|
t.Errorf("expected built-in questions prompt with top_n=6, got: %q", qPrompt)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(kwPrompt, "{{") || strings.Contains(qPrompt, "{{") {
|
|
|
|
|
|
t.Errorf("expected no leftover placeholders, got keywords=%q questions=%q", kwPrompt, qPrompt)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 11:38:52 +08:00
|
|
|
|
func TestExtractorDisabledSummarySkipsCall(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t, stubResponse{Content: "Not expected"})
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"summary": map[string]any{
|
|
|
|
|
|
"enabled": false,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{"content_with_weight": "Some text."},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if stub.Calls() != 0 {
|
|
|
|
|
|
t.Errorf("expected 0 LLM calls when summary is disabled, got %d", stub.Calls())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk output, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, has := chunks[0]["summary"]; has {
|
|
|
|
|
|
t.Errorf("expected no summary key in chunk, got %v", chunks[0]["summary"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
func TestExtractor_ModularConfiguration(t *testing.T) {
|
2026-08-18 11:38:52 +08:00
|
|
|
|
paramsDisabled := map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"metadata": map[string]any{
|
2026-08-18 11:38:52 +08:00
|
|
|
|
"enabled": false,
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"fields": []any{
|
2026-08-18 11:38:52 +08:00
|
|
|
|
map[string]any{"key": "category", "type": "string"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"summary": map[string]any{
|
|
|
|
|
|
"enabled": false,
|
|
|
|
|
|
},
|
|
|
|
|
|
"keywords": map[string]any{
|
|
|
|
|
|
"top_n": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
"questions": map[string]any{
|
|
|
|
|
|
"top_n": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
"tags": map[string]any{
|
|
|
|
|
|
"top_n": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
compRawA, err := NewExtractorComponent(paramsDisabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent A: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
compA := compRawA.(*ExtractorComponent)
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compA.Param.Metadata.Enabled != false {
|
|
|
|
|
|
t.Errorf("expected metadata disabled, got %v", compA.Param.Metadata.Enabled)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compA.Param.Summary.Enabled != false {
|
|
|
|
|
|
t.Errorf("expected summary disabled, got %v", compA.Param.Summary.Enabled)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compA.Param.Keywords.TopN != 0 {
|
|
|
|
|
|
t.Errorf("expected keywords disabled (0), got %v", compA.Param.Keywords.TopN)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compA.Param.Questions.TopN != 0 {
|
|
|
|
|
|
t.Errorf("expected questions disabled (0), got %v", compA.Param.Questions.TopN)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compA.Param.Tags.TopN != 0 {
|
|
|
|
|
|
t.Errorf("expected tags disabled (0), got %v", compA.Param.Tags.TopN)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 15:02:46 +08:00
|
|
|
|
// Explicitly enabled
|
2026-08-18 11:38:52 +08:00
|
|
|
|
paramsEnabled := map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"metadata": map[string]any{
|
2026-08-18 11:38:52 +08:00
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"metadata": []any{
|
|
|
|
|
|
map[string]any{"key": "author", "type": "string"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"summary": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"system_prompt": "Custom summary prompt",
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"keywords": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"top_n": 4,
|
|
|
|
|
|
"system_prompt": "Custom keywords prompt",
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"questions": map[string]any{
|
2026-08-19 15:02:46 +08:00
|
|
|
|
"top_n": 2,
|
|
|
|
|
|
"system_prompt": "Custom questions prompt",
|
2026-08-18 11:38:52 +08:00
|
|
|
|
},
|
|
|
|
|
|
"tags": map[string]any{
|
|
|
|
|
|
"top_n": 3,
|
|
|
|
|
|
"tag_file_id": "file-123",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
compRawB, err := NewExtractorComponent(paramsEnabled)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent B: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
compB := compRawB.(*ExtractorComponent)
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compB.Param.Metadata.Enabled != true {
|
|
|
|
|
|
t.Errorf("expected metadata enabled, got %v", compB.Param.Metadata.Enabled)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(compB.Param.Metadata.Metadata) != 1 || compB.Param.Metadata.Metadata[0].Key != "author" {
|
|
|
|
|
|
t.Errorf("expected metadata fields with author, got %+v", compB.Param.Metadata.Metadata)
|
|
|
|
|
|
}
|
|
|
|
|
|
if compB.Param.Summary.Enabled != true || compB.Param.Summary.SystemPrompt != "Custom summary prompt" {
|
|
|
|
|
|
t.Errorf("expected summary enabled with custom prompt, got %+v", compB.Param.Summary)
|
|
|
|
|
|
}
|
|
|
|
|
|
if compB.Param.Keywords.TopN != 4 || compB.Param.Keywords.SystemPrompt != "Custom keywords prompt" {
|
|
|
|
|
|
t.Errorf("expected keywords 4 with custom prompt, got %+v", compB.Param.Keywords)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compB.Param.Questions.TopN != 2 || compB.Param.Questions.SystemPrompt != "Custom questions prompt" {
|
|
|
|
|
|
t.Errorf("expected questions 2 with custom prompt, got %+v", compB.Param.Questions)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
if compB.Param.Tags.TopN != 3 || compB.Param.Tags.TagFileID != "file-123" {
|
|
|
|
|
|
t.Errorf("expected tags 3 / file-123, got %+v", compB.Param.Tags)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractor_ParseMetadataFieldDefs_MapSlice(t *testing.T) {
|
|
|
|
|
|
inputMapSlice := []map[string]any{
|
|
|
|
|
|
{"key": "author", "type": "string", "description": "Author name"},
|
|
|
|
|
|
}
|
|
|
|
|
|
defs := parseMetadataFieldDefs(inputMapSlice)
|
|
|
|
|
|
if len(defs) != 1 || defs[0].Key != "author" || defs[0].Type != "string" || defs[0].Description != "Author name" {
|
|
|
|
|
|
t.Errorf("parseMetadataFieldDefs failed for []map[string]any: %#v", defs)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inputDefs := []common.MetadataFieldDef{
|
|
|
|
|
|
{Key: "tag", Type: "string"},
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
2026-08-19 15:02:46 +08:00
|
|
|
|
directDefs := parseMetadataFieldDefs(inputDefs)
|
|
|
|
|
|
if len(directDefs) != 1 || directDefs[0].Key != "tag" {
|
|
|
|
|
|
t.Errorf("parseMetadataFieldDefs failed for []common.MetadataFieldDef: %#v", directDefs)
|
2026-08-18 11:38:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-20 15:35:51 +08:00
|
|
|
|
|
|
|
|
|
|
func TestExtractorBuiltInDoesNotCallLLM(t *testing.T) {
|
|
|
|
|
|
// 1b39355c regressed by making runEnableMetadata fire when only built_in_metadata was configured.
|
|
|
|
|
|
// With the modular shape, BuiltInMetadata must never trigger an LLM call; it is applied by the finalizer.
|
|
|
|
|
|
stub := withStubChatInvoker(t)
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"metadata": map[string]any{
|
|
|
|
|
|
"enabled": true,
|
|
|
|
|
|
"built_in_metadata": []any{
|
|
|
|
|
|
map[string]any{"key": "file_name", "type": "string"},
|
|
|
|
|
|
map[string]any{"key": "update_time", "type": "time"},
|
|
|
|
|
|
},
|
|
|
|
|
|
"metadata": []any{},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{{"text": "hello world"}},
|
|
|
|
|
|
}
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if stub.calls.Load() != 0 {
|
|
|
|
|
|
t.Fatalf("built_in-only must not call LLM, got %d calls, requests=%v", stub.calls.Load(), stub.requests)
|
|
|
|
|
|
}
|
|
|
|
|
|
chunks, _ := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := chunks[0]["metadata"]; ok {
|
|
|
|
|
|
t.Fatalf("built_in must not produce chunk metadata, got %v", chunks[0]["metadata"])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestExtractorEnabledFalseDoesNotCallLLM(t *testing.T) {
|
|
|
|
|
|
stub := withStubChatInvoker(t)
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"metadata": map[string]any{
|
|
|
|
|
|
"enabled": false,
|
|
|
|
|
|
"metadata": []any{
|
|
|
|
|
|
map[string]any{"key": "author", "type": "string"},
|
|
|
|
|
|
},
|
|
|
|
|
|
"built_in_metadata": []any{
|
|
|
|
|
|
map[string]any{"key": "file_name", "type": "string"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
in := map[string]any{"chunks": []map[string]any{{"text": "hello"}}}
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if stub.calls.Load() != 0 {
|
|
|
|
|
|
t.Fatalf("enabled=false must not call LLM, got %d", stub.calls.Load())
|
|
|
|
|
|
}
|
|
|
|
|
|
if chunks, _ := out["chunks"].([]map[string]any); len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-24 15:36:32 +08:00
|
|
|
|
|
|
|
|
|
|
func TestExtractor_KeywordsThenTagsSynergy(t *testing.T) {
|
|
|
|
|
|
withStubChatInvoker(t, stubResponse{Content: "Bidding, Procurement"})
|
|
|
|
|
|
|
|
|
|
|
|
params := map[string]any{
|
|
|
|
|
|
"llm_id": "llm-1",
|
|
|
|
|
|
"keywords": map[string]any{
|
|
|
|
|
|
"top_n": 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
comp, err := NewExtractorComponent(params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("NewExtractorComponent: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
in := map[string]any{
|
|
|
|
|
|
"chunks": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"docnm_kwd": "Tender_Notice.pdf",
|
|
|
|
|
|
"content_with_weight": "General bidding notice content.",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out, err := comp.Invoke(t.Context(), nil, in)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("Invoke: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chunks, ok := out["chunks"].([]map[string]any)
|
|
|
|
|
|
if !ok || len(chunks) != 1 {
|
|
|
|
|
|
t.Fatalf("expected 1 chunk, got %v", out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
kwds, ok := chunks[0]["important_kwd"].([]string)
|
|
|
|
|
|
if !ok || len(kwds) != 2 {
|
|
|
|
|
|
t.Fatalf("expected important_kwd populated with 2 keywords, got %v", chunks[0]["important_kwd"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Verify getChunkText on the resulting chunk merges extracted keywords and content without title pollution
|
|
|
|
|
|
chunkText := getChunkText(chunks[0])
|
|
|
|
|
|
if !strings.Contains(chunkText, "Bidding") || !strings.Contains(chunkText, "Procurement") || !strings.Contains(chunkText, "General bidding notice content.") {
|
|
|
|
|
|
t.Fatalf("expected chunk text to contain content and extracted keywords, got %q", chunkText)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(chunkText, "Tender_Notice") {
|
|
|
|
|
|
t.Fatalf("expected chunk text to NOT contain title when content is present, got %q", chunkText)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|