Skip to content

Commit 58acd8b

Browse files
committed
feat(api): honor resource_urls=public on hybrid-search
Single-KB retrieval was still returning resource:// handles even when callers asked for public URLs, unlike knowledge-search.
1 parent e7241e4 commit 58acd8b

14 files changed

Lines changed: 240 additions & 14 deletions

client/knowledgebase.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"net/http"
12+
"net/url"
1213
"time"
1314
)
1415

@@ -407,10 +408,21 @@ type SearchParams struct {
407408
}
408409

409410
// HybridSearch performs hybrid search.
410-
func (c *Client) HybridSearch(ctx context.Context, knowledgeBaseID string, params *SearchParams) ([]*SearchResult, error) {
411+
// Pass ResourceURLOptions to receive public HTTP(S) file URLs in results.
412+
func (c *Client) HybridSearch(
413+
ctx context.Context,
414+
knowledgeBaseID string,
415+
params *SearchParams,
416+
opts ...ResourceURLOptions,
417+
) ([]*SearchResult, error) {
411418
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/hybrid-search", knowledgeBaseID)
412419

413-
resp, err := c.doRequest(ctx, http.MethodPost, path, params, nil)
420+
queryParams := url.Values{}
421+
if len(opts) > 0 {
422+
applyResourceURLQuery(queryParams, &opts[0])
423+
}
424+
425+
resp, err := c.doRequest(ctx, http.MethodPost, path, params, queryParams)
414426
if err != nil {
415427
return nil, err
416428
}

client/resource_urls.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const (
1717
)
1818

1919
// ResourceURLOptions carries the optional resource_urls query parameter shared
20-
// by chat, message-history, and knowledge-search endpoints.
20+
// by chat, message-history, knowledge-search, and hybrid-search endpoints.
2121
type ResourceURLOptions struct {
2222
ResourceURLs ResourceURLMode
2323
}

docs/api/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ X-Request-ID: unique_request_id
8888
- `GET /api/v1/sessions/continue-stream/{session_id}`(SSE)
8989
- `GET /api/v1/messages/{session_id}/load`
9090
- `POST /api/v1/knowledge-search`
91+
- `POST /api/v1/knowledge-bases/{id}/hybrid-search`(兼容 GET)
9192

92-
改写覆盖答案正文、`knowledge_references`(含 `image_info`、Agent 执行步骤与工具结果,以及消息
93+
改写覆盖答案正文、检索结果 `content` / `image_info``knowledge_references`、Agent 执行步骤与工具结果,以及消息
9394
上的图片附件。流式回答里跨两个 chunk 被截断的引用会先缓冲再改写,客户端拿到的始终是完整链接。
9495

9596
### 注意事项

docs/api/chat.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
|------|------|------|
2222
| `resource_urls` | `handle`(默认)/ `public` | `public` 让答案与引用里的图片直接返回可加载的 http(s) 链接,省去逐个调用 `/files` 代理。详见[文件与图片引用](./README.md#文件与图片引用resource-与直链) |
2323

24-
同样适用于下面的 `/agent-chat/:session_id``/knowledge-search``/sessions/continue-stream/:session_id`
24+
同样适用于下面的 `/agent-chat/:session_id``/knowledge-search``/knowledge-bases/:id/hybrid-search``/sessions/continue-stream/:session_id`
2525

2626
**请求参数**
2727

docs/api/knowledge-base.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,10 @@ curl --location --request PUT 'http://localhost:8080/api/v1/knowledge-bases/kb-0
400400
| ---- | ------ | --------- |
401401
| id | string | 知识库 ID |
402402

403+
**查询参数**:
404+
405+
- `resource_urls`: `handle`(默认)或 `public``public` 把检索结果 `content` / `image_info` 里的 `resource://` 引用换成可加载的 http(s) 链接,详见[文件与图片引用](./README.md#文件与图片引用resource-与直链)
406+
403407
**参数说明(请求体)**:
404408

405409
| 字段 | 类型 | 必填 | 说明 |
@@ -419,7 +423,7 @@ curl --location --request PUT 'http://localhost:8080/api/v1/knowledge-bases/kb-0
419423
**请求**:
420424

421425
```curl
422-
curl --location --request POST 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/hybrid-search' \
426+
curl --location --request POST 'http://localhost:8080/api/v1/knowledge-bases/kb-00000001/hybrid-search?resource_urls=public' \
423427
--header 'X-API-Key: sk-xxxxx' \
424428
--header 'Content-Type: application/json' \
425429
--data '{

docs/docs.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4928,6 +4928,17 @@ const docTemplate = `{
49284928
"schema": {
49294929
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.SearchParams"
49304930
}
4931+
},
4932+
{
4933+
"enum": [
4934+
"handle",
4935+
"public"
4936+
],
4937+
"type": "string",
4938+
"default": "handle",
4939+
"description": "文件引用形式,public 返回可加载直链",
4940+
"name": "resource_urls",
4941+
"in": "query"
49314942
}
49324943
],
49334944
"responses": {
@@ -4982,6 +4993,17 @@ const docTemplate = `{
49824993
"schema": {
49834994
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.SearchParams"
49844995
}
4996+
},
4997+
{
4998+
"enum": [
4999+
"handle",
5000+
"public"
5001+
],
5002+
"type": "string",
5003+
"default": "handle",
5004+
"description": "文件引用形式,public 返回可加载直链",
5005+
"name": "resource_urls",
5006+
"in": "query"
49855007
}
49865008
],
49875009
"responses": {

docs/swagger.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4921,6 +4921,17 @@
49214921
"schema": {
49224922
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.SearchParams"
49234923
}
4924+
},
4925+
{
4926+
"enum": [
4927+
"handle",
4928+
"public"
4929+
],
4930+
"type": "string",
4931+
"default": "handle",
4932+
"description": "文件引用形式,public 返回可加载直链",
4933+
"name": "resource_urls",
4934+
"in": "query"
49244935
}
49254936
],
49264937
"responses": {
@@ -4975,6 +4986,17 @@
49754986
"schema": {
49764987
"$ref": "#/definitions/github_com_Tencent_WeKnora_internal_types.SearchParams"
49774988
}
4989+
},
4990+
{
4991+
"enum": [
4992+
"handle",
4993+
"public"
4994+
],
4995+
"type": "string",
4996+
"default": "handle",
4997+
"description": "文件引用形式,public 返回可加载直链",
4998+
"name": "resource_urls",
4999+
"in": "query"
49785000
}
49795001
],
49805002
"responses": {

docs/swagger.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9349,6 +9349,14 @@ paths:
93499349
required: true
93509350
schema:
93519351
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.SearchParams'
9352+
- default: handle
9353+
description: 文件引用形式,public 返回可加载直链
9354+
enum:
9355+
- handle
9356+
- public
9357+
in: query
9358+
name: resource_urls
9359+
type: string
93529360
produces:
93539361
- application/json
93549362
responses:
@@ -9383,6 +9391,14 @@ paths:
93839391
required: true
93849392
schema:
93859393
$ref: '#/definitions/github_com_Tencent_WeKnora_internal_types.SearchParams'
9394+
- default: handle
9395+
description: 文件引用形式,public 返回可加载直链
9396+
enum:
9397+
- handle
9398+
- public
9399+
in: query
9400+
name: resource_urls
9401+
type: string
93869402
produces:
93879403
- application/json
93889404
responses:

internal/handler/knowledgebase.go

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/Tencent/WeKnora/internal/errors"
1616
apperrors "github.com/Tencent/WeKnora/internal/errors"
1717
"github.com/Tencent/WeKnora/internal/logger"
18+
"github.com/Tencent/WeKnora/internal/storageurl"
1819
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
1920
"github.com/Tencent/WeKnora/internal/types"
2021
"github.com/Tencent/WeKnora/internal/types/interfaces"
@@ -35,6 +36,11 @@ type KnowledgeBaseHandler struct {
3536
// userService 仅在 list 类接口里用于批量回填 creator_name;
3637
// 真正的鉴权由 RBAC 中间件 + Lookup 完成,这里不参与决策。
3738
userService interfaces.UserService
39+
// fileService and storageResolver back the optional `resource_urls=public`
40+
// mode on hybrid-search. Both may be nil in tests, in which case only the
41+
// default handle mode is available.
42+
fileService interfaces.FileService
43+
storageResolver interfaces.StorageBackendResolver
3844
}
3945

4046
// NewKnowledgeBaseHandler creates a new knowledge base handler instance
@@ -46,6 +52,8 @@ func NewKnowledgeBaseHandler(
4652
asynqClient interfaces.TaskEnqueuer,
4753
vectorStoreService interfaces.VectorStoreService,
4854
userService interfaces.UserService,
55+
fileService interfaces.FileService,
56+
storageResolver interfaces.StorageBackendResolver,
4957
) *KnowledgeBaseHandler {
5058
return &KnowledgeBaseHandler{
5159
service: service,
@@ -55,9 +63,27 @@ func NewKnowledgeBaseHandler(
5563
asynqClient: asynqClient,
5664
vectorStoreService: vectorStoreService,
5765
userService: userService,
66+
fileService: fileService,
67+
storageResolver: storageResolver,
5868
}
5969
}
6070

71+
// resolveResourceRewriter builds the storage-reference rewriter for one response
72+
// from the request's `resource_urls` parameter, falling back to the deployment
73+
// default. The returned error is already an AppError the caller can hand to
74+
// c.Error: a rejected scope is a 403, a typo in the parameter is a 400.
75+
func (h *KnowledgeBaseHandler) resolveResourceRewriter(c *gin.Context) (*storageurl.Rewriter, error) {
76+
ctx := c.Request.Context()
77+
mode, err := storageurl.ResolveMode(ctx, c.Query(storageurl.QueryParam))
78+
if err != nil {
79+
if stderrors.Is(err, storageurl.ErrPublicModeForbidden) {
80+
return nil, apperrors.NewForbiddenError(err.Error())
81+
}
82+
return nil, apperrors.NewBadRequestError(err.Error())
83+
}
84+
return storageurl.NewRequestRewriter(ctx, mode, h.fileService, h.storageResolver), nil
85+
}
86+
6187
// buildKBResponse turns a knowledge base into a JSON-ready response shape,
6288
// merging the bound vector store's display metadata and any caller-supplied
6389
// extras (e.g., my_permission for shared KBs). Returns the kb pointer
@@ -275,10 +301,11 @@ func (h *KnowledgeBaseHandler) resolveKBStoreView(
275301
// @Tags 知识库
276302
// @Accept json
277303
// @Produce json
278-
// @Param id path string true "知识库ID"
279-
// @Param request body types.SearchParams true "搜索参数"
280-
// @Success 200 {object} map[string]interface{} "搜索结果"
281-
// @Failure 400 {object} errors.AppError "请求参数错误"
304+
// @Param id path string true "知识库ID"
305+
// @Param request body types.SearchParams true "搜索参数"
306+
// @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle)
307+
// @Success 200 {object} map[string]interface{} "搜索结果"
308+
// @Failure 400 {object} errors.AppError "请求参数错误"
282309
// @Security Bearer
283310
// @Security ApiKeyAuth
284311
// @Router /knowledge-bases/{id}/hybrid-search [post]
@@ -311,6 +338,14 @@ func (h *KnowledgeBaseHandler) HybridSearch(c *gin.Context) {
311338
logger.Infof(ctx, "Executing hybrid search, knowledge base ID: %s, query: %s, effectiveTenantID: %d",
312339
secutils.SanitizeForLog(id), secutils.SanitizeForLog(req.QueryText), effectiveTenantID)
313340

341+
// Resolve before retrieving so a typo or a rejected scope costs nothing.
342+
rewriter, err := h.resolveResourceRewriter(c)
343+
if err != nil {
344+
logger.Warnf(ctx, "Rejected resource URL mode: %v", err)
345+
_ = c.Error(err)
346+
return
347+
}
348+
314349
// Execute hybrid search with default search parameters
315350
// Note: For shared KBs, the service uses effectiveTenantID internally via context
316351
results, err := h.service.HybridSearch(ctx, id, req)
@@ -333,7 +368,7 @@ func (h *KnowledgeBaseHandler) HybridSearch(c *gin.Context) {
333368
secutils.SanitizeForLog(id), len(results))
334369
c.JSON(http.StatusOK, gin.H{
335370
"success": true,
336-
"data": results,
371+
"data": rewriter.CopyReferences(ctx, results),
337372
})
338373
}
339374

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package handler
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/Tencent/WeKnora/internal/middleware"
15+
"github.com/Tencent/WeKnora/internal/types"
16+
"github.com/Tencent/WeKnora/internal/types/interfaces"
17+
)
18+
19+
func newHybridSearchResourceURLRouter(svc interfaces.KnowledgeBaseService, fileSvc interfaces.FileService) *gin.Engine {
20+
gin.SetMode(gin.TestMode)
21+
router := gin.New()
22+
router.Use(middleware.ErrorHandler())
23+
router.Use(func(c *gin.Context) {
24+
c.Set(types.TenantIDContextKey.String(), uint64(1))
25+
c.Set(types.UserIDContextKey.String(), "u-test")
26+
c.Next()
27+
})
28+
h := &KnowledgeBaseHandler{service: svc, fileService: fileSvc}
29+
router.POST("/knowledge-bases/:id/hybrid-search", h.HybridSearch)
30+
router.GET("/knowledge-bases/:id/hybrid-search", h.HybridSearch)
31+
return router
32+
}
33+
34+
func performHybridSearchResourceURLRequest(
35+
svc interfaces.KnowledgeBaseService,
36+
fileSvc interfaces.FileService,
37+
method, query, body string,
38+
) *httptest.ResponseRecorder {
39+
response := httptest.NewRecorder()
40+
request := httptest.NewRequest(
41+
method,
42+
"/knowledge-bases/kb-1/hybrid-search"+query,
43+
bytes.NewBufferString(body),
44+
)
45+
request.Header.Set("Content-Type", "application/json")
46+
newHybridSearchResourceURLRouter(svc, fileSvc).ServeHTTP(response, request)
47+
return response
48+
}
49+
50+
func TestHybridSearch_PublicResourceURLs(t *testing.T) {
51+
svc := &hybridSearchTestService{
52+
results: []*types.SearchResult{{
53+
Content: "chunk ![c](" + testResourceHandle + ")",
54+
ImageInfo: `[{"url":"` + testResourceHandle + `"}]`,
55+
}},
56+
}
57+
fileSvc := &stubResourceFileService{url: "https://cdn.example.com/signed.png"}
58+
59+
for _, method := range []string{http.MethodPost, http.MethodGet} {
60+
t.Run(method, func(t *testing.T) {
61+
response := performHybridSearchResourceURLRequest(
62+
svc, fileSvc, method, "?resource_urls=public",
63+
`{"query_text":"diagram"}`,
64+
)
65+
66+
require.Equal(t, http.StatusOK, response.Code, "body=%s", response.Body.String())
67+
assert.NotContains(t, response.Body.String(), testResourceHandle)
68+
assert.Contains(t, response.Body.String(), "cdn.example.com")
69+
})
70+
}
71+
}
72+
73+
func TestHybridSearch_InvalidResourceURLMode(t *testing.T) {
74+
svc := &hybridSearchTestService{}
75+
response := performHybridSearchResourceURLRequest(
76+
svc, &stubResourceFileService{url: "https://cdn.example.com/signed.png"},
77+
http.MethodPost, "?resource_urls=signed",
78+
`{"query_text":"diagram"}`,
79+
)
80+
81+
require.Equal(t, http.StatusBadRequest, response.Code, "body=%s", response.Body.String())
82+
assert.Contains(t, response.Body.String(), "resource_urls")
83+
assert.Equal(t, 0, svc.searchCalls, "invalid mode must not reach HybridSearch")
84+
}
85+
86+
func TestHybridSearch_DefaultKeepsHandles(t *testing.T) {
87+
svc := &hybridSearchTestService{
88+
results: []*types.SearchResult{{
89+
Content: "chunk ![c](" + testResourceHandle + ")",
90+
}},
91+
}
92+
response := performHybridSearchResourceURLRequest(
93+
svc, &stubResourceFileService{url: "https://cdn.example.com/signed.png"},
94+
http.MethodPost, "",
95+
`{"query_text":"diagram"}`,
96+
)
97+
98+
require.Equal(t, http.StatusOK, response.Code, "body=%s", response.Body.String())
99+
100+
var resp struct {
101+
Data []struct {
102+
Content string `json:"content"`
103+
} `json:"data"`
104+
}
105+
require.NoError(t, json.Unmarshal(response.Body.Bytes(), &resp))
106+
require.Len(t, resp.Data, 1)
107+
assert.Contains(t, resp.Data[0].Content, testResourceHandle)
108+
}

0 commit comments

Comments
 (0)