feat: persist single evaluation history
This commit is contained in:
@@ -1554,6 +1554,27 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
except EvaluationError as exc:
|
except EvaluationError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@app.get('/api/evaluations/analyses')
|
||||||
|
async def list_evaluation_analyses(
|
||||||
|
account_id: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return state.evaluation_runtime.list_single_analyses(
|
||||||
|
_safe_account_id(account_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get('/api/evaluations/analyses/{analysis_id}')
|
||||||
|
async def get_evaluation_analysis(
|
||||||
|
analysis_id: str,
|
||||||
|
account_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return state.evaluation_runtime.get_single_analysis(
|
||||||
|
analysis_id,
|
||||||
|
_safe_account_id(account_id),
|
||||||
|
)
|
||||||
|
except EvaluationError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.get('/api/evaluations/datasets')
|
@app.get('/api/evaluations/datasets')
|
||||||
async def list_evaluation_datasets(account_id: str) -> list[dict[str, Any]]:
|
async def list_evaluation_datasets(account_id: str) -> list[dict[str, Any]]:
|
||||||
return state.evaluation_runtime.list_datasets(_safe_account_id(account_id))
|
return state.evaluation_runtime.list_datasets(_safe_account_id(account_id))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
RotateCcwIcon,
|
RotateCcwIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -122,6 +123,8 @@ type EvaluationCase = {
|
|||||||
skill_name?: string;
|
skill_name?: string;
|
||||||
skill_version?: string;
|
skill_version?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
created_at?: number;
|
||||||
|
updated_at?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Experiment = {
|
type Experiment = {
|
||||||
@@ -213,6 +216,8 @@ function EvaluationWorkspace() {
|
|||||||
const [selectedDatasetId, setSelectedDatasetId] = useState("");
|
const [selectedDatasetId, setSelectedDatasetId] = useState("");
|
||||||
const [selectedExperimentId, setSelectedExperimentId] = useState("");
|
const [selectedExperimentId, setSelectedExperimentId] = useState("");
|
||||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||||
|
const [singleAnalyses, setSingleAnalyses] = useState<EvaluationCase[]>([]);
|
||||||
|
const [selectedSingleAnalysisId, setSelectedSingleAnalysisId] = useState("");
|
||||||
const [mapping, setMapping] = useState<DatasetMapping>({
|
const [mapping, setMapping] = useState<DatasetMapping>({
|
||||||
fields: {},
|
fields: {},
|
||||||
label_map: {},
|
label_map: {},
|
||||||
@@ -233,6 +238,7 @@ function EvaluationWorkspace() {
|
|||||||
const uploadRef = useRef<HTMLInputElement>(null);
|
const uploadRef = useRef<HTMLInputElement>(null);
|
||||||
const selectedDatasetIdRef = useRef("");
|
const selectedDatasetIdRef = useRef("");
|
||||||
const selectedExperimentIdRef = useRef("");
|
const selectedExperimentIdRef = useRef("");
|
||||||
|
const selectedSingleAnalysisIdRef = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedDatasetIdRef.current = selectedDatasetId;
|
selectedDatasetIdRef.current = selectedDatasetId;
|
||||||
@@ -242,6 +248,10 @@ function EvaluationWorkspace() {
|
|||||||
selectedExperimentIdRef.current = selectedExperimentId;
|
selectedExperimentIdRef.current = selectedExperimentId;
|
||||||
}, [selectedExperimentId]);
|
}, [selectedExperimentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedSingleAnalysisIdRef.current = selectedSingleAnalysisId;
|
||||||
|
}, [selectedSingleAnalysisId]);
|
||||||
|
|
||||||
const selectedDataset = useMemo(
|
const selectedDataset = useMemo(
|
||||||
() => datasets.find((item) => item.id === selectedDatasetId) ?? null,
|
() => datasets.find((item) => item.id === selectedDatasetId) ?? null,
|
||||||
[datasets, selectedDatasetId],
|
[datasets, selectedDatasetId],
|
||||||
@@ -264,12 +274,14 @@ function EvaluationWorkspace() {
|
|||||||
try {
|
try {
|
||||||
const [
|
const [
|
||||||
metadataResponse,
|
metadataResponse,
|
||||||
|
analysisResponse,
|
||||||
datasetResponse,
|
datasetResponse,
|
||||||
experimentResponse,
|
experimentResponse,
|
||||||
modelResponse,
|
modelResponse,
|
||||||
stateResponse,
|
stateResponse,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetch("/api/claw/evaluations/metadata", { cache: "no-store" }),
|
fetch("/api/claw/evaluations/metadata", { cache: "no-store" }),
|
||||||
|
fetch("/api/claw/evaluations/analyses", { cache: "no-store" }),
|
||||||
fetch("/api/claw/evaluations/datasets", { cache: "no-store" }),
|
fetch("/api/claw/evaluations/datasets", { cache: "no-store" }),
|
||||||
fetch("/api/claw/evaluations/experiments", { cache: "no-store" }),
|
fetch("/api/claw/evaluations/experiments", { cache: "no-store" }),
|
||||||
fetch("/api/claw/models", { cache: "no-store" }),
|
fetch("/api/claw/models", { cache: "no-store" }),
|
||||||
@@ -277,6 +289,8 @@ function EvaluationWorkspace() {
|
|||||||
]);
|
]);
|
||||||
const metadataPayload =
|
const metadataPayload =
|
||||||
await readJson<EvaluationMetadata>(metadataResponse);
|
await readJson<EvaluationMetadata>(metadataResponse);
|
||||||
|
const analysisPayload =
|
||||||
|
await readJson<EvaluationCase[]>(analysisResponse);
|
||||||
const datasetPayload = await readJson<Dataset[]>(datasetResponse);
|
const datasetPayload = await readJson<Dataset[]>(datasetResponse);
|
||||||
const experimentPayload =
|
const experimentPayload =
|
||||||
await readJson<Experiment[]>(experimentResponse);
|
await readJson<Experiment[]>(experimentResponse);
|
||||||
@@ -287,11 +301,21 @@ function EvaluationWorkspace() {
|
|||||||
? ((await stateResponse.json()) as ClawState)
|
? ((await stateResponse.json()) as ClawState)
|
||||||
: {};
|
: {};
|
||||||
setMetadata(metadataPayload);
|
setMetadata(metadataPayload);
|
||||||
|
setSingleAnalyses(analysisPayload);
|
||||||
setDatasets(datasetPayload);
|
setDatasets(datasetPayload);
|
||||||
setExperiments(experimentPayload);
|
setExperiments(experimentPayload);
|
||||||
setSkillName(
|
setSkillName(
|
||||||
(current) => current || metadataPayload.default_skill || "label-master",
|
(current) => current || metadataPayload.default_skill || "label-master",
|
||||||
);
|
);
|
||||||
|
const nextSingleAnalysis =
|
||||||
|
analysisPayload.find(
|
||||||
|
(item) => item.id === selectedSingleAnalysisIdRef.current,
|
||||||
|
) ?? analysisPayload[0];
|
||||||
|
if (nextSingleAnalysis) {
|
||||||
|
setSelectedSingleAnalysisId(nextSingleAnalysis.id);
|
||||||
|
setSingleResult((current) => current ?? nextSingleAnalysis);
|
||||||
|
setSingleQuery((current) => current || nextSingleAnalysis.query);
|
||||||
|
}
|
||||||
const nextModels = modelPayload.models ?? [];
|
const nextModels = modelPayload.models ?? [];
|
||||||
setModels(nextModels);
|
setModels(nextModels);
|
||||||
setModel((current) => {
|
setModel((current) => {
|
||||||
@@ -454,7 +478,13 @@ function EvaluationWorkspace() {
|
|||||||
model: model || undefined,
|
model: model || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setSingleResult(await readJson<EvaluationCase>(response));
|
const payload = await readJson<EvaluationCase>(response);
|
||||||
|
setSingleResult(payload);
|
||||||
|
setSelectedSingleAnalysisId(payload.id);
|
||||||
|
setSingleAnalyses((current) => [
|
||||||
|
payload,
|
||||||
|
...current.filter((item) => item.id !== payload.id),
|
||||||
|
]);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(messageFrom(reason));
|
setError(messageFrom(reason));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -462,6 +492,35 @@ function EvaluationWorkspace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectSingleAnalysis(analysisId: string) {
|
||||||
|
setSelectedSingleAnalysisId(analysisId);
|
||||||
|
setBusy("single-history");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/claw/evaluations/analyses/${encodeURIComponent(analysisId)}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
const payload = await readJson<EvaluationCase>(response);
|
||||||
|
setSingleResult(payload);
|
||||||
|
setSingleQuery(payload.query);
|
||||||
|
setSingleAnalyses((current) =>
|
||||||
|
current.map((item) => (item.id === payload.id ? payload : item)),
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(messageFrom(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSingleAnalysis() {
|
||||||
|
setSelectedSingleAnalysisId("");
|
||||||
|
setSingleResult(null);
|
||||||
|
setSingleQuery("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
async function act(action: "start" | "pause" | "resume" | "cancel") {
|
async function act(action: "start" | "pause" | "resume" | "cancel") {
|
||||||
if (!experiment) return;
|
if (!experiment) return;
|
||||||
setBusy(action);
|
setBusy(action);
|
||||||
@@ -617,7 +676,12 @@ function EvaluationWorkspace() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{workspaceMode === "batch" ? (
|
{workspaceMode === "single" ? (
|
||||||
|
<Button size="sm" onClick={newSingleAnalysis}>
|
||||||
|
<PlusIcon />
|
||||||
|
新建单条分析
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -627,7 +691,7 @@ function EvaluationWorkspace() {
|
|||||||
>
|
>
|
||||||
新建批量评测
|
新建批量评测
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -640,6 +704,51 @@ function EvaluationWorkspace() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{workspaceMode === "single" ? (
|
{workspaceMode === "single" ? (
|
||||||
|
<div className="grid min-h-[calc(100dvh-6.5rem)] lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
|
<aside className="border-r bg-muted/15 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between px-2">
|
||||||
|
<span className="font-medium text-xs">单条分析记录</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{singleAnalyses.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{singleAnalyses.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.id}
|
||||||
|
className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${
|
||||||
|
selectedSingleAnalysisId === item.id
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-accent/60"
|
||||||
|
}`}
|
||||||
|
onClick={() => void selectSingleAnalysis(item.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusDot status={item.status} />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-sm">
|
||||||
|
{item.query}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between gap-2 pl-5 text-muted-foreground text-xs">
|
||||||
|
<span className="truncate">
|
||||||
|
{item.prediction || item.skill_name || "分析失败"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{formatTimestamp(item.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!singleAnalyses.length ? (
|
||||||
|
<div className="px-2 py-8 text-center text-muted-foreground text-xs">
|
||||||
|
暂无单条分析
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section className="min-w-0">
|
||||||
<SingleQueryWorkspace
|
<SingleQueryWorkspace
|
||||||
query={singleQuery}
|
query={singleQuery}
|
||||||
setQuery={setSingleQuery}
|
setQuery={setSingleQuery}
|
||||||
@@ -647,6 +756,8 @@ function EvaluationWorkspace() {
|
|||||||
onAnalyze={analyzeSingleQuery}
|
onAnalyze={analyzeSingleQuery}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid min-h-[calc(100dvh-6.5rem)] lg:grid-cols-[260px_minmax(0,1fr)]">
|
<div className="grid min-h-[calc(100dvh-6.5rem)] lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
<aside className="border-r bg-muted/15 p-3">
|
<aside className="border-r bg-muted/15 p-3">
|
||||||
@@ -1752,6 +1863,23 @@ function formatDuration(value: number | null) {
|
|||||||
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}s`;
|
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value?: number) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
if (date.toDateString() === now.toDateString()) {
|
||||||
|
return date.toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildMappedPreview(
|
function buildMappedPreview(
|
||||||
rows: Array<Record<string, unknown>>,
|
rows: Array<Record<string, unknown>>,
|
||||||
mapping: DatasetMapping,
|
mapping: DatasetMapping,
|
||||||
|
|||||||
+191
-3
@@ -205,10 +205,27 @@ class EvaluationStore:
|
|||||||
FOREIGN KEY(experiment_id) REFERENCES evaluation_experiments(id)
|
FOREIGN KEY(experiment_id) REFERENCES evaluation_experiments(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS evaluation_single_analyses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
skill_name TEXT NOT NULL,
|
||||||
|
snapshot_id TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
result_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
error TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY(snapshot_id) REFERENCES skill_snapshots(id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_eval_datasets_account
|
CREATE INDEX IF NOT EXISTS idx_eval_datasets_account
|
||||||
ON evaluation_datasets(account_id, created_at DESC);
|
ON evaluation_datasets(account_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_eval_experiments_account
|
CREATE INDEX IF NOT EXISTS idx_eval_experiments_account
|
||||||
ON evaluation_experiments(account_id, created_at DESC);
|
ON evaluation_experiments(account_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_eval_single_analyses_account
|
||||||
|
ON evaluation_single_analyses(account_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_eval_cases_experiment
|
CREATE INDEX IF NOT EXISTS idx_eval_cases_experiment
|
||||||
ON evaluation_cases(experiment_id, case_index);
|
ON evaluation_cases(experiment_id, case_index);
|
||||||
CREATE INDEX IF NOT EXISTS idx_eval_cases_status
|
CREATE INDEX IF NOT EXISTS idx_eval_cases_status
|
||||||
@@ -231,6 +248,16 @@ class EvaluationStore:
|
|||||||
""",
|
""",
|
||||||
(time.time(),),
|
(time.time(),),
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE evaluation_single_analyses
|
||||||
|
SET status = 'failed',
|
||||||
|
error = COALESCE(error, '服务重启前分析未完成'),
|
||||||
|
updated_at = ?
|
||||||
|
WHERE status = 'running'
|
||||||
|
""",
|
||||||
|
(time.time(),),
|
||||||
|
)
|
||||||
|
|
||||||
def execute(
|
def execute(
|
||||||
self,
|
self,
|
||||||
@@ -478,6 +505,7 @@ class EvaluationRuntime:
|
|||||||
model_name = model or self.model_config_for(account_id).model
|
model_name = model or self.model_config_for(account_id).model
|
||||||
analysis_id = f'single_{uuid4().hex[:16]}'
|
analysis_id = f'single_{uuid4().hex[:16]}'
|
||||||
case_id = f'case_{uuid4().hex[:16]}'
|
case_id = f'case_{uuid4().hex[:16]}'
|
||||||
|
created_at = time.time()
|
||||||
canonical = {
|
canonical = {
|
||||||
'case_id': case_id,
|
'case_id': case_id,
|
||||||
'query': normalized_query,
|
'query': normalized_query,
|
||||||
@@ -488,6 +516,24 @@ class EvaluationRuntime:
|
|||||||
'request_id': request_id.strip(),
|
'request_id': request_id.strip(),
|
||||||
'metadata': metadata or {},
|
'metadata': metadata or {},
|
||||||
}
|
}
|
||||||
|
self.store.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO evaluation_single_analyses (
|
||||||
|
id, account_id, query, skill_name, snapshot_id, model,
|
||||||
|
status, result_json, error, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'running', '{}', NULL, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
analysis_id,
|
||||||
|
account_id,
|
||||||
|
normalized_query,
|
||||||
|
skill_name,
|
||||||
|
snapshot['id'],
|
||||||
|
model_name,
|
||||||
|
created_at,
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
execution = self._execute_case_analysis(
|
execution = self._execute_case_analysis(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
run_id=analysis_id,
|
run_id=analysis_id,
|
||||||
@@ -503,9 +549,24 @@ class EvaluationRuntime:
|
|||||||
cancel_event=threading.Event(),
|
cancel_event=threading.Event(),
|
||||||
)
|
)
|
||||||
if execution.status != 'completed' or execution.normalized is None:
|
if execution.status != 'completed' or execution.normalized is None:
|
||||||
raise EvaluationError(execution.error or '单条分析失败')
|
error_message = execution.error or '单条分析失败'
|
||||||
|
self.store.execute(
|
||||||
|
"""
|
||||||
|
UPDATE evaluation_single_analyses
|
||||||
|
SET status = ?, error = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND account_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
execution.status,
|
||||||
|
error_message,
|
||||||
|
time.time(),
|
||||||
|
analysis_id,
|
||||||
|
account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise EvaluationError(error_message)
|
||||||
result = execution.normalized
|
result = execution.normalized
|
||||||
return {
|
payload = {
|
||||||
'id': analysis_id,
|
'id': analysis_id,
|
||||||
'case_index': 0,
|
'case_index': 0,
|
||||||
'case_id': case_id,
|
'case_id': case_id,
|
||||||
@@ -538,9 +599,64 @@ class EvaluationRuntime:
|
|||||||
'skill_name': skill_name,
|
'skill_name': skill_name,
|
||||||
'skill_version': snapshot['content_hash'][:12],
|
'skill_version': snapshot['content_hash'][:12],
|
||||||
'model': model_name,
|
'model': model_name,
|
||||||
'created_at': time.time(),
|
'created_at': created_at,
|
||||||
'updated_at': time.time(),
|
'updated_at': time.time(),
|
||||||
}
|
}
|
||||||
|
self.store.execute(
|
||||||
|
"""
|
||||||
|
UPDATE evaluation_single_analyses
|
||||||
|
SET status = 'completed', result_json = ?, error = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ? AND account_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
payload['updated_at'],
|
||||||
|
analysis_id,
|
||||||
|
account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_single_analyses(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
rows = self.store.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT a.*, s.content_hash AS snapshot_hash
|
||||||
|
FROM evaluation_single_analyses AS a
|
||||||
|
JOIN skill_snapshots AS s ON s.id = a.snapshot_id
|
||||||
|
WHERE a.account_id = ?
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(account_id, max(1, min(limit, 200))),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
self._single_analysis_payload(row, include_detail=False)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_single_analysis(
|
||||||
|
self,
|
||||||
|
analysis_id: str,
|
||||||
|
account_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self.store.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT a.*, s.content_hash AS snapshot_hash
|
||||||
|
FROM evaluation_single_analyses AS a
|
||||||
|
JOIN skill_snapshots AS s ON s.id = a.snapshot_id
|
||||||
|
WHERE a.id = ? AND a.account_id = ?
|
||||||
|
""",
|
||||||
|
(analysis_id, account_id),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise EvaluationError('单条分析记录不存在')
|
||||||
|
return self._single_analysis_payload(row, include_detail=True)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Experiments
|
# Experiments
|
||||||
@@ -1391,6 +1507,78 @@ class EvaluationRuntime:
|
|||||||
# Serialization helpers
|
# Serialization helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _single_analysis_payload(
|
||||||
|
self,
|
||||||
|
row: dict[str, Any],
|
||||||
|
*,
|
||||||
|
include_detail: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
stored_result = json.loads(row.get('result_json') or '{}')
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
stored_result = {}
|
||||||
|
if not isinstance(stored_result, dict):
|
||||||
|
stored_result = {}
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
'id': row['id'],
|
||||||
|
'case_index': 0,
|
||||||
|
'case_id': '',
|
||||||
|
'query': row['query'],
|
||||||
|
'gold_label': '',
|
||||||
|
'prediction': '',
|
||||||
|
'function_output': '',
|
||||||
|
'complex': None,
|
||||||
|
'correct': None,
|
||||||
|
'reason': '',
|
||||||
|
'candidates': [],
|
||||||
|
'confidence': None,
|
||||||
|
'uncertainty': '',
|
||||||
|
'evidence_refs': [],
|
||||||
|
'accessed_refs': [],
|
||||||
|
'raw_output': '',
|
||||||
|
'transcript_path': '',
|
||||||
|
'latency_ms': None,
|
||||||
|
'tool_calls': 0,
|
||||||
|
'turns': 0,
|
||||||
|
'input_tokens': 0,
|
||||||
|
'output_tokens': 0,
|
||||||
|
'domain': '',
|
||||||
|
'request_id': '',
|
||||||
|
'status': row['status'],
|
||||||
|
'error': row.get('error'),
|
||||||
|
'review_status': '',
|
||||||
|
'review_note': '',
|
||||||
|
'canonical': {},
|
||||||
|
'skill_name': row['skill_name'],
|
||||||
|
'skill_version': str(row.get('snapshot_hash') or '')[:12],
|
||||||
|
'model': row['model'],
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'updated_at': row['updated_at'],
|
||||||
|
}
|
||||||
|
payload.update(stored_result)
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
'id': row['id'],
|
||||||
|
'query': row['query'],
|
||||||
|
'skill_name': row['skill_name'],
|
||||||
|
'skill_version': str(row.get('snapshot_hash') or '')[:12],
|
||||||
|
'model': row['model'],
|
||||||
|
'status': row['status'],
|
||||||
|
'error': row.get('error'),
|
||||||
|
'created_at': row['created_at'],
|
||||||
|
'updated_at': row['updated_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not include_detail:
|
||||||
|
for key in (
|
||||||
|
'raw_output',
|
||||||
|
'transcript_path',
|
||||||
|
'canonical',
|
||||||
|
'accessed_refs',
|
||||||
|
):
|
||||||
|
payload.pop(key, None)
|
||||||
|
return payload
|
||||||
|
|
||||||
def _dataset_payload(
|
def _dataset_payload(
|
||||||
self,
|
self,
|
||||||
row: dict[str, Any],
|
row: dict[str, Any],
|
||||||
|
|||||||
@@ -88,6 +88,45 @@ class EvaluationApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.json()['prediction'], '地图导航')
|
self.assertEqual(response.json()['prediction'], '地图导航')
|
||||||
analyze.assert_called_once()
|
analyze.assert_called_once()
|
||||||
|
|
||||||
|
def test_single_analysis_history_endpoints(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
state = _build_state(root)
|
||||||
|
record = {
|
||||||
|
'id': 'single_1',
|
||||||
|
'query': '导航去公司',
|
||||||
|
'prediction': '地图导航',
|
||||||
|
'status': 'completed',
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
TestClient(create_app(state)) as client,
|
||||||
|
patch.object(
|
||||||
|
state.evaluation_runtime,
|
||||||
|
'list_single_analyses',
|
||||||
|
return_value=[record],
|
||||||
|
) as list_history,
|
||||||
|
patch.object(
|
||||||
|
state.evaluation_runtime,
|
||||||
|
'get_single_analysis',
|
||||||
|
return_value=record,
|
||||||
|
) as get_history,
|
||||||
|
):
|
||||||
|
response = client.get(
|
||||||
|
'/api/evaluations/analyses',
|
||||||
|
params={'account_id': 'alice'},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), [record])
|
||||||
|
list_history.assert_called_once_with('alice')
|
||||||
|
|
||||||
|
detail = client.get(
|
||||||
|
'/api/evaluations/analyses/single_1',
|
||||||
|
params={'account_id': 'alice'},
|
||||||
|
)
|
||||||
|
self.assertEqual(detail.status_code, 200)
|
||||||
|
self.assertEqual(detail.json(), record)
|
||||||
|
get_history.assert_called_once_with('single_1', 'alice')
|
||||||
|
|
||||||
def test_dataset_experiment_and_export_flow(self) -> None:
|
def test_dataset_experiment_and_export_flow(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|||||||
@@ -333,6 +333,15 @@ class EvaluationRuntimeTests(unittest.TestCase):
|
|||||||
self.assertEqual(result['model'], 'test-model')
|
self.assertEqual(result['model'], 'test-model')
|
||||||
self.assertEqual(runtime.list_datasets('alice'), [])
|
self.assertEqual(runtime.list_datasets('alice'), [])
|
||||||
self.assertEqual(runtime.list_experiments('alice'), [])
|
self.assertEqual(runtime.list_experiments('alice'), [])
|
||||||
|
history = runtime.list_single_analyses('alice')
|
||||||
|
self.assertEqual(len(history), 1)
|
||||||
|
self.assertEqual(history[0]['id'], result['id'])
|
||||||
|
self.assertEqual(history[0]['query'], '导航去公司')
|
||||||
|
self.assertEqual(history[0]['prediction'], '地图导航')
|
||||||
|
self.assertNotIn('raw_output', history[0])
|
||||||
|
detail = runtime.get_single_analysis(result['id'], 'alice')
|
||||||
|
self.assertEqual(detail['prediction'], '地图导航')
|
||||||
|
self.assertEqual(detail['canonical']['query'], '导航去公司')
|
||||||
finally:
|
finally:
|
||||||
runtime.shutdown()
|
runtime.shutdown()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user