fix(review): address release correctness findings

This commit is contained in:
sinohqb 2026-08-09 03:20:40 +08:00
parent 4a0709b456
commit 864ae2b7fe
13 changed files with 249 additions and 55 deletions

View File

@ -111,7 +111,9 @@ class TutuApiChannel(EvalChannel):
"size": 20, "size": 20,
} }
resp = await client.get(url, headers=self._build_headers(), params=params) resp = await client.get(url, headers=self._build_headers(), params=params)
if resp.status_code == 200: if resp.status_code != 200:
raise ChannelTransportError(f"轮询失败: HTTP {resp.status_code}: {resp.text[:500]}")
data = resp.json() data = resp.json()
records = data.get("data", []) if isinstance(data, dict) else [] records = data.get("data", []) if isinstance(data, dict) else []
for msg in records: for msg in records:

View File

@ -21,8 +21,7 @@ from typing import Optional
from sqlmodel import Session from sqlmodel import Session
from agenteval.evaluation.analysis import enqueue_campaign_analysis as start_campaign_analysis from agenteval.evaluation.analysis import enqueue_campaign_analysis, resolve_analysis_model
from agenteval.evaluation.analysis import resolve_analysis_model
from agenteval.evaluation.campaign_lifecycle import complete_campaign from agenteval.evaluation.campaign_lifecycle import complete_campaign
from agenteval.evaluation.campaign_lifecycle import start_campaign as start_campaign_lifecycle from agenteval.evaluation.campaign_lifecycle import start_campaign as start_campaign_lifecycle
from agenteval.evaluation.campaign_scheduler import ( from agenteval.evaluation.campaign_scheduler import (
@ -255,7 +254,7 @@ def _auto_start_analysis(campaign: Campaign, session: Session) -> None:
try: try:
if resolve_analysis_model(campaign, session) is None: if resolve_analysis_model(campaign, session) is None:
return return
start_campaign_analysis(campaign.id, triggered_by="auto") enqueue_campaign_analysis(campaign.id, triggered_by="auto")
except Exception as exc: except Exception as exc:
_logger.warning("活动 %s 自动分析触发失败(已跳过): %s", campaign.id, exc) _logger.warning("活动 %s 自动分析触发失败(已跳过): %s", campaign.id, exc)

View File

@ -11,8 +11,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlmodel import Session from sqlmodel import Session
from agenteval.evaluation.analysis import enqueue_campaign_analysis as start_campaign_analysis from agenteval.evaluation.analysis import enqueue_campaign_analysis, resolve_analysis_model
from agenteval.evaluation.analysis import resolve_analysis_model
from agenteval.evaluation.campaign_lifecycle import CampaignCreateError, CampaignLifecycleError from agenteval.evaluation.campaign_lifecycle import CampaignCreateError, CampaignLifecycleError
from agenteval.evaluation.campaign_lifecycle import cancel_campaign as cancel_campaign_lifecycle from agenteval.evaluation.campaign_lifecycle import cancel_campaign as cancel_campaign_lifecycle
from agenteval.evaluation.campaign_lifecycle import create_campaign as create_campaign_lifecycle from agenteval.evaluation.campaign_lifecycle import create_campaign as create_campaign_lifecycle
@ -191,7 +190,7 @@ async def trigger_campaign_analysis(campaign_id: str, session: Session = Depends
status_code=400, status_code=400,
detail="未配置分析模型:请在模型配置中心将某个 chat 配置设为「分析默认」,或为该活动指定分析模型", detail="未配置分析模型:请在模型配置中心将某个 chat 配置设为「分析默认」,或为该活动指定分析模型",
) )
start_campaign_analysis(campaign_id, triggered_by="manual") enqueue_campaign_analysis(campaign_id, triggered_by="manual")
return {"status": "generating"} return {"status": "generating"}

View File

@ -29,10 +29,46 @@ describe('intelligent evaluation read state', () => {
}) })
it('surfaces an initial detail failure without an existing snapshot', () => { it('surfaces an initial detail failure without an existing snapshot', () => {
const failed = intelligentEvalReadReducer(initialIntelligentEvalReadState, { const loading = intelligentEvalReadReducer(initialIntelligentEvalReadState, {
type: 'detail_requested',
id: 'eval-1',
requestId: 1,
})
const failed = intelligentEvalReadReducer(loading, {
type: 'detail_failed', type: 'detail_failed',
id: 'eval-1',
requestId: 1,
error: '不存在', error: '不存在',
}) })
expect(failed.detail).toEqual({ phase: 'error', value: null, error: '不存在' }) expect(failed.detail).toEqual({
phase: 'error',
value: null,
error: '不存在',
selectedId: 'eval-1',
requestId: 1,
})
})
it('ignores a stale detail response after the selection changes', () => {
const first = intelligentEvalReadReducer(initialIntelligentEvalReadState, {
type: 'detail_requested',
id: 'eval-1',
requestId: 1,
})
const second = intelligentEvalReadReducer(first, {
type: 'detail_requested',
id: 'eval-2',
requestId: 2,
})
const stale = intelligentEvalReadReducer(second, {
type: 'detail_succeeded',
id: 'eval-1',
requestId: 1,
value: evaluation,
})
expect(stale).toBe(second)
expect(stale.detail.value).toBeNull()
expect(stale.detail.selectedId).toBe('eval-2')
}) })
}) })

