feat: export reproducible retrieval history snapshots

This commit is contained in:
2026-10-16 23:08:13 +08:00
parent 5736ad1924
commit f5cddee937
12 changed files with 800 additions and 3 deletions
+2 -1
View File
@@ -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",
+19
View File
@@ -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,
)
+16 -2
View File
@@ -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') }
}
+70
View File
@@ -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)
})