feat: unified query API + deploy metadata

- Add description/tags/examples to deploy params
- Replace /_api/list with /_api/query (public, no auth)
- find mode: precise, detailed, default limit=3
- explore mode: diverse, summary, default limit=20
- Relevance scoring + tag-based dedup for explore
- Delete old list endpoint
This commit is contained in:
2026-04-03 08:02:09 +00:00
parent 3705b158bb
commit 513e84622c
5 changed files with 152 additions and 33 deletions
+30 -1
View File
@@ -4,6 +4,9 @@ export interface DeployParams {
type: 'persistent' | 'normal' | 'ephemeral'
ttl?: number // 秒,仅 ephemeral
bindings?: string[]
description?: string // 一句话描述
tags?: string[] // 标签
examples?: string[] // 用法示例
}
export interface DeployResult {
@@ -23,6 +26,32 @@ export interface Capability {
created_at: number
ttl?: number
expires_at?: string
description?: string
tags?: string[]
examples?: string[]
}
export interface QueryParams {
q?: string
mode?: 'find' | 'explore'
limit?: number
cursor?: string
}
export interface QueryItem {
capability: string
description?: string
tags?: string[]
examples?: string[]
type: 'persistent' | 'normal' | 'ephemeral'
deployed?: boolean
access_count?: number
score: number
}
export interface QueryResult {
total: number
items: QueryItem[]
}
export interface BackendStatus {
@@ -37,7 +66,7 @@ export interface SigilBackend {
deploy(params: DeployParams): Promise<DeployResult>
invoke(name: string, request: Request): Promise<Response>
remove(name: string): Promise<void>
list(): Promise<Capability[]>
query(params: QueryParams): Promise<QueryResult>
inspect(name: string): Promise<Capability | null>
status(): Promise<BackendStatus>
}
+81 -6
View File
@@ -1,7 +1,8 @@
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus } from './types.js'
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
import { KvStore } from '../kv.js'
import { LruScheduler, PageRateLimitError } from '../lru.js'
import { CONFIG } from '../config.js'
import { scoreCapability, applyExploreDedup } from '../scoring.js'
export interface CfApi {
deployWorker(name: string, code: string): Promise<void>
@@ -40,7 +41,7 @@ export class WorkerPool implements SigilBackend {
}
async deploy(params: DeployParams): Promise<DeployResult> {
const { name, code, type, ttl, bindings } = params
const { name, code, type, ttl, bindings, description, tags, examples } = params
// Determine capability name
let capability: string
@@ -90,6 +91,9 @@ export class WorkerPool implements SigilBackend {
ttl,
created_at: now,
bindings,
description,
tags,
examples,
})
await this.kv.setLru(capability, {
last_access: now,
@@ -284,9 +288,17 @@ export class WorkerPool implements SigilBackend {
await this.kv.deleteRoute(capabilityName)
}
async list(): Promise<Capability[]> {
async query(params: QueryParams): Promise<QueryResult> {
const { q, mode: rawMode, limit: rawLimit, cursor } = params
// Determine effective mode
const mode = rawMode ?? (q ? 'find' : 'explore')
const defaultLimit = mode === 'find' ? 3 : 20
const limit = rawLimit ?? defaultLimit
// Fetch all capabilities
const caps = await this.kv.listCapabilities()
const result: Capability[] = []
const allCapabilities: Capability[] = []
for (const cap of caps) {
const meta = await this.kv.getMeta(cap)
@@ -300,6 +312,9 @@ export class WorkerPool implements SigilBackend {
last_access: lru.last_access,
access_count: lru.access_count,
created_at: meta.created_at,
description: meta.description,
tags: meta.tags,
examples: meta.examples,
}
if (meta.ttl !== undefined) {
@@ -307,10 +322,70 @@ export class WorkerPool implements SigilBackend {
capability.expires_at = new Date(meta.created_at + meta.ttl * 1000).toISOString()
}
result.push(capability)
allCapabilities.push(capability)
}
return result
// If mode=find but no q → treat as explore
const effectiveMode = (mode === 'find' && !q) ? 'explore' : mode
let items: QueryItem[]
if (!q) {
// No query — explore mode: sort by created_at descending, return summaries
const sorted = [...allCapabilities].sort((a, b) => b.created_at - a.created_at)
items = sorted.map(cap => ({
capability: cap.capability,
description: cap.description,
type: cap.type,
score: 1.0,
}))
} else {
// Score and filter
const scored = allCapabilities
.map(cap => ({ cap, score: scoreCapability(cap, q) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
if (effectiveMode === 'find') {
items = scored.map(({ cap, score }) => ({
capability: cap.capability,
description: cap.description,
tags: cap.tags,
examples: cap.examples,
type: cap.type,
deployed: cap.deployed,
access_count: cap.access_count,
score,
}))
} else {
// explore: build summary items then apply dedup
const summaryItems: QueryItem[] = scored.map(({ cap, score }) => ({
capability: cap.capability,
description: cap.description,
tags: cap.tags, // keep tags for dedup logic, stripped later
type: cap.type,
score,
}))
const deduped = applyExploreDedup(summaryItems)
.sort((a, b) => b.score - a.score)
// Strip tags/examples from explore output (only capability/description/type/score)
items = deduped.map(({ capability, description, type, score }) => ({
capability,
description,
type,
score,
}))
}
}
// Apply cursor (offset-based paging)
const offset = cursor ? parseInt(cursor, 10) : 0
const total = items.length
const paged = items.slice(offset, offset + limit)
return { total, items: paged }
}
async inspect(capabilityName: string): Promise<Capability | null> {
+3
View File
@@ -9,6 +9,9 @@ export interface KvMetaValue {
ttl?: number
created_at: number
bindings?: string[]
description?: string
tags?: string[]
examples?: string[]
}
export interface KvLruValue {
+19 -15
View File
@@ -30,9 +30,9 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
return handleRemove(request, env)
}
// GET /_api/list
if (method === 'GET' && path === '/_api/list') {
return handleList(request, env)
// GET /_api/query — public, no auth
if (method === 'GET' && path === '/_api/query') {
return handleQuery(request, env)
}
// GET /_api/inspect/{capability}
@@ -68,6 +68,9 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
type: 'persistent' | 'normal' | 'ephemeral'
ttl?: number
bindings?: string[]
description?: string
tags?: string[]
examples?: string[]
}
// Check deploy cooldown
@@ -79,6 +82,9 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
type: body.type,
ttl: body.ttl,
bindings: body.bindings,
description: body.description,
tags: body.tags,
examples: body.examples,
})
// Set cooldown after successful deploy
@@ -114,19 +120,17 @@ async function handleRemove(request: Request, env: RouterEnv): Promise<Response>
}
}
async function handleList(request: Request, env: RouterEnv): Promise<Response> {
try {
const authHeader = request.headers.get('Authorization')
await env.auth.validateToken(authHeader)
async function handleQuery(request: Request, env: RouterEnv): Promise<Response> {
const url = new URL(request.url)
const q = url.searchParams.get('q') ?? undefined
const modeRaw = url.searchParams.get('mode')
const mode = (modeRaw === 'find' || modeRaw === 'explore') ? modeRaw : undefined
const limitRaw = url.searchParams.get('limit')
const limit = limitRaw ? parseInt(limitRaw, 10) : undefined
const cursor = url.searchParams.get('cursor') ?? undefined
const list = await env.backend.list()
return jsonOk({ capabilities: list })
} catch (e) {
if (e instanceof AuthError) {
return jsonError(e.status, e.message)
}
throw e
}
const result = await env.backend.query({ q, mode, limit, cursor })
return jsonOk(result)
}
async function handleInspect(capability: string, env: RouterEnv): Promise<Response> {
+19 -11
View File
@@ -5,7 +5,7 @@ import { AuthModule } from '../src/auth.js'
import { KvStore } from '../src/kv.js'
import { handleRequest } from '../src/router.js'
describe('S7: 列出能力', () => {
describe('S7: 列出能力(已迁移至 query 接口)', () => {
let mockKv: KVNamespace
let mockCf: ReturnType<typeof createMockCfApi>
let pool: WorkerPool
@@ -31,29 +31,37 @@ describe('S7: 列出能力', () => {
}
})
it('should return all capabilities', async () => {
it('/_api/list should return 404 (removed)', async () => {
const req = makeRequest('GET', '/_api/list', {
token: 'deploy-token',
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(404)
})
it('/_api/query should return all capabilities (explore mode)', async () => {
const req = makeRequest('GET', '/_api/query')
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(200)
const body = await resp.json() as { capabilities: Array<{ capability: string }> }
expect(body.capabilities).toHaveLength(3)
const body = await resp.json() as { total: number; items: Array<{ capability: string }> }
expect(body.total).toBe(3)
expect(body.items).toHaveLength(3)
const names = body.capabilities.map(c => c.capability)
const names = body.items.map((c: { capability: string }) => c.capability)
expect(names).toContain('ping')
expect(names).toContain('echo')
expect(names).toContain('hello')
})
it('should include capability metadata in response', async () => {
const caps = await pool.list()
expect(caps.length).toBe(3)
for (const cap of caps) {
expect(cap.type).toBe('normal')
expect(cap.deployed).toBe(true)
it('should include capability metadata in query results', async () => {
const result = await pool.query({})
expect(result.total).toBe(3)
for (const item of result.items) {
expect(item.type).toBe('normal')
expect(item.score).toBeGreaterThan(0)
}
})
})