View File

@ -10,16 +10,20 @@ export interface ReadSlot<T> {
export interface IntelligentEvalReadState { export interface IntelligentEvalReadState {
list: ReadSlot<IntelligentEval[]> list: ReadSlot<IntelligentEval[]>
detail: ReadSlot<IntelligentEval | null> detail: ReadSlot<IntelligentEval | null> & {
selectedId: string | null
requestId: number
}
} }
export type IntelligentEvalReadAction = export type IntelligentEvalReadAction =
| { type: 'list_requested'; silent?: boolean } | { type: 'list_requested'; silent?: boolean }
| { type: 'list_succeeded'; value: IntelligentEval[] } | { type: 'list_succeeded'; value: IntelligentEval[] }
| { type: 'list_failed'; error: string } | { type: 'list_failed'; error: string }
| { type: 'detail_requested'; silent?: boolean } | { type: 'detail_cleared'; requestId: number }
| { type: 'detail_succeeded'; value: IntelligentEval } | { type: 'detail_requested'; id: string; requestId: number; silent?: boolean }
| { type: 'detail_failed'; error: string } | { type: 'detail_succeeded'; id: string; requestId: number; value: IntelligentEval }
| { type: 'detail_failed'; id: string; requestId: number; error: string }
export interface IntelligentEvalReadAdapter { export interface IntelligentEvalReadAdapter {
list: () => Promise<IntelligentEval[]> list: () => Promise<IntelligentEval[]>
@ -33,7 +37,7 @@ export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = {
export const initialIntelligentEvalReadState: IntelligentEvalReadState = { export const initialIntelligentEvalReadState: IntelligentEvalReadState = {
list: { phase: 'idle', value: [], error: null }, list: { phase: 'idle', value: [], error: null },
detail: { phase: 'idle', value: null, error: null }, detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 },
} }
function requestPhase<T>(slot: ReadSlot<T>, silent: boolean | undefined): ReadSlot<T> { function requestPhase<T>(slot: ReadSlot<T>, silent: boolean | undefined): ReadSlot<T> {
@ -56,11 +60,35 @@ export function intelligentEvalReadReducer(
? { ...state.list, phase: 'ready', error: null } ? { ...state.list, phase: 'ready', error: null }
: { ...state.list, phase: 'error', error: action.error }, : { ...state.list, phase: 'error', error: action.error },
} }
case 'detail_requested': case 'detail_cleared':
return { ...state, detail: requestPhase(state.detail, action.silent) } return {
...state,
detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: action.requestId },
}
case 'detail_requested': {
const sameSelection = state.detail.selectedId === action.id
const current = sameSelection
? state.detail
: { ...state.detail, value: null, selectedId: action.id }
return {
...state,
detail: { ...requestPhase(current, action.silent), selectedId: action.id, requestId: action.requestId },
}
}
case 'detail_succeeded': case 'detail_succeeded':
return { ...state, detail: { phase: 'ready', value: action.value, error: null } } if (state.detail.selectedId !== action.id || state.detail.requestId !== action.requestId) return state
return {
...state,
detail: {
phase: 'ready',
value: action.value,
error: null,
selectedId: action.id,
requestId: action.requestId,
},
}
case 'detail_failed': case 'detail_failed':
if (state.detail.selectedId !== action.id || state.detail.requestId !== action.requestId) return state
return { return {
...state, ...state,
detail: state.detail.value detail: state.detail.value

View File

@ -6,6 +6,16 @@ import type { IntelligentEval } from '../api'
const evaluation = { id: 'eval-1', name: '评估', status: 'executing' } as unknown as IntelligentEval const evaluation = { id: 'eval-1', name: '评估', status: 'executing' } as unknown as IntelligentEval
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
}) })
@ -54,4 +64,50 @@ describe('useIntelligentEvalRead', () => {
expect(result.current.list.value).toEqual([evaluation]) expect(result.current.list.value).toEqual([evaluation])
expect(result.current.list.error).toBeNull() expect(result.current.list.error).toBeNull()
}) })
it('clears the old detail and ignores a response for the previous selection', async () => {
const first = deferred<IntelligentEval>()
const second = deferred<IntelligentEval>()
const secondEvaluation = { ...evaluation, id: 'eval-2', name: '评估二' } as IntelligentEval
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn((id: string) => (id === 'eval-1' ? first.promise : second.promise)),
}
const { result, rerender } = renderHook(
({ selectedId }) => useIntelligentEvalRead(selectedId, adapter),
{ initialProps: { selectedId: 'eval-1' as string | null } },
)
rerender({ selectedId: 'eval-2' })
expect(result.current.detail.value).toBeNull()
expect(result.current.detail.selectedId).toBe('eval-2')
await act(async () => { second.resolve(secondEvaluation) })
expect(result.current.detail.value?.id).toBe('eval-2')
await act(async () => { first.resolve(evaluation) })
expect(result.current.detail.value?.id).toBe('eval-2')
})
it('surfaces failure for a newly selected detail instead of retaining the old snapshot', async () => {
const adapter: IntelligentEvalReadAdapter = {
list: vi.fn().mockResolvedValue([]),
get: vi.fn()
.mockResolvedValueOnce(evaluation)
.mockRejectedValueOnce(new Error('不存在')),
}
const { result, rerender } = renderHook(
({ selectedId }) => useIntelligentEvalRead(selectedId, adapter),
{ initialProps: { selectedId: 'eval-1' as string | null } },
)
await settle()
expect(result.current.detail.value?.id).toBe('eval-1')
rerender({ selectedId: 'eval-2' })
await settle()
expect(result.current.detail.phase).toBe('error')
expect(result.current.detail.value).toBeNull()
expect(result.current.detail.error).toBe('不存在')
})
}) })

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useReducer } from 'react' import { useCallback, useEffect, useLayoutEffect, useReducer, useRef } from 'react'
import { import {
intelligentEvalReadAdapter, intelligentEvalReadAdapter,
intelligentEvalReadReducer, intelligentEvalReadReducer,
@ -23,6 +23,7 @@ export function useIntelligentEvalRead(
adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter, adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter,
): IntelligentEvalReadState & { reloadList: () => Promise<void>; reloadDetail: () => Promise<void> } { ): IntelligentEvalReadState & { reloadList: () => Promise<void>; reloadDetail: () => Promise<void> } {
const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState) const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState)
const detailRequestId = useRef(0)
const loadList = useCallback(async (silent = false) => { const loadList = useCallback(async (silent = false) => {
dispatch({ type: 'list_requested', silent }) dispatch({ type: 'list_requested', silent })
@ -35,12 +36,13 @@ export function useIntelligentEvalRead(
}, [adapter]) }, [adapter])
const loadDetail = useCallback(async (id: string, silent = false) => { const loadDetail = useCallback(async (id: string, silent = false) => {
dispatch({ type: 'detail_requested', silent }) const requestId = ++detailRequestId.current
dispatch({ type: 'detail_requested', id, requestId, silent })
try { try {
const value = await adapter.get(id) const value = await adapter.get(id)
dispatch({ type: 'detail_succeeded', value }) dispatch({ type: 'detail_succeeded', id, requestId, value })
} catch (error) { } catch (error) {
dispatch({ type: 'detail_failed', error: errorMessage(error) }) dispatch({ type: 'detail_failed', id, requestId, error: errorMessage(error) })
} }
}, [adapter]) }, [adapter])
@ -48,8 +50,11 @@ export function useIntelligentEvalRead(
void loadList() void loadList()
}, [loadList]) }, [loadList])
useEffect(() => { useLayoutEffect(() => {
if (selectedId == null) return if (selectedId == null) {
dispatch({ type: 'detail_cleared', requestId: ++detailRequestId.current })
return
}
void loadDetail(selectedId) void loadDetail(selectedId)
}, [loadDetail, selectedId]) }, [loadDetail, selectedId])

