Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

369 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A stand-in WeKnora backend used by the unit tests and by the end-to-end run
* inside dsh. It speaks the same routes, envelopes and SSE event types as the
* real server (see internal/handler/session/qa.go and internal/types/chat.go),
* so a test failure here means the plugin, not the fixture, drifted.
*/
import { createServer } from 'node:http'
const DOCUMENTS = [
{
knowledge_id: 'doc-retrieval-pipeline',
knowledge_title: 'WeKnora 检索流程.md',
knowledge_base_id: 'kb-product',
// WeKnora stores the generated summary in `description`, not `summary`.
description: '讲解 WeKnora 混合检索的召回、阈值与父子分块回溯。',
chunks: [
'WeKnora 的混合检索先做向量召回,再做关键词召回,最后交给 rerank 模型统一排序。',
'默认的 vector_threshold 是 0.5,keyword_threshold 是 0.3,可以在知识库配置里按库调整。',
'父子分块开启后,命中子块会回溯父块,把更完整的上下文交给生成模型。',
],
},
{
knowledge_id: 'doc-architecture',
knowledge_title: '系统架构图.md',
knowledge_base_id: 'kb-product',
description: '检索与生成两层的系统架构示意图。',
chunks: [
'系统由检索与生成两层组成,整体结构见架构图。\n\n![系统架构](resource://AbCdEfGhIjKlMnOpQrStUv)',
],
},
{
knowledge_id: 'doc-deployment',
knowledge_title: 'WeKnora 部署手册.md',
knowledge_base_id: 'kb-ops',
description: '单机与 Kubernetes 部署方式,以及可选的向量库后端。',
chunks: [
'WeKnora 支持 docker compose 单机部署,也提供 Helm chart 用于 Kubernetes。',
'向量库可以选择 pgvector、Milvus、Qdrant、Weaviate、OpenSearch 等后端。',
],
},
{
// Title and body share no wording on purpose: retrieving this document by
// name is the only way to reach it, which is what the by-name search is for.
knowledge_id: 'doc-release-checklist',
knowledge_title: '灰度发布检查单.md',
knowledge_base_id: 'kb-ops',
description: '上线前的确认项与回滚准备。',
chunks: [
'上线前需确认监控告警、回滚预案与数据库变更脚本均已就绪,并由值班同学复核。',
],
},
]
// Titled so a by-name query matches it while its text shares nothing with that
// query — the case passage retrieval alone cannot serve.
DOCUMENTS.push({
knowledge_id: 'doc-release-checklist',
knowledge_title: '灰度发布检查单.md',
knowledge_base_id: 'kb-ops',
description: '上线前的确认项与回滚方案。',
chunks: ['本文列出上线前需要确认的配置项与回滚方案。'],
})
const KNOWLEDGE_BASES = [
{ id: 'kb-product', name: 'Product docs', description: 'WeKnora 产品与检索文档' },
{ id: 'kb-ops', name: 'Ops runbooks', description: '部署与运维手册' },
]
const ARCH_HANDLE = 'resource://AbCdEfGhIjKlMnOpQrStUv'
const ARCH_PUBLIC = 'https://cdn.example.com/architecture.png'
/** Mirror WeKnora's `resource_urls=public` rewrite of `resource://` handles. */
function rewriteResources(value, publicMode) {
if (!publicMode) return value
return JSON.parse(JSON.stringify(value).replaceAll(ARCH_HANDLE, ARCH_PUBLIC))
}
/**
* Score a passage by character-bigram overlap. The real backend scores with
* embeddings plus keyword match; bigrams are enough to make the fixture rank
* plausibly for both Chinese and English queries.
*/
function scoreOf(query, content) {
const normalized = query.replace(/[\s,,。??、!!::;;“”"']+/g, '')
const shingles = new Set()
for (let index = 0; index + 2 <= normalized.length; index += 1) {
shingles.add(normalized.slice(index, index + 2))
}
if (shingles.size === 0) return 0
const hits = [...shingles].filter(shingle => content.includes(shingle)).length
return Math.min(0.99, 0.4 + (hits / shingles.size) * 0.5)
}
function searchResults(query, knowledgeBaseIds, knowledgeIds) {
const documents = DOCUMENTS.filter(document =>
(knowledgeIds.length === 0 || knowledgeIds.includes(document.knowledge_id))
&& (knowledgeBaseIds.length === 0 || knowledgeBaseIds.includes(document.knowledge_base_id)))
return documents
.flatMap(document => document.chunks.map((content, index) => ({
id: `${document.knowledge_id}-chunk-${index}`,
content,
knowledge_id: document.knowledge_id,
knowledge_title: document.knowledge_title,
chunk_index: index,
score: scoreOf(query, content),
match_type: 2,
start_at: 0,
end_at: content.length,
metadata: {},
})))
.filter(result => result.score > 0.4)
.sort((left, right) => right.score - left.score)
}
/** The document shape the by-name search and `GET /knowledge/:id` both return. */
function documentRecord(document) {
return {
id: document.knowledge_id,
title: document.knowledge_title,
file_name: document.knowledge_title,
file_type: 'md',
description: document.description,
knowledge_base_id: document.knowledge_base_id,
knowledge_base_name: KNOWLEDGE_BASES.find(kb => kb.id === document.knowledge_base_id)?.name ?? '',
parse_status: 'completed',
summary_status: 'completed',
}
}
/** Assemble the answer the fake RAG/agent pipeline streams back. */
function answerFor(query, results) {
if (results.length === 0) return `没有检索到与「${query}」相关的内容。`
const cited = results.slice(0, 2).map(result => result.content).join(' ')
return `根据知识库内容:${cited}(问题:${query})`
}
/**
* Start the mock backend.
* @param options.apiKey - when set, requests must carry it as `X-API-Key`.
* @param options.streamError - make the chat routes stream an `error` event.
* @param options.streamTruncated - end the chat stream mid-answer, with no `complete`.
* @param options.forbidPublicResourceUrls - reject `resource_urls=public` with the
* 403 WeKnora returns for a knowledge-base-restricted API key.
* @returns the base URL, the recorded requests, and a close function.
*/
export async function startMockWeknora(options = {}) {
const requests = []
// Paths forced to fail, so a test can prove the plugin degrades rather than
// breaks when a deployment does not serve one of the optional routes.
const failures = new Map()
const server = createServer((request, response) => {
const url = new URL(request.url, 'http://mock.invalid')
const chunks = []
request.on('data', chunk => chunks.push(chunk))
request.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8')
let body = {}
if (rawBody !== '') {
try {
body = JSON.parse(rawBody)
} catch {
body = { _unparsed: rawBody }
}
}
requests.push({
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
headers: request.headers,
body,
})
const json = (status, payload) => {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(payload))
}
if (options.apiKey !== undefined && request.headers['x-api-key'] !== options.apiKey) {
json(401, { success: false, error: { message: 'invalid api key' } })
return
}
if (options.forbidPublicResourceUrls === true && url.searchParams.get('resource_urls') === 'public') {
json(403, {
success: false,
error: { message: 'resource_urls=public is not available for a knowledge-base-restricted API key' },
})
return
}
const publicMode = url.searchParams.get('resource_urls') === 'public'
const forced = failures.get(url.pathname)
if (forced !== undefined) {
json(forced, { success: false, error: { message: `forced failure for ${url.pathname}` } })
return
}
if (request.method === 'GET' && url.pathname === '/api/v1/knowledge-bases') {
json(200, { success: true, data: KNOWLEDGE_BASES })
return
}
// By-name document search. Spans every knowledge base and takes no
// scope, matching the real /knowledge/search route.
if (request.method === 'GET' && url.pathname === '/api/v1/knowledge/search') {
const keyword = (url.searchParams.get('keyword') ?? url.searchParams.get('query') ?? '').trim()
if (keyword === '') {
json(400, { success: false, error: { message: 'missing search keyword: pass ?keyword=... or ?query=...' } })
return
}
const limit = Number(url.searchParams.get('limit') ?? '20')
json(200, {
success: true,
data: DOCUMENTS
.filter(document => document.knowledge_title.toLowerCase().includes(keyword.toLowerCase()))
.slice(0, limit)
.map(document => documentRecord(document)),
})
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/v1/knowledge/')) {
const knowledgeId = decodeURIComponent(url.pathname.slice('/api/v1/knowledge/'.length))
const document = DOCUMENTS.find(entry => entry.knowledge_id === knowledgeId)
if (document === undefined) {
json(404, { success: false, error: { message: 'knowledge not found' } })
return
}
json(200, { success: true, data: documentRecord(document) })
return
}
if (request.method === 'POST' && url.pathname === '/api/v1/knowledge-search') {
if (typeof body.query !== 'string' || body.query === '') {
json(400, { success: false, error: { message: 'Query content cannot be empty' } })
return
}
// Mirror the real handler, which refuses an unscoped retrieval
// (internal/handler/session/qa.go). Answering one here would let the
// plugin ship a call every deployment rejects.
if ((body.knowledge_base_ids ?? []).length === 0
&& (body.knowledge_ids ?? []).length === 0
&& (body.tag_ids ?? []).length === 0) {
json(400, {
success: false,
error: {
message: 'At least one knowledge_base_id, knowledge_base_ids, knowledge_ids, or scoped tag must be provided',
},
})
return
}
json(200, {
success: true,
data: rewriteResources(
searchResults(body.query, body.knowledge_base_ids ?? [], body.knowledge_ids ?? []),
publicMode,
),
})
return
}
if (request.method === 'GET' && url.pathname.startsWith('/api/v1/chunks/')) {
const knowledgeId = decodeURIComponent(url.pathname.slice('/api/v1/chunks/'.length))
const document = DOCUMENTS.find(entry => entry.knowledge_id === knowledgeId)
if (document === undefined) {
json(404, { success: false, error: { message: 'knowledge not found' } })
return
}
const page = Number(url.searchParams.get('page') ?? '1')
const pageSize = Number(url.searchParams.get('page_size') ?? '10')
const start = (page - 1) * pageSize
json(200, {
success: true,
data: document.chunks.slice(start, start + pageSize).map((content, index) => ({
id: `${knowledgeId}-chunk-${start + index}`,
knowledge_id: knowledgeId,
knowledge_base_id: 'kb-product',
content,
chunk_index: start + index,
is_enabled: true,
})),
total: document.chunks.length,
page,
page_size: pageSize,
})
return
}
if (request.method === 'POST' && url.pathname === '/api/v1/sessions') {
json(201, { success: true, data: { id: 'session-mock-1', title: body.title ?? '' } })
return
}
const chatMatch = /^\/api\/v1\/(knowledge-chat|agent-chat)\/(.+)$/.exec(url.pathname)
if (request.method === 'POST' && chatMatch !== null) {
const [, route, sessionId] = chatMatch
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
const send = payload => response.write(`event:message\ndata:${JSON.stringify(payload)}\n\n`)
if (options.streamError === true) {
send({ id: 'e1', response_type: 'error', content: 'model provider unavailable', done: true })
response.end()
return
}
// The RAG route retrieves only what the request scopes: a session holds
// no knowledge base of its own (CreateSessionRequest carries none), so
// WeKnora has no default to fall back on and answers from nothing.
// Retrieving here anyway would hide an unscoped ask from the tests. The
// agent route does have a server-side default, its KBSelectionMode.
const scoped = (body.knowledge_base_ids ?? []).length > 0 || (body.knowledge_ids ?? []).length > 0
const results = rewriteResources(
route === 'agent-chat' || scoped
? searchResults(body.query ?? '', body.knowledge_base_ids ?? [], [])
: [],
publicMode,
)
if (route === 'agent-chat') {
send({
id: 't1',
response_type: 'tool_call',
content: '',
done: false,
session_id: sessionId,
tool_calls: [{ id: 'call-1', function: { name: 'knowledge_search', arguments: '{}' } }],
})
}
send({ id: 'r1', response_type: 'references', content: '', done: false, knowledge_references: results })
const pieces = answerFor(body.query ?? '', results).match(/.{1,24}/gs) ?? []
// A stream cut off mid-answer, which is what a dropped connection or a
// killed backend looks like: answer deltas but no `complete`.
if (options.streamTruncated === true) {
send({ id: 'a1', response_type: 'answer', content: pieces[0] ?? '', done: false, session_id: sessionId })
response.end()
return
}
for (const piece of pieces) {
send({ id: 'a1', response_type: 'answer', content: piece, done: false, session_id: sessionId })
}
send({ id: 'c1', response_type: 'complete', content: '', done: true, session_id: sessionId })
response.end()
return
}
json(404, { success: false, error: { message: `no route for ${request.method} ${url.pathname}` } })
})
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address()
return {
url: `http://127.0.0.1:${port}/api/v1`,
requests,
/** Force `path` to answer `status` from now on. */
fail(path, status) {
failures.set(path, status)
},
async close() {
await new Promise(resolve => server.close(resolve))
},
}
}
export { ARCH_HANDLE, ARCH_PUBLIC, DOCUMENTS, KNOWLEDGE_BASES }