feat: export reproducible retrieval history snapshots
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:session": "node --test tests/session.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiDownload } from './session'
|
||||
import type { RetrievalRunResult } from '../api'
|
||||
import type { RagContext } from './rag'
|
||||
|
||||
export type ExportSelection = { run_ids: string[]; include_contexts?: boolean }
|
||||
export type ExportManifest = {
|
||||
record_type: 'manifest'; schema_version: 'nalati-retrieval-export-v1'; export_id: string; exported_at: string;
|
||||
selection: Required<ExportSelection>; counts: { retrieval_runs: number; rag_contexts: number }; records_sha256: string;
|
||||
snapshot_semantics: 'stored_history_not_current_corpus';
|
||||
generation_measurements: { status: 'not_called'; usage: null; cost: null; currency: null }
|
||||
}
|
||||
export type ExportRecord = ExportManifest
|
||||
| { record_type: 'retrieval_run'; id: string; ranking_unit: 'knowledge_item' | 'chunk'; payload: RetrievalRunResult }
|
||||
| { record_type: 'rag_context'; id: string; payload: RagContext }
|
||||
|
||||
// Returns bytes/metadata; callers explicitly choose whether and where to save the file.
|
||||
export const exportRetrievalHistory = (payload: ExportSelection) => apiDownload(
|
||||
'/api/retrieval/exports', { method: 'POST', body: JSON.stringify(payload) }, true,
|
||||
)
|
||||
@@ -6,10 +6,10 @@ export class ApiError extends Error {
|
||||
|
||||
export function clearSessionToken() { csrfToken = null }
|
||||
|
||||
export async function apiRequest<T>(path: string, init: RequestInit = {}, protect = false): Promise<T> {
|
||||
async function request(path: string, init: RequestInit, protect: boolean, accept: string): Promise<Response> {
|
||||
if (protect && !csrfToken) await apiRequest('/api/auth/me')
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
headers.set('Accept', accept)
|
||||
if (init.body) headers.set('Content-Type', 'application/json')
|
||||
if (protect && csrfToken) headers.set('X-CSRF-Token', csrfToken)
|
||||
const response = await fetch(path, { ...init, credentials: 'include', headers })
|
||||
@@ -19,6 +19,11 @@ export async function apiRequest<T>(path: string, init: RequestInit = {}, protec
|
||||
const detail = body && typeof body === 'object' && 'detail' in body ? body.detail : null
|
||||
throw new ApiError(response.status, typeof detail === 'string' ? detail : `请求失败(${response.status}),请检查登录状态或填写内容。`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, init: RequestInit = {}, protect = false): Promise<T> {
|
||||
const response = await request(path, init, protect, 'application/json')
|
||||
if (response.status === 204) return undefined as T
|
||||
const data = await response.json()
|
||||
if (path === '/api/auth/login' || path === '/api/auth/me') {
|
||||
@@ -26,3 +31,12 @@ export async function apiRequest<T>(path: string, init: RequestInit = {}, protec
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export type ApiDownload = { blob: Blob; filename: string; recordsSha256: string | null }
|
||||
|
||||
export async function apiDownload(path: string, init: RequestInit = {}, protect = false): Promise<ApiDownload> {
|
||||
const response = await request(path, init, protect, 'application/x-ndjson')
|
||||
const disposition = response.headers.get('Content-Disposition') ?? ''
|
||||
const filename = /^attachment; filename="([A-Za-z0-9._-]+)"$/.exec(disposition)?.[1] ?? 'download.jsonl'
|
||||
return { blob: await response.blob(), filename, recordsSha256: response.headers.get('X-Records-SHA256') }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
async function session(t, fetch_) {
|
||||
const previous = globalThis.fetch
|
||||
globalThis.fetch = fetch_
|
||||
t.after(() => { globalThis.fetch = previous })
|
||||
const source = await readFile(new URL('../src/services/session.ts', import.meta.url), 'utf8')
|
||||
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } })
|
||||
return import(`data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}#${t.name}`)
|
||||
}
|
||||
|
||||
test('JSON login and binary export share Cookie/CSRF transport without parsing JSONL as JSON', async t => {
|
||||
const content = '{"record_type":"manifest"}\n{"query":"合成测试"}\n'
|
||||
const calls = []
|
||||
const api = await session(t, async (path, init) => {
|
||||
calls.push([path, init])
|
||||
if (path === '/api/auth/login') return Response.json({ csrf_token: 'fixture-csrf' })
|
||||
return new Response(content, { headers: { 'Content-Disposition': 'attachment; filename="fixture.jsonl"', 'X-Records-SHA256': 'a'.repeat(64) } })
|
||||
})
|
||||
await api.apiRequest('/api/auth/login', { method: 'POST', body: '{}' })
|
||||
const file = await api.apiDownload('/api/retrieval/exports', { method: 'POST', body: '{"run_ids":[]}' }, true)
|
||||
assert.equal(await file.blob.text(), content)
|
||||
assert.equal(file.filename, 'fixture.jsonl')
|
||||
assert.equal(file.recordsSha256, 'a'.repeat(64))
|
||||
assert.equal(calls[1][1].credentials, 'include')
|
||||
assert.equal(calls[1][1].headers.get('X-CSRF-Token'), 'fixture-csrf')
|
||||
assert.equal(calls[1][1].headers.get('Content-Type'), 'application/json')
|
||||
assert.equal(calls[1][1].headers.get('Accept'), 'application/x-ndjson')
|
||||
assert.equal(calls[0][1].headers.get('Accept'), 'application/json')
|
||||
})
|
||||
|
||||
test('download refreshes missing session token and preserves server authorization errors', async t => {
|
||||
const calls = []
|
||||
const api = await session(t, async (path, init) => {
|
||||
calls.push([path, init])
|
||||
if (path === '/api/auth/me') return Response.json({ csrf_token: 'refreshed-csrf' })
|
||||
return Response.json({ detail: '需要管理员权限' }, { status: 403 })
|
||||
})
|
||||
await assert.rejects(api.apiDownload('/api/retrieval/exports', { method: 'POST', body: '{}' }, true), error => {
|
||||
assert.ok(error instanceof api.ApiError)
|
||||
assert.equal(error.status, 403)
|
||||
assert.equal(error.message, '需要管理员权限')
|
||||
return true
|
||||
})
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(calls[0][0], '/api/auth/me')
|
||||
assert.equal(calls[1][1].headers.get('X-CSRF-Token'), 'refreshed-csrf')
|
||||
})
|
||||
|
||||
test('401 download clears stale token and unsafe filename is not propagated', async t => {
|
||||
let current = 'old-csrf'
|
||||
let failed = false
|
||||
const api = await session(t, async (path, init) => {
|
||||
if (path === '/api/auth/login' || path === '/api/auth/me') return Response.json({ csrf_token: current })
|
||||
assert.equal(init.headers.get('X-CSRF-Token'), current)
|
||||
if (!failed) {
|
||||
failed = true; current = 'new-csrf'
|
||||
return Response.json({ detail: '请先登录' }, { status: 401 })
|
||||
}
|
||||
return new Response('fixture', { headers: { 'Content-Disposition': 'attachment; filename="../../fixture.jsonl"' } })
|
||||
})
|
||||
await api.apiRequest('/api/auth/login', { method: 'POST', body: '{}' })
|
||||
await assert.rejects(api.apiDownload('/api/retrieval/exports', { method: 'POST' }, true), error => error.status === 401)
|
||||
const file = await api.apiDownload('/api/retrieval/exports', { method: 'POST' }, true)
|
||||
assert.equal(file.filename, 'download.jsonl')
|
||||
assert.equal(file.recordsSha256, null)
|
||||
})
|
||||
Reference in New Issue
Block a user