View File

@ -75,6 +75,14 @@ done
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
RELEASE_DIR=""
cleanup_release_source() {
if [[ -n "$RELEASE_DIR" && -d "$RELEASE_DIR" ]]; then
rm -rf "$RELEASE_DIR"
fi
}
trap cleanup_release_source EXIT
validate_tag() { validate_tag() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid image tag: $1" [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "invalid image tag: $1"
} }
@ -151,21 +159,17 @@ run ssh "$HOST" "set -eu; mkdir -p '$REMOTE_DIR'; \
test -f '$REMOTE_DIR/deploy/volcengine-102/.env' \ test -f '$REMOTE_DIR/deploy/volcengine-102/.env' \
|| { echo 'missing production deploy/volcengine-102/.env' >&2; exit 1; }" || { echo 'missing production deploy/volcengine-102/.env' >&2; exit 1; }"
log "prepare committed release source from HEAD"
RELEASE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/agenteval-release.XXXXXX")
git archive --format=tar HEAD | tar -xf - -C "$RELEASE_DIR"
log "sync committed release source → $HOST:$REMOTE_DIR" log "sync committed release source → $HOST:$REMOTE_DIR"
run rsync -az --delete \ run rsync -az --delete \
--exclude='.git' \
--exclude='.scratch' \
--exclude='.env' \ --exclude='.env' \
--exclude='node_modules' \
--exclude='frontend/web/dist' \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='.pytest_cache' \
--exclude='.ruff_cache' \
--exclude='data' \ --exclude='data' \
--exclude='config/config.json' \ --exclude='config/config.json' \
--exclude='*.db*' \ --exclude='*.db*' \
./ "$HOST:$REMOTE_DIR/" "$RELEASE_DIR/" "$HOST:$REMOTE_DIR/"
backup_remote_data "$IMAGE_TAG" backup_remote_data "$IMAGE_TAG"

View File

@ -14,9 +14,8 @@ if [ ! -f "$DB_PATH" ]; then
else else
HAS_ALEMBIC_VERSION=$(python -c "import sqlite3, sys; connection = sqlite3.connect(sys.argv[1]); row = connection.execute(\"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'\").fetchone(); connection.close(); print('yes' if row else 'no')" "$DB_PATH") HAS_ALEMBIC_VERSION=$(python -c "import sqlite3, sys; connection = sqlite3.connect(sys.argv[1]); row = connection.execute(\"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'\").fetchone(); connection.close(); print('yes' if row else 'no')" "$DB_PATH")
if [ "$HAS_ALEMBIC_VERSION" = "no" ]; then if [ "$HAS_ALEMBIC_VERSION" = "no" ]; then
echo "legacy production db detected; recording the current schema baseline" echo "legacy production db detected; starting migrations from the pre-Alembic baseline"
python -c "from agenteval.storage.db import init_db; init_db()" alembic stamp base
alembic stamp head
fi fi
fi fi

View File

@ -151,7 +151,7 @@ async def test_post_then_get_completed_analysis(client, seeded_db, monkeypatch):
# 假后台任务:同步写入 completed 行(真任务的单测覆盖在 test_campaign_analysis.py # 假后台任务:同步写入 completed 行(真任务的单测覆盖在 test_campaign_analysis.py
monkeypatch.setattr( monkeypatch.setattr(
campaigns_module, campaigns_module,
"start_campaign_analysis", "enqueue_campaign_analysis",
lambda cid, *, triggered_by: _complete_analysis_row(seeded_db, cid), lambda cid, *, triggered_by: _complete_analysis_row(seeded_db, cid),
) )
@ -172,7 +172,7 @@ async def test_rerun_upserts_without_new_row(client, seeded_db, monkeypatch):
campaign_id = await _create_campaign(client, seeded_db) campaign_id = await _create_campaign(client, seeded_db)
monkeypatch.setattr( monkeypatch.setattr(
campaigns_module, campaigns_module,
"start_campaign_analysis", "enqueue_campaign_analysis",
lambda cid, *, triggered_by: _complete_analysis_row(seeded_db, cid), lambda cid, *, triggered_by: _complete_analysis_row(seeded_db, cid),
) )

View File

@ -63,7 +63,7 @@ def analysis_spy(monkeypatch):
"""Spy the analysis seam: resolvable model, recorded enqueue calls.""" """Spy the analysis seam: resolvable model, recorded enqueue calls."""
calls: list[tuple[str, str]] = [] calls: list[tuple[str, str]] = []
monkeypatch.setattr( monkeypatch.setattr(
campaign_runner, "start_campaign_analysis", campaign_runner, "enqueue_campaign_analysis",
lambda cid, *, triggered_by: calls.append((cid, triggered_by)), lambda cid, *, triggered_by: calls.append((cid, triggered_by)),
) )
monkeypatch.setattr(campaign_runner, "resolve_analysis_model", lambda campaign, session: object()) monkeypatch.setattr(campaign_runner, "resolve_analysis_model", lambda campaign, session: object())

View File

@ -4,9 +4,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx import httpx
import pytest import pytest
from agenteval.channels.base import ExchangeStatus from agenteval.channels.base import ExchangeStatus, SendResult
from agenteval.channels.http import HttpChannel, _get_path from agenteval.channels.http import HttpChannel, _get_path
from agenteval.channels.openclaw import OpenClawChannel from agenteval.channels.openclaw import OpenClawChannel
from agenteval.channels.tutu import TutuApiChannel
from agenteval.evaluation.rules.keyword import KeywordMatchRule from agenteval.evaluation.rules.keyword import KeywordMatchRule
from agenteval.evaluation.rules.response_time import ResponseTimeRule from agenteval.evaluation.rules.response_time import ResponseTimeRule
from agenteval.models import Case, CaseType, Expectation, Turn from agenteval.models import Case, CaseType, Expectation, Turn
@ -105,6 +106,30 @@ async def test_http_exchange_does_not_mask_adapter_programming_errors():
await ch.exchange("hi") await ch.exchange("hi")
async def test_tutu_exchange_classifies_non_200_poll_response_as_failure():
channel = TutuApiChannel(
{
"base_url": "http://mock",
"token": "token",
"tenant": "tenant",
"chat_channel_id": "channel",
"chat_contact_id": "contact",
}
)
response = MagicMock(status_code=502, text="bad gateway")
client = MagicMock()
client.get = AsyncMock(return_value=response)
with (
patch.object(channel, "_send", new=AsyncMock(return_value=SendResult(ok=True, question_msg_id="msg-1"))),
patch.object(channel, "_get_client", new=AsyncMock(return_value=client)),
):
outcome = await channel.exchange("hi", timeout=0.1, poll_interval=0.01)
assert outcome.status is ExchangeStatus.POLL_FAILED
assert "HTTP 502" in outcome.reason
async def test_http_poll_reply_found(): async def test_http_poll_reply_found():
ch = _make_channel(reply_path="answer") ch = _make_channel(reply_path="answer")
call_count = {"n": 0} call_count = {"n": 0}

View File

@ -0,0 +1,41 @@
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
def _write_executable(path: Path, content: str) -> None:
path.write_text(content)
path.chmod(0o755)
def test_legacy_database_starts_migrations_from_pre_alembic_baseline(tmp_path: Path) -> None:
database_path = tmp_path / "legacy.db"
connection = sqlite3.connect(database_path)
connection.execute("CREATE TABLE scenarios (id TEXT PRIMARY KEY, name TEXT NOT NULL)")
connection.close()
command_log = tmp_path / "commands.log"
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
recorder = '#!/bin/sh\nprintf "%s\\n" "$*" >> "$COMMAND_LOG"\n'
_write_executable(bin_dir / "alembic", recorder)
_write_executable(bin_dir / "agenteval", recorder)
_write_executable(bin_dir / "python", f'#!/bin/sh\nexec "{sys.executable}" "$@"\n')
environment = {
**os.environ,
"AGENTEVAL_DB_PATH": str(database_path),
"COMMAND_LOG": str(command_log),
"PATH": f"{bin_dir}:{os.environ['PATH']}",
}
script = Path(__file__).resolve().parents[2] / "scripts" / "production-entrypoint.sh"
subprocess.run(["sh", str(script)], check=True, env=environment)
assert command_log.read_text().splitlines() == [
"stamp base",
"upgrade head",
"server start --host 0.0.0.0 --port 8000",
]