From c896ab3f7175b5db7db4301ba6a19ba8f906f9af Mon Sep 17 00:00:00 2001 From: sinohqb Date: Fri, 7 Aug 2026 03:11:37 +0800 Subject: [PATCH] refactor(architecture): deepen evaluation lifecycle and read model --- backend/agenteval/channels/__init__.py | 22 +- backend/agenteval/channels/base.py | 207 +- backend/agenteval/channels/http.py | 4 +- backend/agenteval/channels/openclaw.py | 4 +- backend/agenteval/channels/tutu.py | 4 +- backend/agenteval/evaluation/analysis.py | 18 + .../evaluation/campaign_lifecycle.py | 200 ++ .../agenteval/evaluation/campaign_runner.py | 208 +- backend/agenteval/evaluation/engine.py | 109 +- backend/agenteval/exploration/lifecycle.py | 90 +- .../agenteval/intelligent_eval/lifecycle.py | 251 +- .../agenteval/intelligent_eval/read_model.py | 199 ++ .../agenteval/intelligent_eval/repository.py | 290 ++- backend/agenteval/models.py | 3 + backend/agenteval/storage/db.py | 12 + backend/agenteval/storage/repository.py | 317 ++- backend/agenteval/web/app.py | 35 +- backend/agenteval/web/routers/campaigns.py | 80 +- .../web/routers/intelligent_evals.py | 110 +- ...03-campaign-phased-static-then-adaptive.md | 13 + frontend/web/package-lock.json | 2276 ++++++++++++++++- frontend/web/package.json | 9 +- frontend/web/src/api.ts | 1 + .../intelligent_eval/EvalDetail.tsx | 25 +- .../intelligent_eval/EvalReport.tsx | 26 +- frontend/web/src/pages/IntelligentEvals.tsx | 28 +- frontend/web/src/read/intelligentEval.test.ts | 38 + frontend/web/src/read/intelligentEval.ts | 71 + .../src/read/useIntelligentEvalRead.test.tsx | 57 + .../web/src/read/useIntelligentEvalRead.ts | 77 + frontend/web/src/test/setup.ts | 1 + frontend/web/src/test/smoke.test.tsx | 13 + frontend/web/vite.config.ts | 8 +- .../c2f4a6b8d0e1_add_campaign_run_identity.py | 54 + scripts/deploy-t480.sh | 1 + tests/integration/test_campaign_runner.py | 107 +- tests/integration/test_campaigns_api.py | 6 +- tests/integration/test_exploration_api.py | 2 +- .../integration/test_intelligent_evals_api.py | 56 +- tests/unit/mock_channel.py | 4 +- tests/unit/test_campaign_cancel_lifecycle.py | 52 + .../unit/test_campaign_complete_lifecycle.py | 48 + tests/unit/test_campaign_lifecycle.py | 70 + tests/unit/test_campaign_recovery.py | 46 + tests/unit/test_campaign_run_claim.py | 115 + .../test_campaign_run_identity_migration.py | 85 + tests/unit/test_campaign_start_lifecycle.py | 42 + tests/unit/test_channel_contract.py | 139 + tests/unit/test_engine.py | 226 +- tests/unit/test_http_channel_and_rules.py | 18 +- tests/unit/test_intelligent_eval_model.py | 207 +- .../unit/test_intelligent_eval_read_model.py | 65 + tests/unit/test_llm_task_lifecycle.py | 27 + tests/unit/test_orphan_runs.py | 44 +- tests/unit/test_repository.py | 69 + 55 files changed, 5687 insertions(+), 602 deletions(-) create mode 100644 backend/agenteval/evaluation/campaign_lifecycle.py create mode 100644 backend/agenteval/intelligent_eval/read_model.py create mode 100644 frontend/web/src/read/intelligentEval.test.ts create mode 100644 frontend/web/src/read/intelligentEval.ts create mode 100644 frontend/web/src/read/useIntelligentEvalRead.test.tsx create mode 100644 frontend/web/src/read/useIntelligentEvalRead.ts create mode 100644 frontend/web/src/test/setup.ts create mode 100644 frontend/web/src/test/smoke.test.tsx create mode 100644 migrations/versions/c2f4a6b8d0e1_add_campaign_run_identity.py create mode 100644 tests/unit/test_campaign_cancel_lifecycle.py create mode 100644 tests/unit/test_campaign_complete_lifecycle.py create mode 100644 tests/unit/test_campaign_lifecycle.py create mode 100644 tests/unit/test_campaign_recovery.py create mode 100644 tests/unit/test_campaign_run_claim.py create mode 100644 tests/unit/test_campaign_run_identity_migration.py create mode 100644 tests/unit/test_campaign_start_lifecycle.py create mode 100644 tests/unit/test_channel_contract.py create mode 100644 tests/unit/test_intelligent_eval_read_model.py diff --git a/backend/agenteval/channels/__init__.py b/backend/agenteval/channels/__init__.py index b9fdefd..a72ac00 100644 --- a/backend/agenteval/channels/__init__.py +++ b/backend/agenteval/channels/__init__.py @@ -1,7 +1,25 @@ """Message channel adapters.""" -from agenteval.channels.base import ChannelHealth, EvalChannel, Reply, SendResult +from agenteval.channels.base import ( + ChannelHealth, + EvalChannel, + ExchangeOutcome, + ExchangeStatus, + Reply, + SendResult, + normalize_reply_text, +) from agenteval.channels.factory import ChannelFactory from agenteval.channels.tutu import TutuApiChannel -__all__ = ["ChannelFactory", "ChannelHealth", "EvalChannel", "Reply", "SendResult", "TutuApiChannel"] +__all__ = [ + "ChannelFactory", + "ChannelHealth", + "EvalChannel", + "ExchangeOutcome", + "ExchangeStatus", + "Reply", + "SendResult", + "TutuApiChannel", + "normalize_reply_text", +] diff --git a/backend/agenteval/channels/base.py b/backend/agenteval/channels/base.py index 77d05bf..cc81456 100644 --- a/backend/agenteval/channels/base.py +++ b/backend/agenteval/channels/base.py @@ -1,9 +1,12 @@ """Abstract base class for message channels.""" +import json +import time from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Optional +from enum import Enum +from typing import Any, Awaitable, Callable, Optional @dataclass @@ -29,6 +32,135 @@ class Reply: raw_message: Optional[dict[str, Any]] = None +class ExchangeStatus(str, Enum): + """Outcome categories for one complete channel exchange.""" + + SUCCESS = "success" + SEND_FAILED = "send_failed" + REPLY_TIMEOUT = "reply_timeout" + POLL_FAILED = "poll_failed" + + +def normalize_reply_text(content: Any) -> str: + """Convert a channel reply payload into stable plain text. + + Adapters may return a plain string, a Tutu-style ``msgBody`` object, or a + provider-specific mapping. The exchange interface exposes only text so + callers do not need to learn each provider's response shape. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, dict): + body = content.get("msgBody") + if isinstance(body, dict): + for key in ("content", "text", "message"): + value = body.get(key) + if isinstance(value, str) and value: + return value + elif isinstance(body, str) and body: + return body + + for key in ("content", "text", "message"): + value = content.get(key) + if isinstance(value, str) and value: + return value + return json.dumps(content, ensure_ascii=False) + return str(content) + + +@dataclass(frozen=True) +class ExchangeOutcome: + """Result of a complete send-and-reply exchange. + + ``diagnostic`` is intentionally opaque to callers. Adapters may retain + provider-specific data there without expanding the public interface. + """ + + status: ExchangeStatus + correlation_id: Optional[str] = None + reply_text: Optional[str] = None + latency_ms: Optional[int] = None + reason: Optional[str] = None + diagnostic: Any = None + + def __post_init__(self) -> None: + if self.status is ExchangeStatus.SUCCESS: + if not self.correlation_id: + raise ValueError("a successful exchange requires a correlation_id") + if self.reply_text is None: + raise ValueError("a successful exchange requires reply_text") + if self.latency_ms is None or self.latency_ms < 0: + raise ValueError("a successful exchange requires non-negative latency_ms") + + @property + def ok(self) -> bool: + return self.status is ExchangeStatus.SUCCESS + + @property + def expected_failure(self) -> bool: + return self.status in { + ExchangeStatus.SEND_FAILED, + ExchangeStatus.REPLY_TIMEOUT, + ExchangeStatus.POLL_FAILED, + } + + @classmethod + def succeeded( + cls, + *, + correlation_id: str, + reply: Any, + latency_ms: int, + diagnostic: Any = None, + ) -> "ExchangeOutcome": + return cls( + status=ExchangeStatus.SUCCESS, + correlation_id=correlation_id, + reply_text=normalize_reply_text(reply), + latency_ms=latency_ms, + diagnostic=diagnostic, + ) + + @classmethod + def send_failed(cls, reason: str, *, diagnostic: Any = None) -> "ExchangeOutcome": + return cls(status=ExchangeStatus.SEND_FAILED, reason=reason, diagnostic=diagnostic) + + @classmethod + def reply_timeout( + cls, + *, + correlation_id: Optional[str] = None, + latency_ms: Optional[int] = None, + diagnostic: Any = None, + ) -> "ExchangeOutcome": + return cls( + status=ExchangeStatus.REPLY_TIMEOUT, + correlation_id=correlation_id, + latency_ms=latency_ms, + reason="reply timeout", + diagnostic=diagnostic, + ) + + @classmethod + def poll_failed( + cls, + reason: str, + *, + correlation_id: Optional[str] = None, + latency_ms: Optional[int] = None, + diagnostic: Any = None, + ) -> "ExchangeOutcome": + return cls( + status=ExchangeStatus.POLL_FAILED, + correlation_id=correlation_id, + latency_ms=latency_ms, + reason=reason, + diagnostic=diagnostic, + ) + + class EvalChannel(ABC): """Abstract message channel used to interact with an evaluation target. @@ -41,20 +173,79 @@ class EvalChannel(ABC): """Verify the channel can reach the target.""" ... + async def exchange( + self, + content: str, + *, + timeout: float = 30.0, + poll_interval: float = 1.0, + on_sent: Optional[Callable[[SendResult], Awaitable[None]]] = None, + **kwargs: Any, + ) -> ExchangeOutcome: + """Complete one send-and-reply exchange through this channel. + + ``on_sent`` runs after the adapter confirms a successful send and + before reply polling begins. A hook failure is deliberately allowed + to propagate so callers cannot observe a reply without a durable sent + record. + """ + started_at = time.monotonic() + try: + send_result = await self._send(content, **kwargs) + except Exception as exc: + return ExchangeOutcome.send_failed(str(exc)) + if not send_result.ok: + return ExchangeOutcome.send_failed(send_result.error or "channel send failed") + + correlation_id = send_result.question_msg_id + if not correlation_id: + raise ValueError("a successful send must provide question_msg_id") + + if on_sent is not None: + await on_sent(send_result) + + try: + reply = await self._poll_reply( + correlation_id, + timeout=timeout, + poll_interval=poll_interval, + ) + except Exception as exc: + return ExchangeOutcome.poll_failed( + str(exc), + correlation_id=correlation_id, + latency_ms=_elapsed_ms(started_at), + ) + + latency_ms = _elapsed_ms(started_at) + if reply is None: + return ExchangeOutcome.reply_timeout( + correlation_id=correlation_id, + latency_ms=latency_ms, + ) + + return ExchangeOutcome.succeeded( + correlation_id=correlation_id, + reply=reply.content, + latency_ms=latency_ms, + diagnostic={"send": send_result.raw_response, "reply": reply.raw_message}, + ) + @abstractmethod - async def send(self, content: str, **kwargs: Any) -> SendResult: - """Send a message to the target and return its question message id.""" + async def _send(self, content: str, **kwargs: Any) -> SendResult: + """Transport primitive used by :meth:`exchange`.""" ... @abstractmethod - async def poll_reply( + async def _poll_reply( self, question_msg_id: str, timeout: float = 30.0, poll_interval: float = 1.0, ) -> Optional[Reply]: - """Wait for a reply to a previously sent message. - - Implementations may poll (REST) or await a push event (WebSocket). - """ + """Transport primitive used by :meth:`exchange`.""" ... + + +def _elapsed_ms(started_at: float) -> int: + return max(0, int((time.monotonic() - started_at) * 1000)) diff --git a/backend/agenteval/channels/http.py b/backend/agenteval/channels/http.py index 64916d5..168f3f2 100644 --- a/backend/agenteval/channels/http.py +++ b/backend/agenteval/channels/http.py @@ -76,7 +76,7 @@ class HttpChannel(EvalChannel): except Exception as exc: return ChannelHealth(ok=False, message=str(exc)) - async def send(self, content: str, **kwargs: Any) -> SendResult: + async def _send(self, content: str, **kwargs: Any) -> SendResult: body_str = self.send_body_template.replace("{message}", content) try: body = json.loads(body_str) @@ -93,7 +93,7 @@ class HttpChannel(EvalChannel): except Exception as exc: return SendResult(ok=False, error=str(exc)) - async def poll_reply( + async def _poll_reply( self, question_msg_id: str, timeout: float = 30.0, diff --git a/backend/agenteval/channels/openclaw.py b/backend/agenteval/channels/openclaw.py index 163441e..ec442a5 100644 --- a/backend/agenteval/channels/openclaw.py +++ b/backend/agenteval/channels/openclaw.py @@ -50,7 +50,7 @@ class OpenClawChannel(EvalChannel): except Exception as exc: return ChannelHealth(ok=False, message=str(exc)) - async def send(self, content: str, **kwargs: Any) -> SendResult: + async def _send(self, content: str, **kwargs: Any) -> SendResult: """Send a chat message to OpenClaw.""" payload = { "model": self.model, @@ -70,7 +70,7 @@ class OpenClawChannel(EvalChannel): except Exception as exc: return SendResult(ok=False, error=str(exc)) - async def poll_reply( + async def _poll_reply( self, question_msg_id: str, timeout: float = 30.0, diff --git a/backend/agenteval/channels/tutu.py b/backend/agenteval/channels/tutu.py index 4792963..ab0c47b 100644 --- a/backend/agenteval/channels/tutu.py +++ b/backend/agenteval/channels/tutu.py @@ -65,7 +65,7 @@ class TutuApiChannel(EvalChannel): except Exception as exc: return ChannelHealth(ok=False, message=f"请求异常: {exc}") - async def send(self, content: str, **kwargs: Any) -> SendResult: + async def _send(self, content: str, **kwargs: Any) -> SendResult: """Send a text message to the configured chat contact.""" payload = { "chatChannelId": self.chat_channel_id, @@ -90,7 +90,7 @@ class TutuApiChannel(EvalChannel): except Exception as exc: return SendResult(ok=False, error=f"发送异常: {exc}") - async def poll_reply( + async def _poll_reply( self, question_msg_id: str, timeout: float = 30.0, diff --git a/backend/agenteval/evaluation/analysis.py b/backend/agenteval/evaluation/analysis.py index 761c9f8..1c7ddba 100644 --- a/backend/agenteval/evaluation/analysis.py +++ b/backend/agenteval/evaluation/analysis.py @@ -342,3 +342,21 @@ def start_campaign_analysis(campaign_id: str, *, triggered_by: str) -> None: campaign_id, lambda _cancel: execute_campaign_analysis(campaign_id, triggered_by=triggered_by), ) + + +def enqueue_campaign_analysis(campaign_id: str, *, triggered_by: str) -> None: + """Durably queue analysis, then launch the in-process worker.""" + session = get_session() + try: + CampaignAnalysisRepository(session).enqueue(campaign_id, triggered_by=triggered_by) + finally: + session.close() + start_campaign_analysis(campaign_id, triggered_by=triggered_by) + + +def resume_queued_campaign_analysis(session: Session) -> int: + """Re-launch analysis jobs persisted before a process interruption.""" + rows = CampaignAnalysisRepository(session).list_queued() + for row in rows: + start_campaign_analysis(row.campaign_id, triggered_by=row.triggered_by or "manual") + return len(rows) diff --git a/backend/agenteval/evaluation/campaign_lifecycle.py b/backend/agenteval/evaluation/campaign_lifecycle.py new file mode 100644 index 0000000..1ca7440 --- /dev/null +++ b/backend/agenteval/evaluation/campaign_lifecycle.py @@ -0,0 +1,200 @@ +"""Durable Campaign creation lifecycle. + +This module owns the create seam: reference validation and Campaign +persistence happen before the process-local scheduler is launched. The +database therefore remains authoritative if task startup fails or the process +stops between commit and launch. +""" + +from dataclasses import dataclass +from typing import Callable, Optional + +from sqlmodel import Session + +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, ExplorationBudgetConfig, ExplorationSeeds +from agenteval.storage.db import utc_now +from agenteval.storage.model_config_repository import ModelConfigRepository +from agenteval.storage.repository import ( + CampaignAnalysisRepository, + CampaignPeriodComparisonRepository, + CampaignRepository, + ExplorationSessionRepository, + RunRepository, + ScenarioRepository, + TargetRepository, +) + + +@dataclass(frozen=True) +class CampaignCreateError(Exception): + """Validation failure translated by the HTTP adapter.""" + + status_code: int + detail: str + + +@dataclass(frozen=True) +class CampaignCancelError(Exception): + """Cancellation failure translated by the HTTP adapter.""" + + status_code: int + detail: str + + +@dataclass(frozen=True) +class CampaignRecoverySummary: + """Durable work reconciled during process startup.""" + + interrupted_runs: int = 0 + interrupted_analysis: int = 0 + resumed_campaigns: int = 0 + resumed_analysis: int = 0 + + +def start_campaign( + session: Session, + campaign_id: str, + *, + launch: Optional[Callable[[str, Session], object]] = None, +) -> Optional[Campaign]: + """Start a planned Campaign through a conditional lifecycle write.""" + repo = CampaignRepository(session) + campaign = repo.get(campaign_id) + if campaign is None or campaign.status in ( + CampaignStatus.COMPLETED, + CampaignStatus.CANCELLED, + CampaignStatus.FAILED, + ): + return None + if campaign.status is CampaignStatus.PLANNED: + status, campaign = repo.start_if_planned(campaign_id, utc_now()) + if status != "applied" or campaign is None: + return None + if launch is not None and campaign.id: + launch(campaign.id, session) + return campaign + + +def create_campaign( + session: Session, + *, + name: str, + target_id: str, + window_seconds: int, + time_scale: float, + plan: list[CampaignPlanEntry], + analysis_model_config_id: Optional[str] = None, + exploration_seeds: Optional[ExplorationSeeds] = None, + exploration_budget: Optional[ExplorationBudgetConfig] = None, + launch: Optional[Callable[[str, Session], object]] = None, +) -> Campaign: + """Validate, commit and then launch one running Campaign. + + ``CampaignRepository.create`` is the transaction seam. The optional + ``launch`` callback is invoked only after that commit, which makes this + function straightforward to test with a fake scheduler and ensures a + launch failure cannot erase the durable Campaign row. + """ + if TargetRepository(session).get(target_id) is None: + raise CampaignCreateError(404, "target not found") + + scenario_repo = ScenarioRepository(session) + for entry in plan: + if scenario_repo.get(entry.scenario_id) is None: + raise CampaignCreateError(404, f"scenario not found: {entry.scenario_id}") + + if analysis_model_config_id is not None and ModelConfigRepository(session).get(analysis_model_config_id) is None: + raise CampaignCreateError(400, "analysis model config not found") + + if exploration_seeds is not None and not exploration_seeds.personas and not exploration_seeds.goals: + exploration_seeds = None + + campaign = Campaign( + name=name, + target_id=target_id, + window_seconds=window_seconds, + time_scale=time_scale, + plan=plan, + status=CampaignStatus.RUNNING, + started_at=utc_now(), + analysis_model_config_id=analysis_model_config_id, + exploration_seeds=exploration_seeds, + exploration_budget=exploration_budget, + ) + campaign = CampaignRepository(session).create(campaign) + + # Deliberately after the repository commit. Startup recovery can relaunch + # this Campaign if the process dies before the callback runs. + if launch is not None and campaign.id: + launch(campaign.id, session) + return campaign + + +def cancel_campaign( + session: Session, + campaign_id: str, + *, + stop: Optional[Callable[[str], object]] = None, +) -> Campaign: + """Cancel, settle exploration, then signal the process-local scheduler.""" + repo = CampaignRepository(session) + status, campaign = repo.cancel_if_active(campaign_id, utc_now()) + if status == "not_found": + raise CampaignCancelError(404, "campaign not found") + if status == "conflict" or campaign is None: + raise CampaignCancelError(400, "campaign is not in a cancellable state") + + ExplorationSessionRepository(session).expire_running_sessions(campaign_id) + if stop is not None: + stop(campaign_id) + return campaign + + +def recover_campaign_runtime(session: Session, *, tick_seconds: float = 1.0) -> CampaignRecoverySummary: + """Reconcile all durable Campaign work and relaunch safe tasks. + + The database is inspected and repaired before process-local tasks are + launched. Child Run reconciliation remains inside each resumed Campaign + loop, so a pending claim is resumed there while an orphaned running claim + is marked interrupted without replaying messages. + """ + interrupted_runs = RunRepository(session).mark_orphans_failed() + interrupted_analysis = CampaignAnalysisRepository(session).mark_orphans_failed() + interrupted_analysis += CampaignPeriodComparisonRepository(session).mark_orphans_failed() + + from agenteval.evaluation.analysis import resume_queued_campaign_analysis + from agenteval.evaluation.campaign_runner import resume_running_campaigns + + resumed_campaigns = resume_running_campaigns(session, tick_seconds=tick_seconds) + resumed_analysis = resume_queued_campaign_analysis(session) + return CampaignRecoverySummary( + interrupted_runs=interrupted_runs, + interrupted_analysis=interrupted_analysis, + resumed_campaigns=resumed_campaigns, + resumed_analysis=resumed_analysis, + ) + + +def complete_campaign( + session: Session, + campaign_id: str, + *, + settle: Optional[Callable[[str, Session], object]] = None, +) -> Campaign: + """Complete only a still-running Campaign, then settle exploration. + + A competing cancellation wins because the status predicate is evaluated in + the database. Settlement is deliberately performed after the CAS commit; + a failed settlement can be retried without reverting the terminal status. + """ + repo = CampaignRepository(session) + status, campaign = repo.complete_if_running(campaign_id, utc_now()) + if status == "not_found": + raise CampaignCancelError(404, "campaign not found") + if status == "conflict" or campaign is None: + raise CampaignCancelError(409, "campaign is not running") + if settle is not None: + settle(campaign_id, session) + else: + ExplorationSessionRepository(session).expire_running_sessions(campaign_id) + return campaign diff --git a/backend/agenteval/evaluation/campaign_runner.py b/backend/agenteval/evaluation/campaign_runner.py index 9a74c5b..ec43d0d 100644 --- a/backend/agenteval/evaluation/campaign_runner.py +++ b/backend/agenteval/evaluation/campaign_runner.py @@ -1,10 +1,11 @@ """Campaign runner — the side-effect shell around the pure scheduler. Given a campaign and a clock position (real elapsed seconds), it asks -``campaign_scheduler.decide_schedule`` what is due, then spawns those child Runs -by reusing the existing single-run execution path (``EvalEngine.run`` with an -``existing_run`` that carries ``campaign_id``). Spawned plan-entry indices are -persisted on the campaign so re-advancing the same clock never double-spawns. +``campaign_scheduler.decide_schedule`` what is due, then durably claims each +plan occurrence before reusing the existing single-run execution path +(``EvalEngine.run`` with an ``existing_run``). Child-Run identities are the +primary idempotency authority; the legacy campaign summary remains a fallback +for pre-identity Runs created before this migration. This module also hosts the durable scheduler loop: a thin async shell that, tick by tick, maps real wall-clock elapsed time (since the campaign's persisted @@ -20,7 +21,9 @@ from typing import Optional from sqlmodel import Session -from agenteval.evaluation.analysis import resolve_analysis_model, start_campaign_analysis +from agenteval.evaluation.analysis import enqueue_campaign_analysis, resolve_analysis_model +from agenteval.evaluation.campaign_lifecycle import complete_campaign +from agenteval.evaluation.campaign_lifecycle import start_campaign as start_campaign_lifecycle from agenteval.evaluation.campaign_scheduler import ( TickAction, clock_offset, @@ -42,6 +45,7 @@ from agenteval.models import ( from agenteval.storage.db import get_session, utc_now from agenteval.storage.repository import ( CampaignRepository, + CampaignRunClaimStatus, RunRepository, ScenarioRepository, TargetRepository, @@ -59,6 +63,11 @@ _logger = logging.getLogger("agenteval") campaign_registry = TaskRegistry() +def start_campaign_analysis(campaign_id: str, *, triggered_by: str) -> None: + """Compatibility seam for the auto-analysis launcher.""" + enqueue_campaign_analysis(campaign_id, triggered_by=triggered_by) + + @dataclass class AdvanceResult: """Outcome of advancing a campaign's clock once.""" @@ -67,10 +76,29 @@ class AdvanceResult: finished: bool = False -def _spawned_indices(campaign: Campaign) -> set[int]: - if campaign.summary is None: - return set() - return set(campaign.summary.scheduler.spawned_indices) +@dataclass +class CampaignRecoveryResult: + """Outcome of reconciling child Runs left by a previous process.""" + + resumed_run_ids: list[str] = field(default_factory=list) + failed_run_ids: list[str] = field(default_factory=list) + + +def _spawned_indices(campaign: Campaign, session: Optional[Session] = None) -> set[int]: + """Return plan entries with durable child identities, plus legacy progress.""" + spawned = set(campaign.summary.scheduler.spawned_indices) if campaign.summary else set() + if campaign.id and session is not None: + occurrences: dict[int, set[int]] = {} + for run in RunRepository(session).list_by_campaign(campaign.id): + if run.campaign_plan_index is None or run.campaign_occurrence_index is None: + continue + occurrences.setdefault(run.campaign_plan_index, set()).add(run.campaign_occurrence_index) + spawned.update( + plan_index + for plan_index, entry in enumerate(campaign.plan) + if set(range(entry.count)).issubset(occurrences.get(plan_index, set())) + ) + return spawned def current_window_offset(campaign: Campaign) -> float: @@ -101,31 +129,44 @@ def campaign_progress(campaign: Campaign, runs: list[EvalRun]) -> dict: } -async def _spawn_child_run(campaign: Campaign, scenario_id: str, session: Session) -> str: - """Create a Run owned by the campaign and drive it through the engine. +async def _spawn_child_run( + campaign: Campaign, + scenario_id: str, + *, + plan_index: int, + occurrence_index: int, + session: Session, +) -> Optional[str]: + """Claim one occurrence and drive its pending Run through the engine. - The Run row is created on the caller's ``session``, but the engine runs on - its own session (obtained internally, closed when the run ends) so one - child Run's lifecycle never closes the campaign's session — the same - isolation the single-run background task uses. + A rejected claim means the Campaign stopped being runnable between the + scheduler decision and this write; callers must stop spawning. An existing + pending Run is resumed, while an existing running/completed/failed Run is + returned without replaying external messages. """ - target = TargetRepository(session).get(campaign.target_id) scenario = ScenarioRepository(session).get(scenario_id) - if not target or not scenario: - raise ValueError(f"campaign target or scenario missing: {campaign.target_id}/{scenario_id}") + if not scenario: + raise ValueError(f"campaign scenario missing: {scenario_id}") - run = RunRepository(session).create( - EvalRun( - target_id=campaign.target_id, - scenario_id=scenario_id, - scenario_version=scenario.version or 1, - campaign_id=campaign.id, - triggered_by=RunTrigger.CAMPAIGN, - status=RunStatus.PENDING, - ) + claim = RunRepository(session).claim_campaign_run( + campaign_id=campaign.id or "", + scenario_id=scenario_id, + scenario_version=scenario.version or 1, + plan_index=plan_index, + occurrence_index=occurrence_index, ) - engine = EvalEngine(target=target, scenario=scenario, triggered_by=RunTrigger.CAMPAIGN) - await engine.run(existing_run=run) + if claim.status in (CampaignRunClaimStatus.NOT_FOUND, CampaignRunClaimStatus.CONFLICT): + return None + run = claim.run + if run is None: + raise RuntimeError(f"Campaign child claim returned no Run: {claim.status.value}") + + if run.status is RunStatus.PENDING: + target = TargetRepository(session).get(campaign.target_id) + if not target: + raise ValueError(f"campaign target missing: {campaign.target_id}") + engine = EvalEngine(target=target, scenario=scenario, triggered_by=RunTrigger.CAMPAIGN) + await engine.run(existing_run=run) return run.id or "" @@ -150,7 +191,7 @@ async def advance_campaign( return None offset = clock_offset(elapsed_seconds=elapsed_seconds, time_scale=campaign.time_scale) - spawned = _spawned_indices(campaign) + spawned = _spawned_indices(campaign, session) decision = decide_schedule( plan=campaign.plan, window_seconds=campaign.window_seconds, @@ -164,13 +205,26 @@ async def advance_campaign( # Cancellation stops further spawning; runs already in flight finish. if cancel_event is not None and cancel_event.is_set(): break + claim_rejected = False try: - for _ in range(due.entry.count): - run_id = await _spawn_child_run(campaign, due.entry.scenario_id, session) + for occurrence_index in range(due.entry.count): + run_id = await _spawn_child_run( + campaign, + due.entry.scenario_id, + plan_index=due.index, + occurrence_index=occurrence_index, + session=session, + ) + if run_id is None: + claim_rejected = True + break result.spawned_run_ids.append(run_id) except Exception as exc: # one entry failing must not wedge the campaign errors.append({"entry_index": due.index, "error": str(exc)}) _logger.warning("活动 %s 计划条目 %d 派生失败(跳过): %s", campaign_id, due.index, exc) + if claim_rejected: + break + spawned = _spawned_indices(campaign, session) spawned.add(due.index) # Persist progress per entry: a failure partway through a multi-entry # advance must never lose which entries already spawned, since restart @@ -214,6 +268,70 @@ def _settle_exploration(campaign_id: str, session: Session) -> None: _logger.warning("活动 %s 探索会话结算失败(已跳过): %s", campaign_id, exc) +async def reconcile_campaign_child_runs( + campaign_id: str, + session: Session, + *, + cancel_event: Optional[asyncio.Event] = None, +) -> Optional[CampaignRecoveryResult]: + """Resume safe pending claims and fail running Runs without replaying. + + A pending claim has not crossed the Engine's send seam and can be resumed. + A running Run may already have reached the target, so recovery records an + explicit interruption instead of executing it again. + """ + campaign = CampaignRepository(session).get(campaign_id) + if campaign is None: + return None + if campaign.status is not CampaignStatus.RUNNING: + return CampaignRecoveryResult() + + run_repo = RunRepository(session) + result = CampaignRecoveryResult( + failed_run_ids=run_repo.mark_campaign_running_interrupted(campaign_id) + ) + for run in run_repo.list_pending_campaign_children(campaign_id): + if cancel_event is not None and cancel_event.is_set(): + break + plan_index = run.campaign_plan_index + occurrence_index = run.campaign_occurrence_index + valid_identity = ( + plan_index is not None + and occurrence_index is not None + and 0 <= plan_index < len(campaign.plan) + and 0 <= occurrence_index < campaign.plan[plan_index].count + and run.scenario_id == campaign.plan[plan_index].scenario_id + ) + if not valid_identity: + failed = run_repo.mark_pending_campaign_child_interrupted(run.id or "") + if failed and failed.id: + result.failed_run_ids.append(failed.id) + continue + + try: + resumed_id = await _spawn_child_run( + campaign, + run.scenario_id, + plan_index=plan_index, + occurrence_index=occurrence_index, + session=session, + ) + if resumed_id is None: + break + result.resumed_run_ids.append(resumed_id) + except Exception as exc: + fresh = run_repo.get(run.id or "") + if fresh is not None and fresh.status is RunStatus.PENDING: + failed = run_repo.mark_pending_campaign_child_interrupted(run.id or "") + if failed and failed.id: + result.failed_run_ids.append(failed.id) + elif fresh is not None and fresh.status is RunStatus.FAILED and fresh.id: + result.failed_run_ids.append(fresh.id) + _logger.warning("活动 %s 子运行 %s 恢复失败: %s", campaign_id, run.id, exc) + + return result + + # ── durable scheduler loop ────────────────────────────────────────────────── @@ -229,6 +347,7 @@ async def run_campaign_loop( """ session = get_session() try: + await reconcile_campaign_child_runs(campaign_id, session, cancel_event=cancel) while not cancel.is_set(): repo = CampaignRepository(session) campaign = repo.get(campaign_id) @@ -243,7 +362,7 @@ async def run_campaign_loop( window_seconds=campaign.window_seconds, time_scale=campaign.time_scale, plan=campaign.plan, - spawned_indices=_spawned_indices(campaign), + spawned_indices=_spawned_indices(campaign, session), ) if decision.action is TickAction.STOP: return @@ -258,13 +377,14 @@ async def run_campaign_loop( if decision.action is TickAction.COMPLETE: current = repo.get(campaign_id) - # Cancel-race guard: only complete if still RUNNING (pure rule). + # The lifecycle CAS is the authoritative cancel-race guard. if current and resolve_finalize(current.status) is TickAction.COMPLETE: - current.status = CampaignStatus.COMPLETED - current.completed_at = utc_now() - repo.update(current) - _settle_exploration(campaign_id, session) - _auto_start_analysis(current, session) + try: + completed = complete_campaign(campaign_id=campaign_id, session=session) + except Exception as exc: + _logger.warning("活动 %s 完成迁移失败: %s", campaign_id, exc) + else: + _auto_start_analysis(completed, session) return try: @@ -283,15 +403,13 @@ def start_campaign(campaign_id: str, session: Session, *, tick_seconds: float = a PLANNED campaign gets a fresh ``started_at``; an already-RUNNING one keeps its original window start so recovery resumes at the correct offset. """ - repo = CampaignRepository(session) - campaign = repo.get(campaign_id) + campaign = CampaignRepository(session).get(campaign_id) if not campaign or campaign.status in (CampaignStatus.COMPLETED, CampaignStatus.CANCELLED, CampaignStatus.FAILED): return None - if campaign.status == CampaignStatus.PLANNED: - campaign.status = CampaignStatus.RUNNING - campaign.started_at = utc_now() - repo.update(campaign) + started = start_campaign_lifecycle(session, campaign_id) + if started is None: + return None return campaign_registry.launch( campaign_id, diff --git a/backend/agenteval/evaluation/engine.py b/backend/agenteval/evaluation/engine.py index 87918a4..d161bcc 100644 --- a/backend/agenteval/evaluation/engine.py +++ b/backend/agenteval/evaluation/engine.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Callable, Optional -from agenteval.channels.base import EvalChannel +from agenteval.channels.base import EvalChannel, ExchangeOutcome, ExchangeStatus, SendResult from agenteval.channels.factory import ChannelFactory from agenteval.config import get_settings from agenteval.evaluation.implicit_rules import derive_implicit_rules @@ -35,7 +35,7 @@ from agenteval.models import ( from agenteval.services.model_configs import ModelConfigService, ModelRuntimeConfig from agenteval.storage.db import get_session, utc_now from agenteval.storage.repository import ResultRepository, RunRepository -from agenteval.utils.llm import extract_reply_text, parse_json_from_llm_text +from agenteval.utils.llm import parse_json_from_llm_text # Progress callbacks may be sync or async; the engine awaits the result if # it is a coroutine, otherwise treats it as a plain function. @@ -61,6 +61,18 @@ def _build_send_message(content: str) -> dict[str, Any]: } +def _reply_payload(outcome: ExchangeOutcome) -> Optional[dict[str, Any]]: + """Recover the adapter payload kept in the opaque exchange diagnostic.""" + if not outcome.ok: + return None + diagnostic = outcome.diagnostic + if isinstance(diagnostic, dict): + reply = diagnostic.get("reply") + if isinstance(reply, dict): + return reply + return {"msgBody": {"content": outcome.reply_text or ""}} + + # 需要模型资源的规则类型 → 评测岗位(ModelPurpose);其余规则无需模型 RULE_PURPOSE = { "llm_score": ModelPurpose.JUDGE, @@ -277,6 +289,26 @@ class EvalEngine: self.result_repo.save_turn(turn) return turn + def _complete_turn(self, turn: Turn, outcome: ExchangeOutcome, received_at: datetime) -> Turn: + """Attach exchange facts without replacing the already-sent ledger row.""" + reply = _reply_payload(outcome) + if ( + self.result_repo.update_turn_exchange( + turn.id or "", + question_msg_id=outcome.correlation_id, + reply=reply, + received_at=received_at, + latency_ms=outcome.latency_ms, + ) + is None + ): + raise RuntimeError(f"persisted turn disappeared: {turn.id}") + turn.question_msg_id = outcome.correlation_id + turn.reply = reply + turn.received_at = received_at + turn.latency_ms = outcome.latency_ms + return turn + async def _run_case( self, run: EvalRun, @@ -315,8 +347,25 @@ class EvalEngine: ) sent_at = utc_now() - send_result = await self.channel.send(message) - if not send_result.ok: + turn: Optional[Turn] = None + + async def record_sent(send_result: SendResult) -> None: + nonlocal turn + turn = self._persist_turn( + run, + case, + round_index, + message, + sent_at, + question_msg_id=send_result.question_msg_id, + ) + + outcome = await self.channel.exchange( + message, + timeout=self.timeout_config.poll_reply, + on_sent=record_sent, + ) + if outcome.status is ExchangeStatus.SEND_FAILED: turn = self._persist_turn(run, case, round_index, message, sent_at) await self._save_rule_results(run, case, turn, [], progress_callback) await self._emit( @@ -325,54 +374,28 @@ class EvalEngine: { "case_id": case.id, "round": round_index, - "error": send_result.error, + "error": outcome.reason, }, ) return failed, 0, 0 - try: - reply = await self.channel.poll_reply( - send_result.question_msg_id or "", - timeout=self.timeout_config.poll_reply, - ) - except Exception as poll_exc: - received_at = utc_now() - turn = self._persist_turn( - run, - case, - round_index, - message, - sent_at, - question_msg_id=send_result.question_msg_id, - received_at=received_at, - ) + if turn is None: + raise RuntimeError("channel exchange succeeded without invoking the sent hook") + + received_at = utc_now() + turn = self._complete_turn(turn, outcome, received_at) + if outcome.status is ExchangeStatus.POLL_FAILED: await self._emit( progress_callback, "turn_error", { "case_id": case.id, "round": round_index, - "error": f"poll_reply 异常: {poll_exc}", + "error": f"poll_reply 异常: {outcome.reason}", }, ) return failed, 0, 0 - received_at = utc_now() - latency_ms = None - if sent_at and received_at: - latency_ms = int((received_at - sent_at).total_seconds() * 1000) - - turn = self._persist_turn( - run, - case, - round_index, - message, - sent_at, - question_msg_id=send_result.question_msg_id, - reply=reply.raw_message if reply else None, - received_at=received_at, - latency_ms=latency_ms, - ) dialog.append(turn) await self._emit( @@ -382,8 +405,8 @@ class EvalEngine: "run_id": run.id, "case_id": case.id, "round": round_index, - "latency_ms": latency_ms, - "reply_text": extract_reply_text(reply.raw_message if reply else None), + "latency_ms": outcome.latency_ms, + "reply_text": outcome.reply_text or "", }, ) @@ -505,9 +528,9 @@ class EvalEngine: ) messages_payload = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": f"请生成 {turns} 条测试消息"}, - ] + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"请生成 {turns} 条测试消息"}, + ] try: model_config = self._resolve_model(ModelPurpose.GENERATOR) diff --git a/backend/agenteval/exploration/lifecycle.py b/backend/agenteval/exploration/lifecycle.py index a04f2ba..d54d190 100644 --- a/backend/agenteval/exploration/lifecycle.py +++ b/backend/agenteval/exploration/lifecycle.py @@ -6,11 +6,11 @@ ChannelFactory 既有接缝。 """ -import json from typing import Any, Optional from sqlmodel import Session +from agenteval.channels.base import ExchangeStatus, SendResult from agenteval.channels.factory import ChannelFactory from agenteval.config import get_settings from agenteval.exploration.errors import ( @@ -38,21 +38,6 @@ from agenteval.storage.repository import ( ) -def coerce_reply_text(content: Any) -> str: - """Flatten a reply payload to text; tutu returns msgBody as a parsed object, - and str(dict) would leak a Python repr into the view.""" - if isinstance(content, str): - return content - if isinstance(content, dict): - for key in ("content", "text", "message"): - value = content.get(key) - if isinstance(value, str) and value: - return value - if content is None: - return "" - return json.dumps(content, ensure_ascii=False) - - def _check_creation_guardrails( campaign, triggered_by: ExplorationTrigger, @@ -94,14 +79,16 @@ def open_session( repo = ExplorationSessionRepository(db_session) _check_creation_guardrails(campaign, triggered_by, budget, repo) - return repo.create(ExplorationSession( - campaign_id=campaign.id, - target_id=campaign.target_id, - persona=persona, - goal=goal, - seed_ref=seed_ref, - triggered_by=triggered_by, - )) + return repo.create( + ExplorationSession( + campaign_id=campaign.id, + target_id=campaign.target_id, + persona=persona, + goal=goal, + seed_ref=seed_ref, + triggered_by=triggered_by, + ) + ) async def conduct_turn(db_session: Session, *, session_id: str, content: str) -> dict[str, Any]: @@ -125,38 +112,43 @@ async def conduct_turn(db_session: Session, *, session_id: str, content: str) -> if not target: raise ExplorationNotFoundError("session target not found") - channel = ChannelFactory.create(target) - sent_at = utc_now() - try: - send_result = await channel.send(content) - except Exception as exc: # channel adapters raise transport-specific errors - raise ExplorationChannelError(f"评测对象通道发送失败: {exc}") from exc - if not send_result.ok: - raise ExplorationChannelError(f"评测对象通道发送失败: {send_result.error}") - message_repo = ExplorationMessageRepository(db_session) round_index = session_obj.turn_count + 1 - message_repo.save_message( - ExplorationMessage( - session_id=session_obj.id, round_index=round_index, role="user", content=content, created_at=sent_at - ) - ) - session_obj.turn_count = round_index - repo.update(session_obj) + sent_at = utc_now() + channel = ChannelFactory.create(target) - try: - reply = await channel.poll_reply( - send_result.question_msg_id, - timeout=get_settings().poll_reply_timeout, + async def record_sent(_send_result: SendResult) -> None: + message_repo.save_message( + ExplorationMessage( + session_id=session_obj.id, + round_index=round_index, + role="user", + content=content, + created_at=sent_at, + ) ) - except Exception as exc: - raise ExplorationChannelError(f"等待评测对象回复失败: {exc}") from exc - if reply is None: + session_obj.turn_count = round_index + repo.update(session_obj) + + outcome = await channel.exchange( + content, + timeout=get_settings().poll_reply_timeout, + on_sent=record_sent, + ) + if outcome.status is ExchangeStatus.SEND_FAILED: + raise ExplorationChannelError(f"评测对象通道发送失败: {outcome.reason}") + if outcome.status is ExchangeStatus.POLL_FAILED: + raise ExplorationChannelError(f"等待评测对象回复失败: {outcome.reason}") + if outcome.status is ExchangeStatus.REPLY_TIMEOUT: raise ExplorationChannelError("等待评测对象回复超时") received_at = utc_now() - latency_ms = int((received_at - sent_at).total_seconds() * 1000) - reply_text = coerce_reply_text(reply.content) + latency_ms = ( + outcome.latency_ms + if outcome.latency_ms is not None + else int((received_at - sent_at).total_seconds() * 1000) + ) + reply_text = outcome.reply_text or "" message_repo.save_message( ExplorationMessage( session_id=session_obj.id, diff --git a/backend/agenteval/intelligent_eval/lifecycle.py b/backend/agenteval/intelligent_eval/lifecycle.py index 042b32e..5390792 100644 --- a/backend/agenteval/intelligent_eval/lifecycle.py +++ b/backend/agenteval/intelligent_eval/lifecycle.py @@ -10,11 +10,11 @@ 非法转换抛 IntelligentEvalTransitionError,路由层映射为 409。 """ -import json from typing import Any from sqlmodel import Session +from agenteval.channels.base import ExchangeStatus, SendResult from agenteval.channels.factory import ChannelFactory from agenteval.config import get_settings from agenteval.intelligent_eval.models import ( @@ -25,6 +25,7 @@ from agenteval.intelligent_eval.models import ( IntelligentEvalStatus, ) from agenteval.intelligent_eval.repository import ( + CompareAndSetStatus, IntelligentEvalMessageRepository, IntelligentEvalRepository, IntelligentEvalSessionRepository, @@ -36,8 +37,16 @@ from agenteval.storage.repository import TargetRepository _TRANSITIONS: dict[IntelligentEvalStatus, set[IntelligentEvalStatus]] = { IntelligentEvalStatus.DRAFT: {IntelligentEvalStatus.PLANNING}, IntelligentEvalStatus.PLANNING: {IntelligentEvalStatus.PENDING_APPROVAL}, - IntelligentEvalStatus.PENDING_APPROVAL: {IntelligentEvalStatus.EXECUTING, IntelligentEvalStatus.PLANNING, IntelligentEvalStatus.CANCELLED}, - IntelligentEvalStatus.EXECUTING: {IntelligentEvalStatus.COMPLETED, IntelligentEvalStatus.CANCELLED, IntelligentEvalStatus.FAILED}, + IntelligentEvalStatus.PENDING_APPROVAL: { + IntelligentEvalStatus.EXECUTING, + IntelligentEvalStatus.PLANNING, + IntelligentEvalStatus.CANCELLED, + }, + IntelligentEvalStatus.EXECUTING: { + IntelligentEvalStatus.COMPLETED, + IntelligentEvalStatus.CANCELLED, + IntelligentEvalStatus.FAILED, + }, IntelligentEvalStatus.COMPLETED: set(), IntelligentEvalStatus.CANCELLED: set(), IntelligentEvalStatus.FAILED: set(), @@ -58,20 +67,6 @@ class IntelligentEvalChannelError(Exception): pass -def coerce_reply_text(content: Any) -> str: - """Flatten a reply payload to text (tutu returns msgBody as a parsed object).""" - if isinstance(content, str): - return content - if isinstance(content, dict): - for key in ("content", "text", "message"): - value = content.get(key) - if isinstance(value, str) and value: - return value - if content is None: - return "" - return json.dumps(content, ensure_ascii=False) - - def _get_or_raise(repo: IntelligentEvalRepository, eval_id: str) -> IntelligentEval: ev = repo.get(eval_id) if ev is None: @@ -79,13 +74,34 @@ def _get_or_raise(repo: IntelligentEvalRepository, eval_id: str) -> IntelligentE return ev +def _resolve_write( + eval_id: str, + result, + *, + expected: IntelligentEvalStatus, + target: IntelligentEvalStatus, +) -> IntelligentEval: + if result.status is CompareAndSetStatus.NOT_FOUND: + raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found") + if result.status is CompareAndSetStatus.CONFLICT: + raise IntelligentEvalTransitionError( + f"cannot transition from {expected.value} to {target.value}; state changed concurrently" + ) + if result.evaluation is None: + raise RuntimeError(f"lifecycle write returned no evaluation: {eval_id}") + return result.evaluation + + def _transition(repo: IntelligentEvalRepository, ev: IntelligentEval, target: IntelligentEvalStatus) -> IntelligentEval: allowed = _TRANSITIONS.get(ev.status, set()) if target not in allowed: - raise IntelligentEvalTransitionError( - f"cannot transition from {ev.status.value} to {target.value}" - ) - return repo.transition_status(ev.id, target) + raise IntelligentEvalTransitionError(f"cannot transition from {ev.status.value} to {target.value}") + result = repo._compare_and_set_status( + ev.id, + expected_status=ev.status, + new_status=target, + ) + return _resolve_write(ev.id, result, expected=ev.status, target=target) def create_eval( @@ -100,28 +116,37 @@ def create_eval( time_window_hours: int = 24, ) -> IntelligentEval: """创建智能评估并直接进入 planning 状态(draft → planning 一步完成)。""" + if TargetRepository(session).get(target_id) is None: + raise IntelligentEvalNotFoundError(f"target {target_id} not found") + repo = IntelligentEvalRepository(session) - ev = repo.create(IntelligentEval( - name=name, - target_id=target_id, - status=IntelligentEvalStatus.DRAFT, - goal=goal, - seeds=seeds, - intent=intent, - role_description=role_description, - time_window_hours=time_window_hours, - )) - return _transition(repo, ev, IntelligentEvalStatus.PLANNING) + ev = repo.create( + IntelligentEval( + name=name, + target_id=target_id, + status=IntelligentEvalStatus.PLANNING, + goal=goal, + seeds=seeds, + intent=intent, + role_description=role_description, + time_window_hours=time_window_hours, + created_at=utc_now(), + updated_at=utc_now(), + ) + ) + return ev def submit_plan(session: Session, eval_id: str, plan: dict[str, Any]) -> IntelligentEval: """OpenClaw 提交粗计划:planning → pending_approval。""" repo = IntelligentEvalRepository(session) - ev = _get_or_raise(repo, eval_id) - ev.plan = plan - ev.plan_feedback = None - ev = repo.update(ev) - return _transition(repo, ev, IntelligentEvalStatus.PENDING_APPROVAL) + result = repo._submit_plan_if_planning(eval_id, plan) + return _resolve_write( + eval_id, + result, + expected=IntelligentEvalStatus.PLANNING, + target=IntelligentEvalStatus.PENDING_APPROVAL, + ) def approve(session: Session, eval_id: str) -> IntelligentEval: @@ -134,10 +159,13 @@ def approve(session: Session, eval_id: str) -> IntelligentEval: def reject(session: Session, eval_id: str, feedback: str) -> IntelligentEval: """用户打回:pending_approval → planning(附反馈)。""" repo = IntelligentEvalRepository(session) - ev = _get_or_raise(repo, eval_id) - ev.plan_feedback = feedback - ev = repo.update(ev) - return _transition(repo, ev, IntelligentEvalStatus.PLANNING) + result = repo._reject_plan_if_pending(eval_id, feedback) + return _resolve_write( + eval_id, + result, + expected=IntelligentEvalStatus.PENDING_APPROVAL, + target=IntelligentEvalStatus.PLANNING, + ) def cancel(session: Session, eval_id: str) -> IntelligentEval: @@ -153,10 +181,13 @@ def submit_report(session: Session, eval_id: str, report: dict[str, Any]) -> Int 报告结构校验在路由层(pydantic),此处只负责落库与状态迁移。 """ repo = IntelligentEvalRepository(session) - ev = _get_or_raise(repo, eval_id) - ev.report = report - ev = repo.update(ev) - return _transition(repo, ev, IntelligentEvalStatus.COMPLETED) + result = repo._submit_report_if_executing(eval_id, report) + return _resolve_write( + eval_id, + result, + expected=IntelligentEvalStatus.EXECUTING, + target=IntelligentEvalStatus.COMPLETED, + ) def get_eval(session: Session, eval_id: str) -> IntelligentEval: @@ -187,23 +218,28 @@ def open_session( eval_repo = IntelligentEvalRepository(session) ev = _get_or_raise(eval_repo, eval_id) if ev.status != IntelligentEvalStatus.EXECUTING: - raise IntelligentEvalTransitionError( - f"评估不在执行中(当前 {ev.status.value}),无法创建会话" - ) + raise IntelligentEvalTransitionError(f"评估不在执行中(当前 {ev.status.value}),无法创建会话") repo = IntelligentEvalSessionRepository(session) - return repo.create(IntelligentEvalSession( - eval_id=ev.id, - target_id=ev.target_id, - persona=persona, - goal=goal, - dimension=dimension, - )) + status, created = repo._create_if_executing( + IntelligentEvalSession( + eval_id=ev.id, + target_id=ev.target_id, + persona=persona, + goal=goal, + dimension=dimension, + ) + ) + if status is CompareAndSetStatus.CONFLICT: + raise IntelligentEvalTransitionError("评估不在执行中,会话创建被拒绝") + if status is CompareAndSetStatus.NOT_FOUND or created is None: + raise IntelligentEvalNotFoundError(f"intelligent eval {eval_id} not found") + return created -async def conduct_turn(session: Session, *, session_id: str, content: str) -> dict[str, Any]: +async def conduct_turn(session: Session, *, eval_id: str, session_id: str, content: str) -> dict[str, Any]: """一轮完整问答:状态检查 → 通道往返 → 双条消息落库 → 轮次自增。""" repo = IntelligentEvalSessionRepository(session) - obj = _get_session_or_raise(repo, session_id) + obj = _get_owned_session_or_raise(repo, eval_id, session_id) if obj.status != IntelligentEvalSessionStatus.RUNNING: raise IntelligentEvalTransitionError("会话不在进行中,拒收消息") @@ -211,58 +247,81 @@ async def conduct_turn(session: Session, *, session_id: str, content: str) -> di if not target: raise IntelligentEvalNotFoundError("session target not found") - channel = ChannelFactory.create(target) - sent_at = utc_now() - try: - send_result = await channel.send(content) - except Exception as exc: # channel adapters raise transport-specific errors - raise IntelligentEvalChannelError(f"评测对象通道发送失败: {exc}") from exc - if not send_result.ok: - raise IntelligentEvalChannelError(f"评测对象通道发送失败: {send_result.error}") - message_repo = IntelligentEvalMessageRepository(session) - message_repo.create(IntelligentEvalMessage( - session_id=obj.id, role="user", content=content, created_at=sent_at - )) - repo.increment_turns(obj.id) + sent_at = utc_now() + channel = ChannelFactory.create(target) - try: - reply = await channel.poll_reply( - send_result.question_msg_id, - timeout=get_settings().poll_reply_timeout, + async def record_sent(_send_result: SendResult) -> None: + message = IntelligentEvalMessage( + session_id=obj.id, + role="user", + content=content, + created_at=sent_at, ) - except Exception as exc: - raise IntelligentEvalChannelError(f"等待评测对象回复失败: {exc}") from exc - if reply is None: + status = message_repo._create_user_and_increment(message) + if status is CompareAndSetStatus.NOT_FOUND: + raise IntelligentEvalNotFoundError(f"intelligent eval session {obj.id} not found") + if status is CompareAndSetStatus.CONFLICT: + raise IntelligentEvalTransitionError("会话不在进行中,拒收消息") + + outcome = await channel.exchange( + content, + timeout=get_settings().poll_reply_timeout, + on_sent=record_sent, + ) + if outcome.status is ExchangeStatus.SEND_FAILED: + raise IntelligentEvalChannelError(f"评测对象通道发送失败: {outcome.reason}") + if outcome.status is ExchangeStatus.POLL_FAILED: + raise IntelligentEvalChannelError(f"等待评测对象回复失败: {outcome.reason}") + if outcome.status is ExchangeStatus.REPLY_TIMEOUT: raise IntelligentEvalChannelError("等待评测对象回复超时") received_at = utc_now() - latency_ms = int((received_at - sent_at).total_seconds() * 1000) - reply_text = coerce_reply_text(reply.content) - message_repo.create(IntelligentEvalMessage( - session_id=obj.id, - role="assistant", - content=reply_text, - latency_ms=latency_ms, - created_at=received_at, - )) + latency_ms = ( + outcome.latency_ms if outcome.latency_ms is not None else int((received_at - sent_at).total_seconds() * 1000) + ) + reply_text = outcome.reply_text or "" + message_repo.create( + IntelligentEvalMessage( + session_id=obj.id, + role="assistant", + content=reply_text, + latency_ms=latency_ms, + created_at=received_at, + ) + ) return {"reply": reply_text, "latency_ms": latency_ms, "turn_count": obj.turn_count + 1} -def close_session(session: Session, *, session_id: str, verdict: dict[str, Any]) -> IntelligentEvalSession: +def close_session( + session: Session, + *, + eval_id: str, + session_id: str, + verdict: dict[str, Any], +) -> IntelligentEvalSession: """关闭会话并记录结论(verdict);仅 running 会话可关闭。""" repo = IntelligentEvalSessionRepository(session) - obj = _get_session_or_raise(repo, session_id) - if obj.status != IntelligentEvalSessionStatus.RUNNING: - raise IntelligentEvalTransitionError("会话不在进行中,无法关闭") - closed = repo.close(obj.id, verdict) - if closed is None: + obj = _get_owned_session_or_raise(repo, eval_id, session_id) + status, closed = repo._close_if_running(obj.id, verdict) + if status is CompareAndSetStatus.NOT_FOUND: raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") + if status is CompareAndSetStatus.CONFLICT: + raise IntelligentEvalTransitionError("会话不在进行中,无法关闭") + if closed is None: + raise RuntimeError(f"session close returned no session: {session_id}") return closed -def get_session_by_id(session: Session, session_id: str) -> IntelligentEvalSession: - return _get_session_or_raise(IntelligentEvalSessionRepository(session), session_id) +def _get_owned_session_or_raise( + repo: IntelligentEvalSessionRepository, + eval_id: str, + session_id: str, +) -> IntelligentEvalSession: + obj = _get_session_or_raise(repo, session_id) + if obj.eval_id != eval_id: + raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") + return obj def list_sessions(session: Session, eval_id: str) -> list[IntelligentEvalSession]: @@ -271,6 +330,6 @@ def list_sessions(session: Session, eval_id: str) -> list[IntelligentEvalSession return IntelligentEvalSessionRepository(session).list_by_eval(eval_id) -def list_messages(session: Session, session_id: str) -> list[IntelligentEvalMessage]: - _get_session_or_raise(IntelligentEvalSessionRepository(session), session_id) +def list_messages(session: Session, *, eval_id: str, session_id: str) -> list[IntelligentEvalMessage]: + _get_owned_session_or_raise(IntelligentEvalSessionRepository(session), eval_id, session_id) return IntelligentEvalMessageRepository(session).list_by_session(session_id) diff --git a/backend/agenteval/intelligent_eval/read_model.py b/backend/agenteval/intelligent_eval/read_model.py new file mode 100644 index 0000000..53a156f --- /dev/null +++ b/backend/agenteval/intelligent_eval/read_model.py @@ -0,0 +1,199 @@ +"""Read projections for intelligent evaluations. + +The lifecycle module owns writes and state transitions. This module is the +read seam used by HTTP adapters: it turns domain entities into stable, +purpose-specific projections and keeps session-count/detail assembly out of +routers. Message bodies are intentionally not part of the detail projection; +transcripts remain available through the dedicated messages endpoint. +""" + +from collections.abc import Sequence +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field +from sqlmodel import Session + +from agenteval.intelligent_eval.models import ( + IntelligentEval, + IntelligentEvalMessage, + IntelligentEvalSession, + IntelligentEvalSessionStatus, + IntelligentEvalStatus, +) +from agenteval.intelligent_eval.repository import ( + IntelligentEvalMessageRepository, + IntelligentEvalRepository, + IntelligentEvalSessionRepository, +) + + +class IntelligentEvalProjection(BaseModel): + """Fields shared by list and detail representations.""" + + id: str + name: str + target_id: str + status: IntelligentEvalStatus + goal: str + seeds: dict[str, Any] = Field(default_factory=dict) + intent: str + role_description: str + plan: dict[str, Any] | None = None + plan_feedback: str | None = None + time_window_hours: int + report: dict[str, Any] | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + + +class IntelligentEvalListItem(IntelligentEvalProjection): + """Compact projection used by collection and mutation responses.""" + + session_count: int = 0 + completed_sessions: int = 0 + + +class IntelligentEvalSessionSummary(BaseModel): + """Session metadata shown in an evaluation detail view. + + This deliberately contains no chat messages. Callers that need a + transcript must use ``GET /{eval_id}/sessions/{session_id}/messages``. + """ + + id: str + eval_id: str + target_id: str + persona: dict[str, Any] = Field(default_factory=dict) + goal: str + dimension: str | None = None + status: IntelligentEvalSessionStatus + verdict: dict[str, Any] | None = None + turn_count: int = 0 + created_at: datetime | None = None + closed_at: datetime | None = None + + +class IntelligentEvalDetail(IntelligentEvalListItem): + """Full evaluation projection, including session metadata only.""" + + sessions: list[IntelligentEvalSessionSummary] = Field(default_factory=list) + + +def _require_id(eval_obj: IntelligentEval) -> str: + if eval_obj.id is None: + raise ValueError("intelligent evaluation projection requires an id") + return eval_obj.id + + +def _base_fields(eval_obj: IntelligentEval) -> dict[str, Any]: + """Copy domain data into the stable projection field set.""" + + return { + "id": _require_id(eval_obj), + "name": eval_obj.name, + "target_id": eval_obj.target_id, + "status": eval_obj.status, + "goal": eval_obj.goal, + "seeds": eval_obj.seeds, + "intent": eval_obj.intent, + "role_description": eval_obj.role_description, + "plan": eval_obj.plan, + "plan_feedback": eval_obj.plan_feedback, + "time_window_hours": eval_obj.time_window_hours, + "report": eval_obj.report, + "created_at": eval_obj.created_at, + "updated_at": eval_obj.updated_at, + "started_at": eval_obj.started_at, + "completed_at": eval_obj.completed_at, + } + + +def _session_summary(session_obj: IntelligentEvalSession) -> IntelligentEvalSessionSummary: + if session_obj.id is None: + raise ValueError("intelligent evaluation session projection requires an id") + return IntelligentEvalSessionSummary.model_validate(session_obj.model_dump()) + + +def project_list_item( + eval_obj: IntelligentEval, + sessions: Sequence[IntelligentEvalSession], +) -> IntelligentEvalListItem: + """Build a list projection from an evaluation and its session metadata.""" + + return IntelligentEvalListItem( + **_base_fields(eval_obj), + session_count=len(sessions), + completed_sessions=sum(1 for item in sessions if item.status is IntelligentEvalSessionStatus.COMPLETED), + ) + + +def project_detail( + eval_obj: IntelligentEval, + sessions: Sequence[IntelligentEvalSession], +) -> IntelligentEvalDetail: + """Build a detail projection without embedding transcript messages.""" + + item = project_list_item(eval_obj, sessions) + return IntelligentEvalDetail( + **item.model_dump(), + sessions=[_session_summary(item) for item in sessions], + ) + + +class IntelligentEvalReadModel: + """Read interface for stable intelligent-evaluation projections. + + The current implementation uses the existing session repository. Query + batching can be added behind this seam without changing Router callers. + """ + + def __init__(self, session: Session): + self._evals = IntelligentEvalRepository(session) + self._sessions = IntelligentEvalSessionRepository(session) + self._messages = IntelligentEvalMessageRepository(session) + + def list_item(self, eval_obj: IntelligentEval) -> IntelligentEvalListItem: + return project_list_item(eval_obj, self._sessions.list_by_eval(_require_id(eval_obj))) + + def list_items(self, evals: Sequence[IntelligentEval]) -> list[IntelligentEvalListItem]: + """Project a collection after one batched session lookup.""" + + sessions_by_eval = self._sessions.list_by_evals([_require_id(item) for item in evals]) + return [project_list_item(item, sessions_by_eval[item.id]) for item in evals if item.id is not None] + + def detail(self, eval_obj: IntelligentEval) -> IntelligentEvalDetail: + return project_detail(eval_obj, self._sessions.list_by_eval(_require_id(eval_obj))) + + def detail_by_id(self, eval_id: str) -> IntelligentEvalDetail | None: + """Read one evaluation and its summaries from the same session snapshot.""" + + eval_obj = self._evals.get(eval_id) + return self.detail(eval_obj) if eval_obj is not None else None + + def sessions_by_eval(self, eval_id: str) -> list[IntelligentEvalSession] | None: + """Return session summaries, or ``None`` when the parent is unknown.""" + + if self._evals.get(eval_id) is None: + return None + return self._sessions.list_by_eval(eval_id) + + def messages_by_session(self, eval_id: str, session_id: str) -> list[IntelligentEvalMessage] | None: + """Read a transcript only when the session belongs to the evaluation.""" + + if self._evals.get(eval_id) is None: + return None + session_obj = self._sessions.get(session_id) + if session_obj is None or session_obj.eval_id != eval_id: + return None + return self._messages.list_by_session(session_id) + + def report_by_eval(self, eval_id: str) -> tuple[str, dict[str, Any]] | None: + """Return the report payload with its evaluation name for renderers.""" + + eval_obj = self._evals.get(eval_id) + if eval_obj is None or eval_obj.report is None: + return None + return eval_obj.name, eval_obj.report diff --git a/backend/agenteval/intelligent_eval/repository.py b/backend/agenteval/intelligent_eval/repository.py index a8329ae..1dbed94 100644 --- a/backend/agenteval/intelligent_eval/repository.py +++ b/backend/agenteval/intelligent_eval/repository.py @@ -1,7 +1,14 @@ """Repository for intelligent evaluation entities.""" +import json +from collections.abc import Sequence +from dataclasses import dataclass +from enum import Enum from typing import Optional +from sqlalchemy import func, literal +from sqlalchemy import insert as sql_insert +from sqlalchemy import update as sql_update from sqlmodel import Session, select from agenteval.intelligent_eval.models import ( @@ -16,10 +23,31 @@ from agenteval.storage.db import ( IntelligentEvalMessageDB, IntelligentEvalSessionDB, get_session, + new_uuid, utc_now, ) +class CompareAndSetStatus(str, Enum): + """Outcome of a conditional intelligent-evaluation write.""" + + APPLIED = "applied" + NOT_FOUND = "not_found" + CONFLICT = "conflict" + + +@dataclass(frozen=True) +class CompareAndSetResult: + """Typed result for a status update guarded by an expected status.""" + + status: CompareAndSetStatus + evaluation: Optional[IntelligentEval] = None + + @property + def applied(self) -> bool: + return self.status is CompareAndSetStatus.APPLIED + + class IntelligentEvalRepository: """CRUD + lifecycle operations for intelligent evaluations.""" @@ -84,33 +112,103 @@ class IntelligentEvalRepository: self.session.refresh(db) return self._from_db(db) - def update(self, obj: IntelligentEval) -> IntelligentEval: - db = self.session.get(IntelligentEvalDB, obj.id) - if not db: - raise ValueError(f"IntelligentEval {obj.id} not found") - updated = self._to_db(obj) - updated.id = db.id - # Preserve fields that _to_db doesn't set from None - self.session.delete(db) - self.session.add(updated) - self.session.commit() - self.session.refresh(updated) - return self._from_db(updated) + def _compare_and_set_status( + self, + eval_id: str, + *, + expected_status: IntelligentEvalStatus, + new_status: IntelligentEvalStatus, + ) -> CompareAndSetResult: + """Atomically transition status only when the expected state still holds.""" + now = utc_now() + values = { + "status": new_status.value, + "updated_at": now, + } + if new_status is IntelligentEvalStatus.EXECUTING: + values["started_at"] = func.coalesce(IntelligentEvalDB.started_at, now) + if new_status in { + IntelligentEvalStatus.COMPLETED, + IntelligentEvalStatus.CANCELLED, + IntelligentEvalStatus.FAILED, + }: + values["completed_at"] = now + + return self._compare_and_set_fields( + eval_id, + expected_status=expected_status, + values=values, + ) + + def _compare_and_set_fields( + self, + eval_id: str, + *, + expected_status: IntelligentEvalStatus, + values: dict[str, object], + ) -> CompareAndSetResult: + """Apply one conditional SQL update and classify its outcome.""" + try: + result = self.session.exec( + sql_update(IntelligentEvalDB) + .where( + IntelligentEvalDB.id == eval_id, + IntelligentEvalDB.status == expected_status.value, + ) + .values(**values) + ) + if result.rowcount != 1: + self.session.rollback() + current = self.session.get(IntelligentEvalDB, eval_id) + status = CompareAndSetStatus.NOT_FOUND if current is None else CompareAndSetStatus.CONFLICT + return CompareAndSetResult(status=status) + self.session.commit() + except Exception: + self.session.rollback() + raise - def transition_status(self, eval_id: str, new_status: IntelligentEvalStatus) -> Optional[IntelligentEval]: db = self.session.get(IntelligentEvalDB, eval_id) - if not db: - return None - db.status = new_status.value - db.updated_at = utc_now() - if new_status == IntelligentEvalStatus.EXECUTING and db.started_at is None: - db.started_at = utc_now() - if new_status in (IntelligentEvalStatus.COMPLETED, IntelligentEvalStatus.CANCELLED, IntelligentEvalStatus.FAILED): - db.completed_at = utc_now() - self.session.add(db) - self.session.commit() - self.session.refresh(db) - return self._from_db(db) + if db is None: + return CompareAndSetResult(status=CompareAndSetStatus.NOT_FOUND) + return CompareAndSetResult(status=CompareAndSetStatus.APPLIED, evaluation=self._from_db(db)) + + def _submit_plan_if_planning(self, eval_id: str, plan: dict) -> CompareAndSetResult: + now = utc_now() + return self._compare_and_set_fields( + eval_id, + expected_status=IntelligentEvalStatus.PLANNING, + values={ + "status": IntelligentEvalStatus.PENDING_APPROVAL.value, + "plan": json.dumps(plan, ensure_ascii=False), + "plan_feedback": None, + "updated_at": now, + }, + ) + + def _reject_plan_if_pending(self, eval_id: str, feedback: str) -> CompareAndSetResult: + now = utc_now() + return self._compare_and_set_fields( + eval_id, + expected_status=IntelligentEvalStatus.PENDING_APPROVAL, + values={ + "status": IntelligentEvalStatus.PLANNING.value, + "plan_feedback": feedback, + "updated_at": now, + }, + ) + + def _submit_report_if_executing(self, eval_id: str, report: dict) -> CompareAndSetResult: + now = utc_now() + return self._compare_and_set_fields( + eval_id, + expected_status=IntelligentEvalStatus.EXECUTING, + values={ + "status": IntelligentEvalStatus.COMPLETED.value, + "report": json.dumps(report, ensure_ascii=False), + "updated_at": now, + "completed_at": now, + }, + ) def delete(self, eval_id: str) -> bool: db = self.session.get(IntelligentEvalDB, eval_id) @@ -150,6 +248,26 @@ class IntelligentEvalSessionRepository: ) return [self._from_db(r) for r in self.session.exec(statement).all()] + def list_by_evals(self, eval_ids: Sequence[str]) -> dict[str, list[IntelligentEvalSession]]: + """Load sessions for several evaluations with one query. + + The result contains an empty list for every requested evaluation. This + keeps read-model assembly deterministic while avoiding one count query + per row in the evaluation list. + """ + + grouped = {eval_id: [] for eval_id in eval_ids} + if not grouped: + return grouped + statement = ( + select(IntelligentEvalSessionDB) + .where(IntelligentEvalSessionDB.eval_id.in_(tuple(grouped))) + .order_by(IntelligentEvalSessionDB.created_at.asc()) + ) + for row in self.session.exec(statement).all(): + grouped.setdefault(row.eval_id, []).append(self._from_db(row)) + return grouped + def get(self, session_id: str) -> Optional[IntelligentEvalSession]: db = self.session.get(IntelligentEvalSessionDB, session_id) return self._from_db(db) if db else None @@ -172,7 +290,63 @@ class IntelligentEvalSessionRepository: self.session.refresh(db) return self._from_db(db) - def close(self, session_id: str, verdict: dict, status: IntelligentEvalSessionStatus = IntelligentEvalSessionStatus.COMPLETED) -> Optional[IntelligentEvalSession]: + def _create_if_executing( + self, + obj: IntelligentEvalSession, + ) -> tuple[CompareAndSetStatus, Optional[IntelligentEvalSession]]: + """Insert a session only while its parent evaluation is executing.""" + session_id = obj.id or new_uuid() + now = obj.created_at or utc_now() + statement = sql_insert(IntelligentEvalSessionDB).from_select( + [ + "id", + "eval_id", + "target_id", + "persona", + "goal", + "dimension", + "status", + "turn_count", + "created_at", + ], + select( + literal(session_id), + IntelligentEvalDB.id, + IntelligentEvalDB.target_id, + literal(json.dumps(obj.persona, ensure_ascii=False)), + literal(obj.goal), + literal(obj.dimension), + literal(IntelligentEvalSessionStatus.RUNNING.value), + literal(0), + literal(now), + ).where( + IntelligentEvalDB.id == obj.eval_id, + IntelligentEvalDB.status == IntelligentEvalStatus.EXECUTING.value, + ), + ) + try: + result = self.session.exec(statement) + if result.rowcount != 1: + self.session.rollback() + evaluation = self.session.get(IntelligentEvalDB, obj.eval_id) + status = CompareAndSetStatus.NOT_FOUND if evaluation is None else CompareAndSetStatus.CONFLICT + return status, None + self.session.commit() + except Exception: + self.session.rollback() + raise + + db = self.session.get(IntelligentEvalSessionDB, session_id) + if db is None: + return CompareAndSetStatus.NOT_FOUND, None + return CompareAndSetStatus.APPLIED, self._from_db(db) + + def close( + self, + session_id: str, + verdict: dict, + status: IntelligentEvalSessionStatus = IntelligentEvalSessionStatus.COMPLETED, + ) -> Optional[IntelligentEvalSession]: db = self.session.get(IntelligentEvalSessionDB, session_id) if not db: return None @@ -184,6 +358,42 @@ class IntelligentEvalSessionRepository: self.session.refresh(db) return self._from_db(db) + def _close_if_running( + self, + session_id: str, + verdict: dict, + status: IntelligentEvalSessionStatus = IntelligentEvalSessionStatus.COMPLETED, + ) -> tuple[CompareAndSetStatus, Optional[IntelligentEvalSession]]: + """Close exactly one running session without overwriting a race winner.""" + now = utc_now() + try: + result = self.session.exec( + sql_update(IntelligentEvalSessionDB) + .where( + IntelligentEvalSessionDB.id == session_id, + IntelligentEvalSessionDB.status == IntelligentEvalSessionStatus.RUNNING.value, + ) + .values( + status=status.value, + verdict=json.dumps(verdict, ensure_ascii=False), + closed_at=now, + ) + ) + if result.rowcount != 1: + self.session.rollback() + current = self.session.get(IntelligentEvalSessionDB, session_id) + outcome = CompareAndSetStatus.NOT_FOUND if current is None else CompareAndSetStatus.CONFLICT + return outcome, None + self.session.commit() + except Exception: + self.session.rollback() + raise + + db = self.session.get(IntelligentEvalSessionDB, session_id) + if db is None: + return CompareAndSetStatus.NOT_FOUND, None + return CompareAndSetStatus.APPLIED, self._from_db(db) + def increment_turns(self, session_id: str) -> None: db = self.session.get(IntelligentEvalSessionDB, session_id) if db: @@ -229,3 +439,31 @@ class IntelligentEvalMessageRepository: self.session.commit() self.session.refresh(db) return self._from_db(db) + + def _create_user_and_increment(self, obj: IntelligentEvalMessage) -> CompareAndSetStatus: + """Persist the sent user message and consume one turn atomically.""" + session_db = self.session.get(IntelligentEvalSessionDB, obj.session_id) + if session_db is None: + return CompareAndSetStatus.NOT_FOUND + if session_db.status != IntelligentEvalSessionStatus.RUNNING.value: + return CompareAndSetStatus.CONFLICT + + message_db = IntelligentEvalMessageDB( + id=obj.id, + session_id=obj.session_id, + role="user", + content=obj.content, + latency_ms=None, + created_at=obj.created_at, + ) + try: + session_db.turn_count += 1 + self.session.add(session_db) + self.session.add(message_db) + self.session.commit() + self.session.refresh(message_db) + except Exception: + self.session.rollback() + raise + obj.id = message_db.id + return CompareAndSetStatus.APPLIED diff --git a/backend/agenteval/models.py b/backend/agenteval/models.py index 66221d3..6871760 100644 --- a/backend/agenteval/models.py +++ b/backend/agenteval/models.py @@ -222,6 +222,9 @@ class EvalRun(BaseModel): scenario_version: int = 1 # 归属的评估活动(Campaign);手动/单次运行为空 campaign_id: Optional[str] = None + # Campaign 计划条目的持久化身份;历史 Run 保持为空,不做回填。 + campaign_plan_index: Optional[int] = Field(default=None, ge=0) + campaign_occurrence_index: Optional[int] = Field(default=None, ge=0) status: RunStatus = RunStatus.PENDING triggered_by: RunTrigger = RunTrigger.MANUAL started_at: Optional[datetime] = None diff --git a/backend/agenteval/storage/db.py b/backend/agenteval/storage/db.py index 1b2ad56..6a234fb 100644 --- a/backend/agenteval/storage/db.py +++ b/backend/agenteval/storage/db.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional +import sqlalchemy as sa from sqlalchemy.pool import StaticPool from sqlmodel import Field, Relationship, Session, SQLModel, create_engine @@ -348,12 +349,23 @@ class EvalRunDB(SQLModel, table=True): """Database table for evaluation runs.""" __tablename__ = "eval_runs" + __table_args__ = ( + sa.Index( + "uq_eval_runs_campaign_occurrence", + "campaign_id", + "campaign_plan_index", + "campaign_occurrence_index", + unique=True, + ), + ) id: Optional[str] = Field(default_factory=new_uuid, primary_key=True) target_id: Optional[str] = Field(default=None, foreign_key="eval_targets.id") scenario_id: Optional[str] = Field(default=None, foreign_key="scenarios.id") scenario_version: int = Field(default=1) campaign_id: Optional[str] = Field(default=None, foreign_key="campaigns.id") + campaign_plan_index: Optional[int] = Field(default=None, ge=0) + campaign_occurrence_index: Optional[int] = Field(default=None, ge=0) status: str = "pending" triggered_by: str = Field(default="manual") started_at: Optional[datetime] = Field(default_factory=utc_now) diff --git a/backend/agenteval/storage/repository.py b/backend/agenteval/storage/repository.py index fedecc2..729162a 100644 --- a/backend/agenteval/storage/repository.py +++ b/backend/agenteval/storage/repository.py @@ -1,12 +1,29 @@ """Repository layer for database access.""" +from dataclasses import dataclass from datetime import datetime +from enum import Enum from typing import Generic, Optional, TypeVar +from sqlalchemy import insert as sql_insert +from sqlalchemy import literal +from sqlalchemy import update as sql_update +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from agenteval.exploration.models import ExplorationMessage, ExplorationSession, ExplorationSessionStatus -from agenteval.models import Campaign, CampaignStatus, CampaignSummary, Case, EvalResult, EvalRun, EvalTarget, Scenario +from agenteval.models import ( + Campaign, + CampaignStatus, + CampaignSummary, + Case, + EvalResult, + EvalRun, + EvalTarget, + RunStatus, + RunTrigger, + Scenario, +) from agenteval.services.model_configs import ModelConfigService from agenteval.storage.db import ( CampaignAnalysisDB, @@ -20,6 +37,7 @@ from agenteval.storage.db import ( ScenarioDB, TurnDB, get_session, + new_uuid, utc_now, ) from agenteval.storage.model_config_repository import ScenarioModelBindingRepository @@ -253,12 +271,122 @@ class ScenarioRepository(BaseRepository[Scenario, ScenarioDB]): return {sid: name for sid, name in self.session.exec(select(ScenarioDB.id, ScenarioDB.name)).all()} +class CampaignRunClaimStatus(str, Enum): + """Outcome of a durable Campaign child-Run claim.""" + + CLAIMED = "claimed" + EXISTING = "existing" + NOT_FOUND = "not_found" + CONFLICT = "conflict" + + +@dataclass(frozen=True) +class CampaignRunClaimResult: + """Typed result for the idempotent child-Run claim seam.""" + + status: CampaignRunClaimStatus + run: Optional[EvalRun] = None + + @property + def accepted(self) -> bool: + return self.status in (CampaignRunClaimStatus.CLAIMED, CampaignRunClaimStatus.EXISTING) + + @property + def created(self) -> bool: + return self.status is CampaignRunClaimStatus.CLAIMED + + class RunRepository(BaseRepository[EvalRun, EvalRunDB]): """Repository for evaluation runs.""" _table = EvalRunDB _order_by = "started_at" + def claim_campaign_run( + self, + *, + campaign_id: str, + scenario_id: str, + scenario_version: int, + plan_index: int, + occurrence_index: int, + ) -> "CampaignRunClaimResult": + """Claim one durable child Run occurrence while its Campaign is running. + + The conditional ``INSERT ... SELECT`` makes the Campaign status check + part of the write. The composite unique index turns retries or a + competing scheduler into an ``EXISTING`` result instead of a second + pending Run. No external messages are sent by this operation. + """ + if plan_index < 0 or occurrence_index < 0: + raise ValueError("plan_index and occurrence_index must be non-negative") + + run_id = new_uuid() + now = utc_now() + statement = sql_insert(EvalRunDB).from_select( + [ + "id", + "target_id", + "scenario_id", + "scenario_version", + "campaign_id", + "campaign_plan_index", + "campaign_occurrence_index", + "status", + "triggered_by", + "started_at", + ], + select( + literal(run_id), + CampaignDB.target_id, + literal(scenario_id), + literal(scenario_version), + CampaignDB.id, + literal(plan_index), + literal(occurrence_index), + literal(RunStatus.PENDING.value), + literal(RunTrigger.CAMPAIGN.value), + literal(now), + ).where( + CampaignDB.id == campaign_id, + CampaignDB.status == CampaignStatus.RUNNING.value, + CampaignDB.target_id.is_not(None), + ), + ) + + try: + result = self.session.exec(statement) + if result.rowcount == 1: + self.session.commit() + db = self.session.get(EvalRunDB, run_id) + return CampaignRunClaimResult(CampaignRunClaimStatus.CLAIMED, self._from_db(db)) + self.session.rollback() + except IntegrityError: + self.session.rollback() + except Exception: + self.session.rollback() + raise + + existing = self._get_campaign_identity(campaign_id, plan_index, occurrence_index) + campaign = self.session.get(CampaignDB, campaign_id) + if campaign is None: + return CampaignRunClaimResult(CampaignRunClaimStatus.NOT_FOUND) + if campaign.status != CampaignStatus.RUNNING.value: + return CampaignRunClaimResult(CampaignRunClaimStatus.CONFLICT) + if existing is not None: + return CampaignRunClaimResult(CampaignRunClaimStatus.EXISTING, self._from_db(existing)) + return CampaignRunClaimResult(CampaignRunClaimStatus.CONFLICT) + + def _get_campaign_identity( + self, campaign_id: str, plan_index: int, occurrence_index: int + ) -> Optional[EvalRunDB]: + statement = select(EvalRunDB).where( + EvalRunDB.campaign_id == campaign_id, + EvalRunDB.campaign_plan_index == plan_index, + EvalRunDB.campaign_occurrence_index == occurrence_index, + ) + return self.session.exec(statement).first() + def _to_db(self, run: EvalRun) -> EvalRunDB: db = EvalRunDB( id=run.id, @@ -266,6 +394,8 @@ class RunRepository(BaseRepository[EvalRun, EvalRunDB]): scenario_id=run.scenario_id, scenario_version=run.scenario_version, campaign_id=run.campaign_id, + campaign_plan_index=run.campaign_plan_index, + campaign_occurrence_index=run.campaign_occurrence_index, status=run.status.value, triggered_by=run.triggered_by.value, started_at=run.started_at, @@ -282,6 +412,8 @@ class RunRepository(BaseRepository[EvalRun, EvalRunDB]): scenario_id=db.scenario_id, scenario_version=db.scenario_version or 1, campaign_id=db.campaign_id, + campaign_plan_index=db.campaign_plan_index, + campaign_occurrence_index=db.campaign_occurrence_index, status=db.status, triggered_by=db.triggered_by or "manual", started_at=db.started_at, @@ -294,23 +426,81 @@ class RunRepository(BaseRepository[EvalRun, EvalRunDB]): return [self._from_db(r) for r in self.session.exec(statement).all()] def mark_orphans_failed(self) -> int: - """服务启动时清理:把遗留的 running/pending 运行标记为 failed。 + """Fail process-orphaned Runs while preserving recoverable child claims. - 评测任务是进程内 asyncio 任务,服务重启后不会恢复;不清理则这些 - 运行永远停留在 running(僵尸运行)。 + Every RUNNING Run may already have sent messages and must not replay. + A PENDING Run is preserved only when it has a complete Campaign plan + identity and its parent Campaign is still running; the Campaign loop + can safely resume those claims before any external send occurred. """ statement = select(EvalRunDB).where(EvalRunDB.status.in_(["running", "pending"])) # type: ignore[attr-defined] - orphans = self.session.exec(statement).all() - for db in orphans: - db.status = "failed" - db.completed_at = db.completed_at or utc_now() - summary = db.get_summary() or {} - summary["error"] = {"code": "interrupted", "message": "服务重启导致评测中断"} - db.set_summary(summary) - self.session.add(db) - if orphans: + candidates = self.session.exec(statement).all() + failed: list[EvalRunDB] = [] + for db in candidates: + if db.status == RunStatus.PENDING.value and self._is_recoverable_campaign_pending(db): + continue + self._set_interrupted(db) + failed.append(db) + if failed: self.session.commit() - return len(orphans) + return len(failed) + + def _is_recoverable_campaign_pending(self, db: EvalRunDB) -> bool: + if ( + db.campaign_id is None + or db.campaign_plan_index is None + or db.campaign_occurrence_index is None + ): + return False + campaign = self.session.get(CampaignDB, db.campaign_id) + return campaign is not None and campaign.status == CampaignStatus.RUNNING.value + + def _set_interrupted(self, db: EvalRunDB) -> None: + db.status = RunStatus.FAILED.value + db.completed_at = db.completed_at or utc_now() + summary = db.get_summary() or {} + summary["error"] = {"code": "interrupted", "message": "服务重启导致评测中断"} + db.set_summary(summary) + self.session.add(db) + + def list_pending_campaign_children(self, campaign_id: str) -> list[EvalRun]: + """Return durable pending claims in deterministic plan order.""" + statement = ( + select(EvalRunDB) + .where( + EvalRunDB.campaign_id == campaign_id, + EvalRunDB.campaign_plan_index.is_not(None), + EvalRunDB.campaign_occurrence_index.is_not(None), + EvalRunDB.status == RunStatus.PENDING.value, + ) + .order_by(EvalRunDB.campaign_plan_index, EvalRunDB.campaign_occurrence_index) + ) + return [self._from_db(db) for db in self.session.exec(statement).all()] + + def mark_campaign_running_interrupted(self, campaign_id: str) -> list[str]: + """Fail running child Runs from a previous process without replaying.""" + statement = select(EvalRunDB).where( + EvalRunDB.campaign_id == campaign_id, + EvalRunDB.campaign_plan_index.is_not(None), + EvalRunDB.campaign_occurrence_index.is_not(None), + EvalRunDB.status == RunStatus.RUNNING.value, + ) + rows = list(self.session.exec(statement).all()) + for db in rows: + self._set_interrupted(db) + if rows: + self.session.commit() + return [db.id or "" for db in rows] + + def mark_pending_campaign_child_interrupted(self, run_id: str) -> Optional[EvalRun]: + """Fail one still-pending child claim that cannot be safely recovered.""" + db = self.session.get(EvalRunDB, run_id) + if db is None or db.status != RunStatus.PENDING.value: + return self._from_db(db) if db is not None else None + self._set_interrupted(db) + self.session.commit() + self.session.refresh(db) + return self._from_db(db) def update(self, run: EvalRun) -> Optional[EvalRun]: existing = self.session.get(EvalRunDB, run.id) @@ -320,6 +510,8 @@ class RunRepository(BaseRepository[EvalRun, EvalRunDB]): existing.scenario_id = run.scenario_id existing.scenario_version = run.scenario_version existing.campaign_id = run.campaign_id + existing.campaign_plan_index = run.campaign_plan_index + existing.campaign_occurrence_index = run.campaign_occurrence_index existing.status = run.status.value existing.triggered_by = run.triggered_by.value existing.completed_at = run.completed_at @@ -434,6 +626,57 @@ class CampaignRepository(BaseRepository[Campaign, CampaignDB]): self.session.refresh(db) return self._from_db(db) + def cancel_if_active(self, campaign_id: str, at: datetime) -> tuple[str, Optional[Campaign]]: + """Compare-and-set a planned/running Campaign to cancelled.""" + statement = ( + sql_update(CampaignDB) + .where( + CampaignDB.id == campaign_id, + CampaignDB.status.in_([CampaignStatus.PLANNED.value, CampaignStatus.RUNNING.value]), + ) + .values(status=CampaignStatus.CANCELLED.value, completed_at=at) + ) + result = self.session.exec(statement) + if result.rowcount == 1: + self.session.commit() + db = self.session.get(CampaignDB, campaign_id) + return "applied", self._from_db(db) if db else None + self.session.rollback() + db = self.session.get(CampaignDB, campaign_id) + return ("not_found", None) if db is None else ("conflict", self._from_db(db)) + + def complete_if_running(self, campaign_id: str, at: datetime) -> tuple[str, Optional[Campaign]]: + """Compare-and-set a running Campaign to completed.""" + statement = ( + sql_update(CampaignDB) + .where(CampaignDB.id == campaign_id, CampaignDB.status == CampaignStatus.RUNNING.value) + .values(status=CampaignStatus.COMPLETED.value, completed_at=at) + ) + result = self.session.exec(statement) + if result.rowcount == 1: + self.session.commit() + db = self.session.get(CampaignDB, campaign_id) + return "applied", self._from_db(db) if db else None + self.session.rollback() + db = self.session.get(CampaignDB, campaign_id) + return ("not_found", None) if db is None else ("conflict", self._from_db(db)) + + def start_if_planned(self, campaign_id: str, at: datetime) -> tuple[str, Optional[Campaign]]: + """Atomically stamp a planned Campaign as running.""" + statement = ( + sql_update(CampaignDB) + .where(CampaignDB.id == campaign_id, CampaignDB.status == CampaignStatus.PLANNED.value) + .values(status=CampaignStatus.RUNNING.value, started_at=at) + ) + result = self.session.exec(statement) + if result.rowcount == 1: + self.session.commit() + db = self.session.get(CampaignDB, campaign_id) + return "applied", self._from_db(db) if db else None + self.session.rollback() + db = self.session.get(CampaignDB, campaign_id) + return ("not_found", None) if db is None else ("conflict", self._from_db(db)) + def save_scheduler_state(self, campaign_id: str, summary: CampaignSummary) -> None: """窄口径调度进度持久化:只写 summary 列,不覆写并发的状态 / 水位变更。""" db = self.session.get(CampaignDB, campaign_id) @@ -485,6 +728,24 @@ class CampaignAnalysisRepository(AsyncJobRepository[CampaignAnalysisDB]): _table = CampaignAnalysisDB + def enqueue(self, campaign_id: str, *, triggered_by: str = "manual") -> CampaignAnalysisDB: + """Persist an analysis job before launching its process-local task.""" + row = self.get_by_campaign(campaign_id) + if row is None: + row = CampaignAnalysisDB(campaign_id=campaign_id) + row.status = "queued" + row.result = None + row.error = None + row.triggered_by = triggered_by + row.updated_at = utc_now() + self.session.add(row) + self.session.commit() + self.session.refresh(row) + return row + + def list_queued(self) -> list[CampaignAnalysisDB]: + return list(self.session.exec(select(CampaignAnalysisDB).where(CampaignAnalysisDB.status == "queued")).all()) + def upsert( self, campaign_id: str, @@ -584,6 +845,34 @@ class ResultRepository: self.session.refresh(db) return db + def update_turn_exchange( + self, + turn_id: str, + *, + question_msg_id: Optional[str], + reply: Optional[dict], + received_at: Optional[datetime], + latency_ms: Optional[int], + ) -> Optional[TurnDB]: + """Attach exchange facts to an already-persisted Turn. + + This is deliberately narrower than updating a whole Turn: the sent + message, case identity, ordering and send timestamp remain untouched. + A caller can therefore commit the sent fact before polling and safely + complete the same ledger row after a reply, timeout or poll failure. + """ + db = self.session.get(TurnDB, turn_id) + if db is None: + return None + db.question_msg_id = question_msg_id + db.set_reply(reply) + db.received_at = received_at + db.latency_ms = latency_ms + self.session.add(db) + self.session.commit() + self.session.refresh(db) + return db + def save_result(self, result: EvalResult) -> EvalResult: db = _result_to_db(result) self.session.add(db) diff --git a/backend/agenteval/web/app.py b/backend/agenteval/web/app.py index e2e0315..5e85709 100644 --- a/backend/agenteval/web/app.py +++ b/backend/agenteval/web/app.py @@ -10,11 +10,6 @@ from fastapi.responses import FileResponse, JSONResponse from agenteval.config import get_settings from agenteval.storage.db import get_session, init_db -from agenteval.storage.repository import ( - CampaignAnalysisRepository, - CampaignPeriodComparisonRepository, - RunRepository, -) from agenteval.version import get_build_info, get_version from agenteval.web.deps import require_api_key from agenteval.web.routers import ( @@ -41,19 +36,25 @@ async def lifespan(_: FastAPI): try: session = get_session() try: - count = RunRepository(session).mark_orphans_failed() - if count: - logging.getLogger("agenteval").warning("启动清理:%d 个中断的运行已标记为 failed", count) - llm_orphans = CampaignAnalysisRepository(session).mark_orphans_failed() - llm_orphans += CampaignPeriodComparisonRepository(session).mark_orphans_failed() - if llm_orphans: - logging.getLogger("agenteval").warning("启动清理:%d 条中断的分析/对比已标记为 failed", llm_orphans) - # 据库恢复所有未完成的评估活动,重建其调度循环(不重复派生已到点条目) - from agenteval.evaluation.campaign_runner import resume_running_campaigns + from agenteval.evaluation.campaign_lifecycle import recover_campaign_runtime - resumed = resume_running_campaigns(session) - if resumed: - logging.getLogger("agenteval").warning("启动恢复:%d 个进行中的评估活动已续跑", resumed) + recovery = recover_campaign_runtime(session) + if recovery.interrupted_runs: + logging.getLogger("agenteval").warning( + "启动清理:%d 个中断的运行已标记为 failed", recovery.interrupted_runs + ) + if recovery.interrupted_analysis: + logging.getLogger("agenteval").warning( + "启动清理:%d 条中断的分析/对比已标记为 failed", recovery.interrupted_analysis + ) + if recovery.resumed_campaigns: + logging.getLogger("agenteval").warning( + "启动恢复:%d 个进行中的评估活动已续跑", recovery.resumed_campaigns + ) + if recovery.resumed_analysis: + logging.getLogger("agenteval").warning( + "启动恢复:%d 条排队中的活动分析已续跑", recovery.resumed_analysis + ) finally: session.close() except Exception as exc: diff --git a/backend/agenteval/web/routers/campaigns.py b/backend/agenteval/web/routers/campaigns.py index 23d05b3..751e355 100644 --- a/backend/agenteval/web/routers/campaigns.py +++ b/backend/agenteval/web/routers/campaigns.py @@ -11,7 +11,10 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Response from pydantic import BaseModel, Field from sqlmodel import Session -from agenteval.evaluation.analysis import resolve_analysis_model, start_campaign_analysis +from agenteval.evaluation.analysis import enqueue_campaign_analysis, resolve_analysis_model +from agenteval.evaluation.campaign_lifecycle import CampaignCancelError, CampaignCreateError +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_runner import campaign_progress, request_cancel, start_campaign from agenteval.evaluation.comparison import ( ComparisonError, @@ -27,9 +30,8 @@ from agenteval.evaluation.report import ( ) from agenteval.evaluation.report_render import render_campaign_markdown from agenteval.exploration.summary import summarize_campaign_exploration -from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, ExplorationBudgetConfig, ExplorationSeeds -from agenteval.storage.db import iso_utc, utc_now -from agenteval.storage.model_config_repository import ModelConfigRepository +from agenteval.models import CampaignPlanEntry, CampaignStatus, ExplorationBudgetConfig, ExplorationSeeds +from agenteval.storage.db import iso_utc from agenteval.storage.repository import ( CampaignAnalysisRepository, CampaignRepository, @@ -42,6 +44,11 @@ from agenteval.web.deps import get_db router = APIRouter() +def start_campaign_analysis(campaign_id: str, *, triggered_by: str) -> None: + """Compatibility seam for tests and adapter-level task injection.""" + enqueue_campaign_analysis(campaign_id, triggered_by=triggered_by) + + class CreateCampaignRequest(BaseModel): name: str target_id: str @@ -71,55 +78,30 @@ async def create_campaign( request: CreateCampaignRequest, session: Session = Depends(get_db), ) -> dict: - if not TargetRepository(session).get(request.target_id): - raise HTTPException(status_code=404, detail="target not found") - - scenario_repo = ScenarioRepository(session) - for entry in request.plan: - if not scenario_repo.get(entry.scenario_id): - raise HTTPException( - status_code=404, - detail=f"scenario not found: {entry.scenario_id}", - ) - - if request.analysis_model_config_id is not None: - if not ModelConfigRepository(session).get(request.analysis_model_config_id): - raise HTTPException(status_code=400, detail="analysis model config not found") - - seeds = request.exploration_seeds - if seeds is not None and not seeds.personas and not seeds.goals: - seeds = None # 种子留空 = 该活动不参与探索 - - campaign = Campaign( - name=request.name, - target_id=request.target_id, - window_seconds=request.window_seconds, - time_scale=request.time_scale, - plan=request.plan, - analysis_model_config_id=request.analysis_model_config_id, - exploration_seeds=seeds, - exploration_budget=request.exploration_budget, - ) - repo = CampaignRepository(session) - campaign = repo.create(campaign) - # Kick off the durable loop; it moves the campaign into RUNNING. - start_campaign(campaign.id, session) - return (repo.get(campaign.id) or campaign).model_dump() + try: + campaign = create_campaign_lifecycle( + session, + name=request.name, + target_id=request.target_id, + window_seconds=request.window_seconds, + time_scale=request.time_scale, + plan=request.plan, + analysis_model_config_id=request.analysis_model_config_id, + exploration_seeds=request.exploration_seeds, + exploration_budget=request.exploration_budget, + launch=start_campaign, + ) + except CampaignCreateError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return campaign.model_dump() @router.post("/{campaign_id}/cancel") async def cancel_campaign(campaign_id: str, session: Session = Depends(get_db)) -> dict: - repo = CampaignRepository(session) - campaign = repo.get(campaign_id) - if not campaign: - raise HTTPException(status_code=404, detail="campaign not found") - if campaign.status not in (CampaignStatus.PLANNED, CampaignStatus.RUNNING): - raise HTTPException(status_code=400, detail="campaign is not in a cancellable state") - - campaign = repo.mark_cancelled(campaign_id, utc_now()) or campaign - request_cancel(campaign_id) - from agenteval.storage.repository import ExplorationSessionRepository - ExplorationSessionRepository(session).expire_running_sessions(campaign_id) + try: + campaign = cancel_campaign_lifecycle(session, campaign_id, stop=request_cancel) + except CampaignCancelError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc return campaign.model_dump() diff --git a/backend/agenteval/web/routers/intelligent_evals.py b/backend/agenteval/web/routers/intelligent_evals.py index a11d981..a83b954 100644 --- a/backend/agenteval/web/routers/intelligent_evals.py +++ b/backend/agenteval/web/routers/intelligent_evals.py @@ -17,8 +17,8 @@ from agenteval.intelligent_eval.lifecycle import ( IntelligentEvalNotFoundError, IntelligentEvalTransitionError, ) +from agenteval.intelligent_eval.read_model import IntelligentEvalReadModel from agenteval.intelligent_eval.report import ReportModel, render_report_markdown -from agenteval.intelligent_eval.repository import IntelligentEvalSessionRepository from agenteval.web.deps import get_db router = APIRouter() @@ -68,42 +68,45 @@ def _translate(exc: Exception) -> HTTPException: return HTTPException(status_code=409, detail=exc.reason) -def _eval_response(ev, session: Session) -> dict: - data = ev.model_dump(mode="json") - sessions = IntelligentEvalSessionRepository(session).list_by_eval(ev.id) - data["session_count"] = len(sessions) - data["completed_sessions"] = sum(1 for s in sessions if s.status.value == "completed") - return data +def _eval_response(ev, session: Session, *, detail: bool = False) -> dict: + """Serialize one stable intelligent-evaluation read projection.""" + + reader = IntelligentEvalReadModel(session) + projection = reader.detail(ev) if detail else reader.list_item(ev) + return projection.model_dump(mode="json") @router.post("") async def create_eval(request: CreateEvalRequest, session: Session = Depends(get_db)) -> dict: - ev = lifecycle.create_eval( - session, - name=request.name, - target_id=request.target_id, - goal=request.goal, - seeds=request.seeds, - intent=request.intent, - role_description=request.role_description, - time_window_hours=request.time_window_hours, - ) + try: + ev = lifecycle.create_eval( + session, + name=request.name, + target_id=request.target_id, + goal=request.goal, + seeds=request.seeds, + intent=request.intent, + role_description=request.role_description, + time_window_hours=request.time_window_hours, + ) + except IntelligentEvalNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc return _eval_response(ev, session) @router.get("") async def list_evals(session: Session = Depends(get_db)) -> dict: evals = lifecycle.list_evals(session) - return {"intelligent_evals": [_eval_response(ev, session) for ev in evals]} + reader = IntelligentEvalReadModel(session) + return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]} @router.get("/{eval_id}") async def get_eval(eval_id: str, session: Session = Depends(get_db)) -> dict: - try: - ev = lifecycle.get_eval(session, eval_id) - except IntelligentEvalNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - return _eval_response(ev, session) + projection = IntelligentEvalReadModel(session).detail_by_id(eval_id) + if projection is None: + raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") + return projection.model_dump(mode="json") @router.put("/{eval_id}/plan") @@ -153,31 +156,24 @@ async def submit_report(eval_id: str, request: SubmitReportRequest, session: Ses @router.get("/{eval_id}/report") async def get_report(eval_id: str, session: Session = Depends(get_db)) -> dict: - try: - ev = lifecycle.get_eval(session, eval_id) - except IntelligentEvalNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - if ev.report is None: + report = IntelligentEvalReadModel(session).report_by_eval(eval_id) + if report is None: raise HTTPException(status_code=404, detail="report not submitted yet") - return ev.report + return report[1] @router.get("/{eval_id}/report/markdown", response_class=PlainTextResponse) async def get_report_markdown(eval_id: str, session: Session = Depends(get_db)) -> PlainTextResponse: - try: - ev = lifecycle.get_eval(session, eval_id) - except IntelligentEvalNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - if ev.report is None: + report = IntelligentEvalReadModel(session).report_by_eval(eval_id) + if report is None: raise HTTPException(status_code=404, detail="report not submitted yet") - markdown = render_report_markdown(ev.report, name=ev.name, eval_id=ev.id) + name, payload = report + markdown = render_report_markdown(payload, name=name, eval_id=eval_id) return PlainTextResponse(markdown, media_type="text/markdown; charset=utf-8") @router.post("/{eval_id}/sessions") -async def create_session( - eval_id: str, request: CreateSessionRequest, session: Session = Depends(get_db) -) -> dict: +async def create_session(eval_id: str, request: CreateSessionRequest, session: Session = Depends(get_db)) -> dict: try: obj = lifecycle.open_session( session, @@ -193,27 +189,23 @@ async def create_session( @router.get("/{eval_id}/sessions") async def list_sessions(eval_id: str, session: Session = Depends(get_db)) -> dict: - try: - sessions = lifecycle.list_sessions(session, eval_id) - except IntelligentEvalNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc + sessions = IntelligentEvalReadModel(session).sessions_by_eval(eval_id) + if sessions is None: + raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") return {"sessions": [s.model_dump(mode="json") for s in sessions]} -def _get_owned_session(eval_id: str, session_id: str, session: Session): - obj = lifecycle.get_session_by_id(session, session_id) - if obj.eval_id != eval_id: - raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") - return obj - - @router.post("/{eval_id}/sessions/{session_id}/messages") async def send_message( eval_id: str, session_id: str, request: SendMessageRequest, session: Session = Depends(get_db) ) -> dict: try: - _get_owned_session(eval_id, session_id, session) - return await lifecycle.conduct_turn(session, session_id=session_id, content=request.content) + return await lifecycle.conduct_turn( + session, + eval_id=eval_id, + session_id=session_id, + content=request.content, + ) except ( IntelligentEvalNotFoundError, IntelligentEvalTransitionError, @@ -227,8 +219,12 @@ async def close_session( eval_id: str, session_id: str, request: CloseSessionRequest, session: Session = Depends(get_db) ) -> dict: try: - _get_owned_session(eval_id, session_id, session) - obj = lifecycle.close_session(session, session_id=session_id, verdict=request.verdict) + obj = lifecycle.close_session( + session, + eval_id=eval_id, + session_id=session_id, + verdict=request.verdict, + ) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return obj.model_dump(mode="json") @@ -236,9 +232,7 @@ async def close_session( @router.get("/{eval_id}/sessions/{session_id}/messages") async def list_messages(eval_id: str, session_id: str, session: Session = Depends(get_db)) -> dict: - try: - _get_owned_session(eval_id, session_id, session) - messages = lifecycle.list_messages(session, session_id) - except IntelligentEvalNotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc + messages = IntelligentEvalReadModel(session).messages_by_session(eval_id, session_id) + if messages is None: + raise HTTPException(status_code=404, detail=f"intelligent eval session {session_id} not found") return {"messages": [m.model_dump(mode="json") for m in messages]} diff --git a/docs/adr/0003-campaign-phased-static-then-adaptive.md b/docs/adr/0003-campaign-phased-static-then-adaptive.md index b47b1f2..7b0e50a 100644 --- a/docs/adr/0003-campaign-phased-static-then-adaptive.md +++ b/docs/adr/0003-campaign-phased-static-then-adaptive.md @@ -41,3 +41,16 @@ - **产出接入**:报告新增"探索发现"维度 + 喂 v0.7 分析 + Markdown 导出;周期对比暂不扩展,待口径稳定。 词汇表更新见 `CONTEXT.md`「探索式评测」章节。 + +## v1 持久化与恢复修订(2026-08-06) + +为使静态活动真正可跨进程恢复,v1 的耐久边界进一步明确: + +- **数据库是唯一权威**:`TaskRegistry` 只保存进程内任务句柄,不决定活动或 Run 的持久状态。 +- **子 Run 身份持久化**:新建 child Run 保存 `campaign_id`、计划条目索引和 occurrence 索引;三者由 Campaign 范围内唯一索引约束。历史 Run 保持空值,不回填。 +- **Claim 先于执行**:调度器对每个 occurrence 先执行条件 claim,再进入 `EvalEngine`。重复 tick、并发调度或重启不会创建第二个 Run;取消后的 Campaign 不允许新 claim,已启动 Run 可继续完成。 +- **恢复边界**:持久化为 `pending` 且仍属于 running Campaign 的子 Run 可以恢复;进程中断遗留的 `running` 子 Run 统一标记为 `failed/interrupted`,禁止重放可能已经发送的外部消息。 +- **生命周期 CAS**:创建、启动、取消和完成均通过生命周期模块与条件更新完成。取消竞态优先于完成;完成提交后再结算 running 探索会话,结算失败不回滚活动终态,可由后续恢复/重试处理。 +- **分析任务耐久化**:活动分析先写入 `queued` 再启动进程内 worker;重启恢复 queued 任务,遗留 `generating` 任务标记为中断失败。分析失败或重复执行不改变 Campaign 完成状态。 + +启动恢复顺序固定为:清理中断 Run 与 LLM 任务 → 重建 running Campaign 调度循环(其中包含 child Run reconciliation)→ 重启 queued 分析任务。该顺序保证恢复动作只依据已提交的数据库事实,不依赖上一次进程的内存状态。 diff --git a/frontend/web/package-lock.json b/frontend/web/package-lock.json index 3de753b..afa709c 100644 --- a/frontend/web/package-lock.json +++ b/frontend/web/package-lock.json @@ -21,13 +21,24 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.1.1", "typescript": "^5.5.3", - "vite": "^5.3.3" + "vite": "^5.3.3", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@ant-design/charts": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/@ant-design/charts/-/charts-2.6.7.tgz", @@ -1191,6 +1202,57 @@ "d3-timer": "^3.0.1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1483,6 +1545,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chenshuai2144/sketch-color": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@chenshuai2144/sketch-color/-/sketch-color-1.0.9.tgz", @@ -1496,6 +1571,148 @@ "react": ">=16.12.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@ctrl/tinycolor": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", @@ -1889,6 +2106,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -1906,6 +2140,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -1923,6 +2174,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1991,6 +2259,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2064,6 +2350,16 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@rc-component/async-validator": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", @@ -2246,6 +2542,244 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2603,6 +3137,99 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2648,6 +3275,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2777,6 +3415,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2815,6 +3460,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2862,6 +3508,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/add-dom-event-listener": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz", @@ -2883,6 +3615,29 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/antd": { "version": "5.29.3", "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", @@ -2990,6 +3745,26 @@ "react-dom": ">=16.0.0" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3030,6 +3805,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -3114,6 +3899,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -3223,6 +4018,27 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3527,6 +4343,20 @@ "lodash": "^4.17.15" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -3550,6 +4380,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3568,6 +4405,23 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -3598,6 +4452,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3616,6 +4483,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3692,18 +4566,56 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -3892,6 +4804,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -3930,6 +4855,16 @@ "node": ">=0.10.0" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3957,12 +4892,71 @@ "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3998,6 +4992,268 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -4032,6 +5288,26 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -4053,6 +5329,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4074,6 +5357,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ml-array-max": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-2.0.0.tgz", @@ -4131,9 +5424,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -4168,6 +5461,33 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-to-regexp": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", @@ -4177,6 +5497,13 @@ "node": ">=16" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pdfast": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz", @@ -4190,10 +5517,24 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -4211,7 +5552,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4225,6 +5566,28 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4251,6 +5614,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rc-cascader": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", @@ -4948,12 +6321,76 @@ "lodash": "^4.0.1" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -5020,6 +6457,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5054,6 +6504,13 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -5073,18 +6530,45 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -5110,6 +6594,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -5128,18 +6619,108 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5160,6 +6741,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5270,6 +6861,606 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -5279,6 +7470,75 @@ "loose-envify": "^1.0.0" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/web/package.json b/frontend/web/package.json index e511c48..ac1f40d 100644 --- a/frontend/web/package.json +++ b/frontend/web/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@ant-design/charts": "^2.6.7", @@ -22,10 +23,14 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.1.1", "typescript": "^5.5.3", - "vite": "^5.3.3" + "vite": "^5.3.3", + "vitest": "^4.1.10" } } diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index f9a59cd..95c1b46 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -702,6 +702,7 @@ export interface IntelligentEval { completed_at: string | null session_count: number completed_sessions: number + sessions?: IntelligentEvalSession[] } export interface CreateIntelligentEvalPayload { diff --git a/frontend/web/src/components/intelligent_eval/EvalDetail.tsx b/frontend/web/src/components/intelligent_eval/EvalDetail.tsx index 1b73dd8..d929fd1 100644 --- a/frontend/web/src/components/intelligent_eval/EvalDetail.tsx +++ b/frontend/web/src/components/intelligent_eval/EvalDetail.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useState } from 'react' +import { useState } from 'react' import { Alert, Button, Card, Col, Descriptions, Empty, Input, Modal, Popconfirm, Progress, Row, Space, Spin, Tag, message, } from 'antd' import { FileTextOutlined, StopOutlined } from '@ant-design/icons' -import { intelligentEvalsApi, type IntelligentEval, type IntelligentEvalSession } from '../../api' +import { intelligentEvalsApi, type IntelligentEval } from '../../api' import { colors } from '../../tokens' import { formatDateTime, shortDateTime } from '../../utils/date' -import { usePolling } from '../../hooks/usePolling' import { EVAL_STATUS, SESSION_STATUS } from './status' const sectionCard: React.CSSProperties = { marginBottom: 16 } @@ -71,22 +70,9 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }: const [busy, setBusy] = useState(false) const [rejectOpen, setRejectOpen] = useState(false) const [feedback, setFeedback] = useState('') - const [sessions, setSessions] = useState(null) const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' } const showSessions = ev.status === 'executing' || ev.status === 'completed' - - const loadSessions = useCallback(() => { - intelligentEvalsApi.listSessions(ev.id) - .then((res) => setSessions(res.data.sessions)) - .catch(() => undefined) - }, [ev.id]) - - useEffect(() => { - if (!showSessions) { setSessions(null); return } - loadSessions() - }, [showSessions, loadSessions]) - - usePolling(loadSessions, 5000, showSessions && ev.status === 'executing') + const sessions = showSessions ? ev.sessions ?? [] : [] const runAction = async (fn: () => Promise, okMsg: string) => { setBusy(true) @@ -211,13 +197,12 @@ export default function EvalDetail({ ev, targetName, onOpenReport, onChanged }: /> )} > - {sessions === null && } - {sessions !== null && sessions.length === 0 && ( + {sessions.length === 0 && (
等待 OpenClaw 按粗计划的时间分布创建会话…
)} - {sessions?.map((s) => { + {sessions.map((s) => { const sMeta = SESSION_STATUS[s.status] return (
(ev.report) - const [reportLoading, setReportLoading] = useState(!ev.report) - const [sessions, setSessions] = useState(null) - const [sessionsLoading, setSessionsLoading] = useState(true) const [exporting, setExporting] = useState(false) - - useEffect(() => { - let cancelled = false - setReport(ev.report) - setReportLoading(!ev.report) - if (!ev.report) { - intelligentEvalsApi.getReport(ev.id) - .then((res) => { if (!cancelled) setReport(res.data) }) - .catch(() => undefined) - .finally(() => { if (!cancelled) setReportLoading(false) }) - } - setSessionsLoading(true) - intelligentEvalsApi.listSessions(ev.id) - .then((res) => { if (!cancelled) setSessions(res.data.sessions) }) - .catch(() => undefined) - .finally(() => { if (!cancelled) setSessionsLoading(false) }) - return () => { cancelled = true } - }, [ev.id, ev.report]) + const report: IntelligentEvalReport | null = ev.report + const reportLoading = false + const sessions: IntelligentEvalSession[] = ev.sessions ?? [] + const sessionsLoading = false const exportMarkdown = async () => { setExporting(true) diff --git a/frontend/web/src/pages/IntelligentEvals.tsx b/frontend/web/src/pages/IntelligentEvals.tsx index 4c2683f..d1b122b 100644 --- a/frontend/web/src/pages/IntelligentEvals.tsx +++ b/frontend/web/src/pages/IntelligentEvals.tsx @@ -9,7 +9,6 @@ import PageWrapper from '../components/PageWrapper' import EvalDetail from '../components/intelligent_eval/EvalDetail' import EvalReport from '../components/intelligent_eval/EvalReport' import { EVAL_STATUS } from '../components/intelligent_eval/status' -import { usePolling } from '../hooks/usePolling' import { useResource } from '../hooks/useResource' import { intelligentEvalsApi, targetsApi, @@ -17,6 +16,7 @@ import { } from '../api' import { colors } from '../tokens' import { formatDateTime } from '../utils/date' +import { useIntelligentEvalRead } from '../read/useIntelligentEvalRead' type DrawerView = 'detail' | 'report' @@ -33,29 +33,19 @@ interface CreateFormValues { export default function IntelligentEvalsPage() { const [drawerView, setDrawerView] = useState('detail') const [selectedId, setSelectedId] = useState(null) - const [detailTick, setDetailTick] = useState(0) const [createOpen, setCreateOpen] = useState(false) const [submitting, setSubmitting] = useState(false) const [form] = Form.useForm() - const { data: evals, loading, reload } = useResource( - () => intelligentEvalsApi.list().then((r) => r.data.intelligent_evals), - { tabPath: '/intelligent-evals' }, - ) + const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(selectedId) + const evals = list.value + const loading = list.phase === 'loading' const { data: targets } = useResource( () => targetsApi.list().then((r) => r.data), { tabPath: '/intelligent-evals' }, ) - const { data: selected, reload: reloadSelected } = useResource( - () => (selectedId ? intelligentEvalsApi.get(selectedId).then((r) => r.data) : Promise.resolve(null)), - { deps: [selectedId, detailTick] }, - ) - - const pollActive = selected != null - && (selected.status === 'planning' || selected.status === 'executing' || selected.status === 'pending_approval') - - usePolling(() => void reloadSelected(true), 5000, pollActive) + const selected = selectedId != null && detail.value?.id === selectedId ? detail.value : null const targetName = (id: string) => targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8) @@ -73,7 +63,7 @@ export default function IntelligentEvalsPage() { const closeDrawer = () => { setSelectedId(null) setDrawerView('detail') - void reload() + void reloadList() } const submitCreate = async () => { @@ -108,7 +98,7 @@ export default function IntelligentEvalsPage() { message.success('已创建,OpenClaw 开始规划') setCreateOpen(false) form.resetFields() - void reload() + void reloadList() } finally { setSubmitting(false) } @@ -163,7 +153,7 @@ export default function IntelligentEvalsPage() { fullHeight extra={ - @@ -198,7 +188,7 @@ export default function IntelligentEvalsPage() { ev={selected} targetName={targetName(selected.target_id)} onOpenReport={() => setDrawerView('report')} - onChanged={() => setDetailTick((t) => t + 1)} + onChanged={() => void reloadDetail()} /> )} diff --git a/frontend/web/src/read/intelligentEval.test.ts b/frontend/web/src/read/intelligentEval.test.ts new file mode 100644 index 0000000..17f5279 --- /dev/null +++ b/frontend/web/src/read/intelligentEval.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + initialIntelligentEvalReadState, + intelligentEvalReadReducer, + type IntelligentEvalReadState, +} from './intelligentEval' + +const evaluation = { id: 'eval-1', name: '评估', status: 'planning' } as never + +describe('intelligent evaluation read state', () => { + it('represents initial loading and a complete list snapshot', () => { + const loading = intelligentEvalReadReducer(initialIntelligentEvalReadState, { type: 'list_requested' }) + expect(loading.list.phase).toBe('loading') + + const ready = intelligentEvalReadReducer(loading, { type: 'list_succeeded', value: [evaluation] }) + expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null }) + }) + + it('keeps the last list snapshot during silent refresh and failure', () => { + const ready: IntelligentEvalReadState = { + ...initialIntelligentEvalReadState, + list: { phase: 'ready', value: [evaluation], error: null }, + } + const refreshing = intelligentEvalReadReducer(ready, { type: 'list_requested', silent: true }) + expect(refreshing.list.phase).toBe('refreshing') + + const afterFailure = intelligentEvalReadReducer(refreshing, { type: 'list_failed', error: '网络错误' }) + expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null }) + }) + + it('surfaces an initial detail failure without an existing snapshot', () => { + const failed = intelligentEvalReadReducer(initialIntelligentEvalReadState, { + type: 'detail_failed', + error: '不存在', + }) + expect(failed.detail).toEqual({ phase: 'error', value: null, error: '不存在' }) + }) +}) diff --git a/frontend/web/src/read/intelligentEval.ts b/frontend/web/src/read/intelligentEval.ts new file mode 100644 index 0000000..fa55221 --- /dev/null +++ b/frontend/web/src/read/intelligentEval.ts @@ -0,0 +1,71 @@ +import { intelligentEvalsApi, type IntelligentEval } from '../api' + +export type ReadPhase = 'idle' | 'loading' | 'refreshing' | 'ready' | 'error' + +export interface ReadSlot { + phase: ReadPhase + value: T + error: string | null +} + +export interface IntelligentEvalReadState { + list: ReadSlot + detail: ReadSlot +} + +export type IntelligentEvalReadAction = + | { type: 'list_requested'; silent?: boolean } + | { type: 'list_succeeded'; value: IntelligentEval[] } + | { type: 'list_failed'; error: string } + | { type: 'detail_requested'; silent?: boolean } + | { type: 'detail_succeeded'; value: IntelligentEval } + | { type: 'detail_failed'; error: string } + +export interface IntelligentEvalReadAdapter { + list: () => Promise + get: (id: string) => Promise +} + +export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = { + list: () => intelligentEvalsApi.list().then((response) => response.data.intelligent_evals), + get: (id) => intelligentEvalsApi.get(id).then((response) => response.data), +} + +export const initialIntelligentEvalReadState: IntelligentEvalReadState = { + list: { phase: 'idle', value: [], error: null }, + detail: { phase: 'idle', value: null, error: null }, +} + +function requestPhase(slot: ReadSlot, silent: boolean | undefined): ReadSlot { + return { ...slot, phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', error: null } +} + +export function intelligentEvalReadReducer( + state: IntelligentEvalReadState, + action: IntelligentEvalReadAction, +): IntelligentEvalReadState { + switch (action.type) { + case 'list_requested': + return { ...state, list: requestPhase(state.list, action.silent) } + case 'list_succeeded': + return { ...state, list: { phase: 'ready', value: action.value, error: null } } + case 'list_failed': + return { + ...state, + list: state.list.value.length > 0 + ? { ...state.list, phase: 'ready', error: null } + : { ...state.list, phase: 'error', error: action.error }, + } + case 'detail_requested': + return { ...state, detail: requestPhase(state.detail, action.silent) } + case 'detail_succeeded': + return { ...state, detail: { phase: 'ready', value: action.value, error: null } } + case 'detail_failed': + return { + ...state, + detail: state.detail.value + ? { ...state.detail, phase: 'ready', error: null } + : { ...state.detail, phase: 'error', error: action.error }, + } + } +} diff --git a/frontend/web/src/read/useIntelligentEvalRead.test.tsx b/frontend/web/src/read/useIntelligentEvalRead.test.tsx new file mode 100644 index 0000000..6010799 --- /dev/null +++ b/frontend/web/src/read/useIntelligentEvalRead.test.tsx @@ -0,0 +1,57 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useIntelligentEvalRead } from './useIntelligentEvalRead' +import type { IntelligentEvalReadAdapter } from './intelligentEval' +import type { IntelligentEval } from '../api' + +const evaluation = { id: 'eval-1', name: '评估', status: 'executing' } as unknown as IntelligentEval + +afterEach(() => { + vi.useRealTimers() +}) + +async function settle() { + await act(async () => { await Promise.resolve() }) +} + +describe('useIntelligentEvalRead', () => { + it('refreshes active evaluations every five seconds and stops at terminal state', async () => { + vi.useFakeTimers() + const adapter: IntelligentEvalReadAdapter = { + list: vi.fn().mockResolvedValue([evaluation]), + get: vi.fn().mockResolvedValue(evaluation), + } + renderHook(() => useIntelligentEvalRead(null, adapter)) + await settle() + expect(adapter.list).toHaveBeenCalledTimes(1) + + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + await settle() + expect(adapter.list).toHaveBeenCalledTimes(2) + + const terminal = { ...evaluation, status: 'completed' } as IntelligentEval + vi.mocked(adapter.list).mockResolvedValue([terminal]) + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + await settle() + await act(async () => { await vi.advanceTimersByTimeAsync(10000) }) + expect(adapter.list).toHaveBeenCalledTimes(3) + }) + + it('retains a complete list when a silent refresh fails', async () => { + vi.useFakeTimers() + const adapter: IntelligentEvalReadAdapter = { + list: vi.fn() + .mockResolvedValueOnce([evaluation]) + .mockRejectedValueOnce(new Error('网络错误')), + get: vi.fn().mockResolvedValue(evaluation), + } + const { result } = renderHook(() => useIntelligentEvalRead(null, adapter)) + await settle() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + await settle() + + expect(result.current.list.phase).toBe('ready') + expect(result.current.list.value).toEqual([evaluation]) + expect(result.current.list.error).toBeNull() + }) +}) diff --git a/frontend/web/src/read/useIntelligentEvalRead.ts b/frontend/web/src/read/useIntelligentEvalRead.ts new file mode 100644 index 0000000..69e32cb --- /dev/null +++ b/frontend/web/src/read/useIntelligentEvalRead.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useReducer } from 'react' +import { + intelligentEvalReadAdapter, + intelligentEvalReadReducer, + initialIntelligentEvalReadState, + type IntelligentEvalReadAdapter, + type IntelligentEvalReadState, +} from './intelligentEval' + +const ACTIVE_STATUSES = new Set(['planning', 'pending_approval', 'executing']) + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : '读取智能评估失败' +} + +function isActive(status: string | undefined): boolean { + return status != null && ACTIVE_STATUSES.has(status) +} + +export function useIntelligentEvalRead( + selectedId: string | null, + adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter, +): IntelligentEvalReadState & { reloadList: () => Promise; reloadDetail: () => Promise } { + const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState) + + const loadList = useCallback(async (silent = false) => { + dispatch({ type: 'list_requested', silent }) + try { + const value = await adapter.list() + dispatch({ type: 'list_succeeded', value }) + } catch (error) { + dispatch({ type: 'list_failed', error: errorMessage(error) }) + } + }, [adapter]) + + const loadDetail = useCallback(async (id: string, silent = false) => { + dispatch({ type: 'detail_requested', silent }) + try { + const value = await adapter.get(id) + dispatch({ type: 'detail_succeeded', value }) + } catch (error) { + dispatch({ type: 'detail_failed', error: errorMessage(error) }) + } + }, [adapter]) + + useEffect(() => { + void loadList() + }, [loadList]) + + useEffect(() => { + if (selectedId == null) return + void loadDetail(selectedId) + }, [loadDetail, selectedId]) + + const listActive = state.list.value.some((item) => isActive(item.status)) + const detailActive = isActive(state.detail.value?.status) + + useEffect(() => { + if (!listActive) return + const timer = window.setInterval(() => { void loadList(true) }, 5000) + return () => window.clearInterval(timer) + }, [listActive, loadList]) + + useEffect(() => { + if (selectedId == null || !detailActive) return + const timer = window.setInterval(() => { void loadDetail(selectedId, true) }, 5000) + return () => window.clearInterval(timer) + }, [detailActive, loadDetail, selectedId]) + + const reloadList = useCallback(() => loadList(true), [loadList]) + const reloadDetail = useCallback( + () => (selectedId == null ? Promise.resolve() : loadDetail(selectedId, true)), + [loadDetail, selectedId], + ) + + return { ...state, reloadList, reloadDetail } +} diff --git a/frontend/web/src/test/setup.ts b/frontend/web/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/frontend/web/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/frontend/web/src/test/smoke.test.tsx b/frontend/web/src/test/smoke.test.tsx new file mode 100644 index 0000000..53eecc2 --- /dev/null +++ b/frontend/web/src/test/smoke.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +function SmokeView() { + return

测试环境已就绪

+} + +describe('frontend test foundation', () => { + it('renders a React component in jsdom', () => { + render() + expect(screen.getByText('测试环境已就绪')).toBeInTheDocument() + }) +}) diff --git a/frontend/web/vite.config.ts b/frontend/web/vite.config.ts index 424e51e..7937549 100644 --- a/frontend/web/vite.config.ts +++ b/frontend/web/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' export default defineConfig({ @@ -54,4 +54,8 @@ export default defineConfig({ }, }, }, -}) \ No newline at end of file + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + }, +}) diff --git a/migrations/versions/c2f4a6b8d0e1_add_campaign_run_identity.py b/migrations/versions/c2f4a6b8d0e1_add_campaign_run_identity.py new file mode 100644 index 0000000..f59969c --- /dev/null +++ b/migrations/versions/c2f4a6b8d0e1_add_campaign_run_identity.py @@ -0,0 +1,54 @@ +"""add durable Campaign child Run identity + +Revision ID: c2f4a6b8d0e1 +Revises: 99dbae2a20da +Create Date: 2026-08-06 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +import sqlmodel # noqa: F401 +from alembic import op + + +revision: str = "c2f4a6b8d0e1" +down_revision: Union[str, Sequence[str], None] = "99dbae2a20da" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add nullable identity columns without backfilling historical Runs.""" + # Adding nullable columns directly avoids rebuilding ``eval_runs``. That + # table participates in a foreign-key cycle with ``campaigns`` and + # ``eval_targets``; SQLite batch recreation can otherwise fail while + # topologically sorting the copied table. A unique index has the same + # database-level idempotency semantics as a composite unique constraint. + inspector = sa.inspect(op.get_bind()) + columns = {column["name"] for column in inspector.get_columns("eval_runs")} + if "campaign_plan_index" not in columns: + op.add_column("eval_runs", sa.Column("campaign_plan_index", sa.Integer(), nullable=True)) + if "campaign_occurrence_index" not in columns: + op.add_column("eval_runs", sa.Column("campaign_occurrence_index", sa.Integer(), nullable=True)) + index_names = {index["name"] for index in inspector.get_indexes("eval_runs")} + if "uq_eval_runs_campaign_occurrence" not in index_names: + op.create_index( + "uq_eval_runs_campaign_occurrence", + "eval_runs", + ["campaign_id", "campaign_plan_index", "campaign_occurrence_index"], + unique=True, + ) + + +def downgrade() -> None: + """Remove the child identity fields and their uniqueness constraint.""" + inspector = sa.inspect(op.get_bind()) + index_names = {index["name"] for index in inspector.get_indexes("eval_runs")} + if "uq_eval_runs_campaign_occurrence" in index_names: + op.drop_index("uq_eval_runs_campaign_occurrence", table_name="eval_runs") + columns = {column["name"] for column in inspector.get_columns("eval_runs")} + if "campaign_occurrence_index" in columns: + op.drop_column("eval_runs", "campaign_occurrence_index") + if "campaign_plan_index" in columns: + op.drop_column("eval_runs", "campaign_plan_index") diff --git a/scripts/deploy-t480.sh b/scripts/deploy-t480.sh index b492af3..314e1c8 100755 --- a/scripts/deploy-t480.sh +++ b/scripts/deploy-t480.sh @@ -86,6 +86,7 @@ run rsync -az --delete \ --exclude='.pytest_cache' \ --exclude='.ruff_cache' \ --exclude='AGENTS.md' \ + --exclude='.scratch' \ --exclude='data' \ --exclude='.env' \ --exclude='config/config.json' \ diff --git a/tests/integration/test_campaign_runner.py b/tests/integration/test_campaign_runner.py index fe01a63..c05ad29 100644 --- a/tests/integration/test_campaign_runner.py +++ b/tests/integration/test_campaign_runner.py @@ -8,7 +8,7 @@ No real timer is used — the clock is injected, mirroring how the durable loop """ import pytest -from agenteval.evaluation.campaign_runner import advance_campaign +from agenteval.evaluation.campaign_runner import advance_campaign, reconcile_campaign_child_runs from agenteval.models import ( Campaign, CampaignPlanEntry, @@ -44,6 +44,7 @@ def seeded_db(db_session, monkeypatch): monkeypatch.setattr(engine_module, "get_session", _test_get_session) channel = MockChannel(reply_delay=0.0) + db_session.info["mock_channel"] = channel monkeypatch.setattr(factory_module.ChannelFactory, "create", lambda target: channel) target = EvalTarget( @@ -68,6 +69,7 @@ def _make_campaign(session) -> Campaign: return CampaignRepository(session).create(Campaign( name="compressed", target_id="t-1", + status="running", window_seconds=7200, time_scale=3600.0, # 1 real second == 3600 window seconds plan=[ @@ -91,6 +93,9 @@ async def test_advance_spawns_due_runs_matching_plan(seeded_db): runs = RunRepository(seeded_db).list_all() assert len(runs) == 3 + assert { + (run.campaign_plan_index, run.campaign_occurrence_index) for run in runs + } == {(0, 0), (0, 1), (1, 0)} for run in runs: assert run.campaign_id == campaign.id assert run.scenario_id == "s-1" @@ -118,3 +123,103 @@ async def test_advance_reports_finished_at_window_end(seeded_db): async def test_advance_missing_campaign_returns_none(seeded_db): assert await advance_campaign(campaign_id="nope", elapsed_seconds=0.0, session=seeded_db) is None + + +async def test_restart_after_claim_does_not_create_second_run(seeded_db): + campaign = CampaignRepository(seeded_db).create( + Campaign( + name="claimed-before-crash", + target_id="t-1", + status="running", + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + ) + ) + claim = RunRepository(seeded_db).claim_campaign_run( + campaign_id=campaign.id, + scenario_id="s-1", + scenario_version=1, + plan_index=0, + occurrence_index=0, + ) + assert claim.run is not None + + result = await advance_campaign(campaign_id=campaign.id, elapsed_seconds=0.0, session=seeded_db) + + runs = RunRepository(seeded_db).list_by_campaign(campaign.id) + assert result.spawned_run_ids == [] + assert len(runs) == 1 + assert runs[0].id == claim.run.id + assert runs[0].status is RunStatus.PENDING + + +async def test_partial_claim_does_not_hide_remaining_occurrences(seeded_db): + campaign = CampaignRepository(seeded_db).create( + Campaign( + name="partial-claim", + target_id="t-1", + status="running", + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=2)], + ) + ) + first_claim = RunRepository(seeded_db).claim_campaign_run( + campaign_id=campaign.id, + scenario_id="s-1", + scenario_version=1, + plan_index=0, + occurrence_index=0, + ) + assert first_claim.run is not None + + await advance_campaign(campaign_id=campaign.id, elapsed_seconds=0.0, session=seeded_db) + + runs = RunRepository(seeded_db).list_by_campaign(campaign.id) + assert len(runs) == 2 + assert {run.campaign_occurrence_index for run in runs} == {0, 1} + assert all(run.status is RunStatus.COMPLETED for run in runs) + + +async def test_recovery_resumes_pending_claim_without_replacing_identity(seeded_db): + campaign = _make_campaign(seeded_db) + claim = RunRepository(seeded_db).claim_campaign_run( + campaign_id=campaign.id, + scenario_id="s-1", + scenario_version=1, + plan_index=0, + occurrence_index=0, + ) + assert claim.run is not None + + result = await reconcile_campaign_child_runs(campaign.id, seeded_db) + + recovered = RunRepository(seeded_db).get(claim.run.id) + assert result is not None + assert result.resumed_run_ids == [claim.run.id] + assert recovered.status is RunStatus.COMPLETED + assert len(RunRepository(seeded_db).list_by_campaign(campaign.id)) == 1 + + +async def test_recovery_fails_running_claim_without_replaying_messages(seeded_db): + campaign = _make_campaign(seeded_db) + repo = RunRepository(seeded_db) + claim = repo.claim_campaign_run( + campaign_id=campaign.id, + scenario_id="s-1", + scenario_version=1, + plan_index=0, + occurrence_index=0, + ) + assert claim.run is not None + claim.run.status = RunStatus.RUNNING + repo.update(claim.run) + + result = await reconcile_campaign_child_runs(campaign.id, seeded_db) + + interrupted = repo.get(claim.run.id) + assert result is not None + assert result.resumed_run_ids == [] + assert result.failed_run_ids == [claim.run.id] + assert interrupted.status is RunStatus.FAILED + assert interrupted.summary.error.code == "interrupted" + assert seeded_db.info["mock_channel"].send_calls == 0 diff --git a/tests/integration/test_campaigns_api.py b/tests/integration/test_campaigns_api.py index 7e4b736..ea21b04 100644 --- a/tests/integration/test_campaigns_api.py +++ b/tests/integration/test_campaigns_api.py @@ -106,7 +106,7 @@ async def test_create_campaign_then_get(client, seeded_db): assert resp.status_code == 200 body = resp.json() campaign_id = body["id"] - assert body["status"] == "planned" + assert body["status"] == "running" assert body["name"] == "24h-cycle" assert body["target_id"] == "t-1" assert body["window_seconds"] == 86400 @@ -115,7 +115,7 @@ async def test_create_campaign_then_get(client, seeded_db): got = (await client.get(f"/api/campaigns/{campaign_id}")).json() assert got["id"] == campaign_id - assert got["status"] == "planned" + assert got["status"] == "running" assert got["plan"][0]["scenario_id"] == "s-1" assert got["plan"][0]["offset_seconds"] == 0 assert got["plan"][0]["count"] == 2 @@ -245,7 +245,7 @@ async def test_detail_includes_progress_fields(client, seeded_db): progress = got["progress"] assert progress["spawned_runs"] == 0 assert progress["completed_runs"] == 0 - assert progress["current_offset_seconds"] == 0.0 + assert progress["current_offset_seconds"] >= 0.0 # ── campaign report endpoint (ticket 04) ───────────────────────────────────── diff --git a/tests/integration/test_exploration_api.py b/tests/integration/test_exploration_api.py index e4ffd01..c95548b 100644 --- a/tests/integration/test_exploration_api.py +++ b/tests/integration/test_exploration_api.py @@ -182,7 +182,7 @@ async def test_dict_reply_content_is_flattened_to_text(seeded_db, monkeypatch, c from tests.unit.mock_channel import MockChannel class _DictReplyChannel(MockChannel): - async def poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0): + async def _poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0): return Reply( question_msg_id=question_msg_id, content={"content": "您好,我是客服"}, diff --git a/tests/integration/test_intelligent_evals_api.py b/tests/integration/test_intelligent_evals_api.py index 462035a..a2f36f5 100644 --- a/tests/integration/test_intelligent_evals_api.py +++ b/tests/integration/test_intelligent_evals_api.py @@ -101,17 +101,34 @@ class TestCreateEval: assert "id" in data async def test_create_requires_name(self, client, seeded_db): - resp = await client.post("/api/intelligent-evals", json={ - "name": "", "target_id": "t-1", "goal": "test", - }) + resp = await client.post( + "/api/intelligent-evals", + json={ + "name": "", + "target_id": "t-1", + "goal": "test", + }, + ) assert resp.status_code == 422 async def test_create_requires_goal(self, client, seeded_db): - resp = await client.post("/api/intelligent-evals", json={ - "name": "test", "target_id": "t-1", "goal": "", - }) + resp = await client.post( + "/api/intelligent-evals", + json={ + "name": "test", + "target_id": "t-1", + "goal": "", + }, + ) assert resp.status_code == 422 + async def test_create_requires_existing_target(self, client, seeded_db): + resp = await client.post( + "/api/intelligent-evals", + json={"name": "test", "target_id": "missing", "goal": "evaluate"}, + ) + assert resp.status_code == 404 + class TestPlanApproval: async def test_submit_plan_transitions_to_pending_approval(self, client, seeded_db): @@ -267,6 +284,8 @@ class TestCreateSession: data = resp.json() assert data["session_count"] == 1 assert data["completed_sessions"] == 0 + assert len(data["sessions"]) == 1 + assert "messages" not in data["sessions"][0] class TestConductTurn: @@ -424,6 +443,23 @@ class TestListSessions: resp = await client.get(f"/api/intelligent-evals/{other['id']}/sessions/{session['id']}/messages") assert resp.status_code == 404 + async def test_session_mutation_via_wrong_eval_returns_404(self, client, seeded_db): + eval_id = await _create_executing_eval(client) + session = await _create_session(client, eval_id) + other = await _create_executing_eval(client) + + message = await client.post( + f"/api/intelligent-evals/{other}/sessions/{session['id']}/messages", + json={"content": "越权消息"}, + ) + assert message.status_code == 404 + + closed = await client.post( + f"/api/intelligent-evals/{other}/sessions/{session['id']}/close", + json={"verdict": {"passed": False}}, + ) + assert closed.status_code == 404 + def _report_payload() -> dict: return { @@ -453,9 +489,7 @@ def _report_payload() -> dict: class TestReport: async def test_submit_report_completes_eval(self, client, seeded_db): eval_id = await _create_executing_eval(client) - resp = await client.put( - f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()} - ) + resp = await client.put(f"/api/intelligent-evals/{eval_id}/report", json={"report": _report_payload()}) assert resp.status_code == 200, resp.text data = resp.json() assert data["status"] == "completed" @@ -482,9 +516,7 @@ class TestReport: async def test_submit_report_requires_executing(self, client, seeded_db): ev = await _create_eval(client) # planning state - resp = await client.put( - f"/api/intelligent-evals/{ev['id']}/report", json={"report": _report_payload()} - ) + resp = await client.put(f"/api/intelligent-evals/{ev['id']}/report", json={"report": _report_payload()}) assert resp.status_code == 409 async def test_submit_report_rejects_invalid_structure(self, client, seeded_db): diff --git a/tests/unit/mock_channel.py b/tests/unit/mock_channel.py index d7e7e46..e9584ac 100644 --- a/tests/unit/mock_channel.py +++ b/tests/unit/mock_channel.py @@ -37,7 +37,7 @@ class MockChannel(EvalChannel): async def health_check(self) -> ChannelHealth: return ChannelHealth(ok=True, message="mock") - async def send(self, content: str, **kwargs: Any) -> SendResult: + async def _send(self, content: str, **kwargs: Any) -> SendResult: self.send_calls += 1 self.sent.append(content) if self.raise_on_send: @@ -47,7 +47,7 @@ class MockChannel(EvalChannel): self._msg_counter += 1 return SendResult(ok=True, question_msg_id=f"q-{self._msg_counter}") - async def poll_reply( + async def _poll_reply( self, question_msg_id: str, timeout: float = 30.0, diff --git a/tests/unit/test_campaign_cancel_lifecycle.py b/tests/unit/test_campaign_cancel_lifecycle.py new file mode 100644 index 0000000..4c7069a --- /dev/null +++ b/tests/unit/test_campaign_cancel_lifecycle.py @@ -0,0 +1,52 @@ +"""Tests for atomic Campaign cancellation and settlement ordering.""" + +import pytest +from agenteval.evaluation.campaign_lifecycle import CampaignCancelError, cancel_campaign +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus +from agenteval.storage.repository import CampaignRepository + + +def _campaign(session, status=CampaignStatus.RUNNING): + return CampaignRepository(session).create( + Campaign( + id="campaign-1", + name="campaign", + target_id="t-1", + status=status, + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + ) + ) + + +def test_cancel_is_atomic_and_stop_happens_after_durable_state(db_session): + _campaign(db_session) + observed = [] + + def stop(campaign_id): + observed.append(CampaignRepository(db_session).get(campaign_id).status) + + cancelled = cancel_campaign(db_session, "campaign-1", stop=stop) + + assert cancelled.status is CampaignStatus.CANCELLED + assert cancelled.completed_at is not None + assert observed == [CampaignStatus.CANCELLED] + + +def test_cancel_distinguishes_missing_and_terminal_campaign(db_session): + with pytest.raises(CampaignCancelError) as missing: + cancel_campaign(db_session, "missing") + assert missing.value.status_code == 404 + + _campaign(db_session, CampaignStatus.COMPLETED) + with pytest.raises(CampaignCancelError) as terminal: + cancel_campaign(db_session, "campaign-1") + assert terminal.value.status_code == 400 + + +def test_cancel_is_idempotency_guarded_by_status(db_session): + _campaign(db_session) + cancel_campaign(db_session, "campaign-1") + with pytest.raises(CampaignCancelError) as again: + cancel_campaign(db_session, "campaign-1") + assert again.value.status_code == 400 diff --git a/tests/unit/test_campaign_complete_lifecycle.py b/tests/unit/test_campaign_complete_lifecycle.py new file mode 100644 index 0000000..fb6797a --- /dev/null +++ b/tests/unit/test_campaign_complete_lifecycle.py @@ -0,0 +1,48 @@ +"""Tests for idempotent Campaign completion and settlement ordering.""" + +import pytest +from agenteval.evaluation.campaign_lifecycle import CampaignCancelError, complete_campaign +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus +from agenteval.storage.repository import CampaignRepository + + +def _campaign(session, status=CampaignStatus.RUNNING): + return CampaignRepository(session).create( + Campaign( + id="campaign-1", + name="campaign", + target_id="t-1", + status=status, + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + ) + ) + + +def test_complete_uses_cas_and_settles_after_commit(db_session): + _campaign(db_session) + observed = [] + + def settle(campaign_id, session): + observed.append(CampaignRepository(session).get(campaign_id).status) + + completed = complete_campaign(db_session, "campaign-1", settle=settle) + + assert completed.status is CampaignStatus.COMPLETED + assert completed.completed_at is not None + assert observed == [CampaignStatus.COMPLETED] + + +def test_complete_is_idempotency_guarded(db_session): + _campaign(db_session) + complete_campaign(db_session, "campaign-1") + with pytest.raises(CampaignCancelError) as again: + complete_campaign(db_session, "campaign-1") + assert again.value.status_code == 409 + + +def test_cancel_wins_completion_race(db_session): + _campaign(db_session, CampaignStatus.CANCELLED) + with pytest.raises(CampaignCancelError) as conflict: + complete_campaign(db_session, "campaign-1") + assert conflict.value.status_code == 409 diff --git a/tests/unit/test_campaign_lifecycle.py b/tests/unit/test_campaign_lifecycle.py new file mode 100644 index 0000000..1463142 --- /dev/null +++ b/tests/unit/test_campaign_lifecycle.py @@ -0,0 +1,70 @@ +"""Tests for the durable Campaign creation lifecycle seam.""" + +import pytest +from agenteval.evaluation.campaign_lifecycle import CampaignCreateError, create_campaign +from agenteval.models import CampaignPlanEntry, CampaignStatus, Case, Scenario +from agenteval.storage.repository import CampaignRepository, ScenarioRepository, TargetRepository + +from tests.unit.test_repository import _make_target + + +def test_create_campaign_commits_running_before_launch(db_session): + TargetRepository(db_session).create(_make_target()) + ScenarioRepository(db_session).create(Scenario(id="s-1", name="scenario", cases=[Case(id="c-1", messages=["hi"])])) + launched: list[str] = [] + + def launch(campaign_id, session): + launched.append(campaign_id) + assert CampaignRepository(session).get(campaign_id).status is CampaignStatus.RUNNING + + campaign = create_campaign( + db_session, + name="campaign", + target_id="t-1", + window_seconds=60, + time_scale=1, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + launch=launch, + ) + + assert campaign.status is CampaignStatus.RUNNING + assert campaign.started_at is not None + assert launched == [campaign.id] + + +def test_create_campaign_launch_failure_leaves_durable_row(db_session): + TargetRepository(db_session).create(_make_target()) + ScenarioRepository(db_session).create(Scenario(id="s-1", name="scenario", cases=[Case(id="c-1", messages=["hi"])])) + + def launch(_campaign_id, _session): + raise RuntimeError("scheduler unavailable") + + with pytest.raises(RuntimeError): + create_campaign( + db_session, + name="campaign", + target_id="t-1", + window_seconds=60, + time_scale=1, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + launch=launch, + ) + + rows = CampaignRepository(db_session).list_all() + assert len(rows) == 1 + assert rows[0].status is CampaignStatus.RUNNING + + +def test_create_campaign_rejects_invalid_references_without_writing(db_session): + with pytest.raises(CampaignCreateError) as exc: + create_campaign( + db_session, + name="campaign", + target_id="missing", + window_seconds=60, + time_scale=1, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + ) + + assert exc.value.status_code == 404 + assert CampaignRepository(db_session).list_all() == [] diff --git a/tests/unit/test_campaign_recovery.py b/tests/unit/test_campaign_recovery.py new file mode 100644 index 0000000..bba199d --- /dev/null +++ b/tests/unit/test_campaign_recovery.py @@ -0,0 +1,46 @@ +"""Tests for the unified durable Campaign startup recovery seam.""" + +from agenteval.evaluation import campaign_lifecycle + + +def test_recovery_coordinator_orders_cleanup_before_relaunch(db_session, monkeypatch): + calls: list[str] = [] + + class FakeRuns: + def __init__(self, _session): + pass + + def mark_orphans_failed(self): + calls.append("runs") + return 2 + + class FakeAnalysis: + def __init__(self, _session): + pass + + def mark_orphans_failed(self): + calls.append("analysis") + return 1 + + class FakeComparison(FakeAnalysis): + def mark_orphans_failed(self): + calls.append("comparison") + return 1 + + monkeypatch.setattr(campaign_lifecycle, "RunRepository", FakeRuns) + monkeypatch.setattr(campaign_lifecycle, "CampaignAnalysisRepository", FakeAnalysis) + monkeypatch.setattr(campaign_lifecycle, "CampaignPeriodComparisonRepository", FakeComparison) + + import agenteval.evaluation.analysis as analysis + import agenteval.evaluation.campaign_runner as runner + + monkeypatch.setattr(analysis, "resume_queued_campaign_analysis", lambda _session: calls.append("queued") or 3) + monkeypatch.setattr(runner, "resume_running_campaigns", lambda _session, **_: calls.append("campaigns") or 4) + + summary = campaign_lifecycle.recover_campaign_runtime(db_session) + + assert calls == ["runs", "analysis", "comparison", "campaigns", "queued"] + assert summary.interrupted_runs == 2 + assert summary.interrupted_analysis == 2 + assert summary.resumed_campaigns == 4 + assert summary.resumed_analysis == 3 diff --git a/tests/unit/test_campaign_run_claim.py b/tests/unit/test_campaign_run_claim.py new file mode 100644 index 0000000..fe1cb6b --- /dev/null +++ b/tests/unit/test_campaign_run_claim.py @@ -0,0 +1,115 @@ +"""Tests for the durable, idempotent Campaign child-Run claim seam.""" + +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, RunStatus, RunTrigger +from agenteval.storage.repository import ( + CampaignRepository, + CampaignRunClaimStatus, + RunRepository, + TargetRepository, +) + +from tests.unit.test_repository import _make_target + + +def _running_campaign(session, *, status=CampaignStatus.RUNNING): + TargetRepository(session).create(_make_target()) + return CampaignRepository(session).create( + Campaign( + id="campaign-1", + name="campaign", + target_id="t-1", + status=status, + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="scenario-1", offset_seconds=0, count=2)], + ) + ) + + +def _claim(repo: RunRepository, *, occurrence_index=0): + return repo.claim_campaign_run( + campaign_id="campaign-1", + scenario_id="scenario-1", + scenario_version=3, + plan_index=0, + occurrence_index=occurrence_index, + ) + + +def test_claim_creates_pending_campaign_run_with_durable_identity(db_session): + _running_campaign(db_session) + + result = _claim(RunRepository(db_session)) + + assert result.status is CampaignRunClaimStatus.CLAIMED + assert result.accepted is True + assert result.created is True + assert result.run is not None + assert result.run.status is RunStatus.PENDING + assert result.run.triggered_by is RunTrigger.CAMPAIGN + assert result.run.target_id == "t-1" + assert result.run.campaign_id == "campaign-1" + assert result.run.campaign_plan_index == 0 + assert result.run.campaign_occurrence_index == 0 + + +def test_repeated_claim_returns_same_run_without_duplicate(db_session): + _running_campaign(db_session) + repo = RunRepository(db_session) + + first = _claim(repo) + second = _claim(repo) + + assert first.run is not None and second.run is not None + assert second.status is CampaignRunClaimStatus.EXISTING + assert second.created is False + assert second.run.id == first.run.id + assert len(repo.list_by_campaign("campaign-1")) == 1 + + +def test_claim_rejects_missing_or_non_running_campaign(db_session): + repo = RunRepository(db_session) + + missing = _claim(repo) + assert missing.status is CampaignRunClaimStatus.NOT_FOUND + assert missing.accepted is False + + _running_campaign(db_session, status=CampaignStatus.CANCELLED) + cancelled = _claim(repo) + assert cancelled.status is CampaignRunClaimStatus.CONFLICT + assert cancelled.run is None + + +def test_each_occurrence_has_independent_claim_identity(db_session): + _running_campaign(db_session) + repo = RunRepository(db_session) + + first = _claim(repo, occurrence_index=0) + second = _claim(repo, occurrence_index=1) + + assert first.created is True + assert second.created is True + assert first.run is not None and second.run is not None + assert first.run.id != second.run.id + assert len(repo.list_by_campaign("campaign-1")) == 2 + + +def test_competing_sessions_converge_on_one_claim(tmp_path): + from agenteval.storage.db import EvalRunDB # noqa: F401 - register metadata + from sqlmodel import Session, SQLModel, create_engine + + engine = create_engine( + f"sqlite:///{tmp_path / 'competing-claims.db'}", + connect_args={"check_same_thread": False}, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as first_session, Session(engine) as second_session: + _running_campaign(first_session) + + first = _claim(RunRepository(first_session)) + second = _claim(RunRepository(second_session)) + + assert first.status is CampaignRunClaimStatus.CLAIMED + assert second.status is CampaignRunClaimStatus.EXISTING + assert first.run is not None and second.run is not None + assert second.run.id == first.run.id + assert len(RunRepository(second_session).list_by_campaign("campaign-1")) == 1 diff --git a/tests/unit/test_campaign_run_identity_migration.py b/tests/unit/test_campaign_run_identity_migration.py new file mode 100644 index 0000000..93e1bca --- /dev/null +++ b/tests/unit/test_campaign_run_identity_migration.py @@ -0,0 +1,85 @@ +"""Verify durable Campaign child-Run identity migration semantics.""" + +import importlib + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def _legacy_eval_runs(connection) -> None: + connection.execute(sa.text("CREATE TABLE campaigns (id VARCHAR PRIMARY KEY)")) + connection.execute( + sa.text( + "CREATE TABLE eval_runs (" + "id VARCHAR PRIMARY KEY, campaign_id VARCHAR NULL, " + "status VARCHAR NOT NULL)" + ) + ) + connection.execute(sa.text("INSERT INTO eval_runs (id, campaign_id, status) VALUES ('old', NULL, 'completed')")) + + +def test_campaign_run_identity_migration_is_nullable_unique_and_reversible(tmp_path): + engine = sa.create_engine(f"sqlite:///{tmp_path / 'campaign-run-identity.db'}") + migration = importlib.import_module( + "migrations.versions.c2f4a6b8d0e1_add_campaign_run_identity" + ) + + with engine.begin() as connection: + _legacy_eval_runs(connection) + operations = Operations(MigrationContext.configure(connection)) + migration.op = operations + migration.upgrade() + + inspector = sa.inspect(connection) + columns = {column["name"]: column for column in inspector.get_columns("eval_runs")} + assert columns["campaign_plan_index"]["nullable"] is True + assert columns["campaign_occurrence_index"]["nullable"] is True + assert connection.execute(sa.text("SELECT campaign_plan_index, campaign_occurrence_index FROM eval_runs WHERE id='old'")) .one() == (None, None) + indexes = inspector.get_indexes("eval_runs") + assert any( + index["name"] == "uq_eval_runs_campaign_occurrence" + and bool(index["unique"]) + and index["column_names"] == ["campaign_id", "campaign_plan_index", "campaign_occurrence_index"] + for index in indexes + ) + + connection.execute( + sa.text( + "INSERT INTO eval_runs (id, campaign_id, campaign_plan_index, campaign_occurrence_index, status) " + "VALUES ('child-1', 'campaign-1', 0, 0, 'pending')" + ) + ) + try: + connection.execute( + sa.text( + "INSERT INTO eval_runs (id, campaign_id, campaign_plan_index, campaign_occurrence_index, status) " + "VALUES ('child-duplicate', 'campaign-1', 0, 0, 'pending')" + ) + ) + except sa.exc.IntegrityError: + pass + else: # pragma: no cover - assertion guard + raise AssertionError("Campaign child identity must be unique") + + migration.downgrade() + assert "campaign_plan_index" not in {column["name"] for column in sa.inspect(connection).get_columns("eval_runs")} + assert "campaign_occurrence_index" not in {column["name"] for column in sa.inspect(connection).get_columns("eval_runs")} + + +def test_fresh_schema_exposes_same_identity_columns_and_unique_index(tmp_path): + """SQLModel's fresh-database path matches the Alembic contract.""" + from agenteval.storage.db import EvalRunDB # noqa: F401 - register table + from sqlmodel import SQLModel, create_engine + + engine = create_engine(f"sqlite:///{tmp_path / 'fresh.db'}") + SQLModel.metadata.create_all(engine) + inspector = sa.inspect(engine) + columns = {column["name"] for column in inspector.get_columns("eval_runs")} + assert {"campaign_plan_index", "campaign_occurrence_index"} <= columns + assert any( + index["name"] == "uq_eval_runs_campaign_occurrence" + and bool(index["unique"]) + and index["column_names"] == ["campaign_id", "campaign_plan_index", "campaign_occurrence_index"] + for index in inspector.get_indexes("eval_runs") + ) diff --git a/tests/unit/test_campaign_start_lifecycle.py b/tests/unit/test_campaign_start_lifecycle.py new file mode 100644 index 0000000..c034445 --- /dev/null +++ b/tests/unit/test_campaign_start_lifecycle.py @@ -0,0 +1,42 @@ +"""Tests for the conditional Campaign start lifecycle seam.""" + +from agenteval.evaluation.campaign_lifecycle import start_campaign +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus +from agenteval.storage.repository import CampaignRepository + + +def test_start_planned_campaign_is_atomic_and_launches_after_commit(db_session): + CampaignRepository(db_session).create( + Campaign( + id="campaign-1", + name="campaign", + target_id="t-1", + status=CampaignStatus.PLANNED, + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + ) + ) + observed = [] + + def launch(campaign_id, session): + observed.append(CampaignRepository(session).get(campaign_id).status) + + started = start_campaign(db_session, "campaign-1", launch=launch) + + assert started.status is CampaignStatus.RUNNING + assert started.started_at is not None + assert observed == [CampaignStatus.RUNNING] + + +def test_start_terminal_campaign_is_noop(db_session): + CampaignRepository(db_session).create( + Campaign( + id="campaign-1", + name="campaign", + target_id="t-1", + status=CampaignStatus.COMPLETED, + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)], + ) + ) + assert start_campaign(db_session, "campaign-1") is None diff --git a/tests/unit/test_channel_contract.py b/tests/unit/test_channel_contract.py new file mode 100644 index 0000000..d720f1b --- /dev/null +++ b/tests/unit/test_channel_contract.py @@ -0,0 +1,139 @@ +"""Contract tests for the complete channel exchange outcome.""" + +import pytest +from agenteval.channels.base import ( + ChannelHealth, + EvalChannel, + ExchangeOutcome, + ExchangeStatus, + Reply, + SendResult, + normalize_reply_text, +) + + +class ContractChannel(EvalChannel): + def __init__(self, *, send_result=None, reply=None, poll_error=None): + self.send_result = send_result or SendResult(ok=True, question_msg_id="question-7") + self.reply = reply + self.poll_error = poll_error + self.events: list[str] = [] + + async def health_check(self): + return ChannelHealth(ok=True) + + async def _send(self, content, **kwargs): + self.events.append("send") + return self.send_result + + async def _poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0): + self.events.append("poll") + if self.poll_error: + raise self.poll_error + return self.reply + + +def test_success_normalizes_reply_and_preserves_exchange_metadata(): + diagnostic = {"provider": "tutu", "request_id": "req-7"} + + outcome = ExchangeOutcome.succeeded( + correlation_id="question-7", + reply={"msgBody": {"content": "你好"}}, + latency_ms=128, + diagnostic=diagnostic, + ) + + assert outcome.status is ExchangeStatus.SUCCESS + assert outcome.ok is True + assert outcome.expected_failure is False + assert outcome.correlation_id == "question-7" + assert outcome.reply_text == "你好" + assert outcome.latency_ms == 128 + assert outcome.diagnostic is diagnostic + + +@pytest.mark.parametrize( + ("outcome", "status"), + [ + (ExchangeOutcome.send_failed("connection refused"), ExchangeStatus.SEND_FAILED), + (ExchangeOutcome.reply_timeout(correlation_id="question-7", latency_ms=30_000), ExchangeStatus.REPLY_TIMEOUT), + ( + ExchangeOutcome.poll_failed("upstream returned 502", correlation_id="question-7"), + ExchangeStatus.POLL_FAILED, + ), + ], +) +def test_expected_transport_failures_have_typed_status(outcome, status): + assert outcome.status is status + assert outcome.ok is False + assert outcome.expected_failure is True + + +def test_normalize_reply_text_handles_provider_shapes(): + assert normalize_reply_text("plain text") == "plain text" + assert normalize_reply_text({"content": "content field"}) == "content field" + assert normalize_reply_text({"msgBody": {"text": "nested text"}}) == "nested text" + assert normalize_reply_text(None) == "" + + +def test_success_requires_correlation_text_and_latency(): + with pytest.raises(ValueError, match="correlation_id"): + ExchangeOutcome(status=ExchangeStatus.SUCCESS, reply_text="ok", latency_ms=1) + + with pytest.raises(ValueError, match="reply_text"): + ExchangeOutcome(status=ExchangeStatus.SUCCESS, correlation_id="question-7", latency_ms=1) + + with pytest.raises(ValueError, match="latency_ms"): + ExchangeOutcome(status=ExchangeStatus.SUCCESS, correlation_id="question-7", reply_text="ok", latency_ms=-1) + + +async def test_exchange_runs_send_hook_before_polling_and_returns_success(): + channel = ContractChannel(reply=Reply(question_msg_id="question-7", content={"content": "答复"})) + + async def on_sent(send_result): + channel.events.append(f"hook:{send_result.question_msg_id}") + + outcome = await channel.exchange("问题", on_sent=on_sent) + + assert outcome.ok is True + assert outcome.reply_text == "答复" + assert outcome.correlation_id == "question-7" + assert channel.events == ["send", "hook:question-7", "poll"] + + +async def test_exchange_send_failure_skips_hook_and_polling(): + channel = ContractChannel(send_result=SendResult(ok=False, error="offline")) + + async def on_sent(_send_result): + raise AssertionError("send hook must not run after a failed send") + + outcome = await channel.exchange("问题", on_sent=on_sent) + + assert outcome.status is ExchangeStatus.SEND_FAILED + assert outcome.reason == "offline" + assert channel.events == ["send"] + + +async def test_exchange_hook_failure_skips_polling(): + channel = ContractChannel(reply=Reply(question_msg_id="question-7", content="答复")) + + async def on_sent(_send_result): + channel.events.append("hook") + raise RuntimeError("ledger unavailable") + + with pytest.raises(RuntimeError, match="ledger unavailable"): + await channel.exchange("问题", on_sent=on_sent) + + assert channel.events == ["send", "hook"] + + +async def test_exchange_distinguishes_timeout_and_poll_failure(): + timeout_channel = ContractChannel() + timeout = await timeout_channel.exchange("问题") + assert timeout.status is ExchangeStatus.REPLY_TIMEOUT + assert timeout.correlation_id == "question-7" + + failed_channel = ContractChannel(poll_error=RuntimeError("upstream unavailable")) + failed = await failed_channel.exchange("问题") + assert failed.status is ExchangeStatus.POLL_FAILED + assert failed.reason == "upstream unavailable" diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index 7a07cf8..9d46fa3 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -21,7 +21,9 @@ from agenteval.models import ( Scenario, TargetStatus, ) +from agenteval.storage.db import TurnDB from agenteval.storage.repository import RunRepository +from sqlmodel import select from tests.unit.mock_channel import MockChannel @@ -71,9 +73,11 @@ def _build_engine( # ── basic happy path ───────────────────────────────────────────────────── + async def test_run_single_case_completes(db_session): scenario = Scenario( - id="s-1", name="single", + id="s-1", + name="single", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hello"])], ) channel = MockChannel() @@ -90,7 +94,8 @@ async def test_run_single_case_completes(db_session): async def test_run_multi_turn_collects_all_turns(db_session): scenario = Scenario( - id="s-1", name="multi", + id="s-1", + name="multi", cases=[Case(id="c1", type=CaseType.MULTI_TURN, messages=["a", "b", "c"])], ) channel = MockChannel() @@ -107,7 +112,8 @@ async def test_run_multi_turn_collects_all_turns(db_session): async def test_run_multiple_cases(db_session): scenario = Scenario( - id="s-1", name="multi-case", + id="s-1", + name="multi-case", cases=[ Case(id="c1", type=CaseType.SINGLE, messages=["one"]), Case(id="c2", type=CaseType.SINGLE, messages=["two"]), @@ -126,9 +132,11 @@ async def test_run_multiple_cases(db_session): # ── progress callback ──────────────────────────────────────────────────── + async def test_progress_callback_receives_events(db_session): scenario = Scenario( - id="s-1", name="single", + id="s-1", + name="single", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], ) events: list[tuple[str, dict]] = [] @@ -151,7 +159,8 @@ async def test_progress_callback_receives_events(db_session): async def test_sync_progress_callback_also_works(db_session): """Engine must accept sync callbacks (CLI uses them).""" scenario = Scenario( - id="s-1", name="single", + id="s-1", + name="single", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], ) seen: list[str] = [] @@ -168,9 +177,11 @@ async def test_sync_progress_callback_also_works(db_session): # ── cancellation ───────────────────────────────────────────────────────── + async def test_cancel_before_run_marks_failed(db_session): scenario = Scenario( - id="s-1", name="single", + id="s-1", + name="single", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], ) cancel_token = asyncio.Event() @@ -189,7 +200,8 @@ async def test_cancel_before_run_marks_failed(db_session): async def test_cancel_mid_run_stops_after_current_case(db_session): """Cancelling between cases should stop further cases from running.""" scenario = Scenario( - id="s-1", name="multi", + id="s-1", + name="multi", cases=[ Case(id="c1", type=CaseType.SINGLE, messages=["a"]), Case(id="c2", type=CaseType.SINGLE, messages=["b"]), @@ -220,16 +232,19 @@ async def test_cancel_mid_run_stops_after_current_case(db_session): # ── timeouts ───────────────────────────────────────────────────────────── + async def test_poll_timeout_records_missing_reply(db_session): """When the channel returns no reply within the timeout, the turn is saved with reply=None but the engine keeps going (doesn't crash).""" scenario = Scenario( - id="s-1", name="single", + id="s-1", + name="single", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], ) channel = MockChannel(missing_reply=True) engine = _build_engine( - scenario, channel, + scenario, + channel, timeout_config=TimeoutConfig(poll_reply=0.1), session=db_session, ) @@ -241,36 +256,79 @@ async def test_poll_timeout_records_missing_reply(db_session): turns = RunRepository(db_session).get_turns(run.id) assert len(turns) == 1 assert turns[0].reply is None + assert turns[0].question_msg_id == "q-1" + + +async def test_sent_turn_is_durable_before_reply_polling(db_session): + scenario = Scenario( + id="s-1", + name="durable-send", + cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], + ) + + class LedgerInspectingChannel(MockChannel): + persisted_before_poll = False + + async def _poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0): + turns = list(db_session.exec(select(TurnDB)).all()) + self.persisted_before_poll = len(turns) == 1 and turns[0].question_msg_id == question_msg_id + return await super()._poll_reply(question_msg_id, timeout, poll_interval) + + channel = LedgerInspectingChannel() + engine = _build_engine(scenario, channel, session=db_session) + + await engine.run() + + assert channel.persisted_before_poll is True + + +async def test_poll_failure_keeps_sent_turn_and_fails_case(db_session): + scenario = Scenario( + id="s-1", + name="poll-failure", + cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], + ) + channel = MockChannel(raise_on_poll=RuntimeError("upstream unavailable")) + engine = _build_engine(scenario, channel, session=db_session) + + run = await engine.run() + + assert run.status == RunStatus.COMPLETED + assert run.summary.failed_cases == 1 + turns = RunRepository(db_session).get_turns(run.id) + assert len(turns) == 1 + assert turns[0].question_msg_id == "q-1" + assert turns[0].reply is None # ── concurrency ────────────────────────────────────────────────────────── + async def test_concurrent_cases_respect_semaphore(db_session): """With max_concurrent_cases=2, at most 2 cases should be in-flight.""" scenario = Scenario( - id="s-1", name="concurrent", - cases=[ - Case(id=f"c{i}", type=CaseType.SINGLE, messages=[f"m{i}"]) - for i in range(5) - ], + id="s-1", + name="concurrent", + cases=[Case(id=f"c{i}", type=CaseType.SINGLE, messages=[f"m{i}"]) for i in range(5)], ) in_flight = {"n": 0, "max": 0} lock = asyncio.Lock() class TrackedChannel(MockChannel): - async def send(self, content: str, **kwargs): + async def _send(self, content: str, **kwargs): async with lock: in_flight["n"] += 1 in_flight["max"] = max(in_flight["max"], in_flight["n"]) await asyncio.sleep(0.05) - result = await super().send(content, **kwargs) + result = await super()._send(content, **kwargs) async with lock: in_flight["n"] -= 1 return result channel = TrackedChannel() engine = _build_engine( - scenario, channel, + scenario, + channel, max_concurrent_cases=2, session=db_session, ) @@ -284,11 +342,13 @@ async def test_concurrent_cases_respect_semaphore(db_session): # ── send failure ───────────────────────────────────────────────────────── + async def test_send_failure_aborts_case(db_session): """When channel.send fails, the case is marked failed but the engine continues with the next case.""" scenario = Scenario( - id="s-1", name="mixed", + id="s-1", + name="mixed", cases=[ Case(id="c1", type=CaseType.SINGLE, messages=["a"]), Case(id="c2", type=CaseType.SINGLE, messages=["b"]), @@ -297,11 +357,11 @@ async def test_send_failure_aborts_case(db_session): call_count = {"n": 0} class FlakeyChannel(MockChannel): - async def send(self, content, **kwargs): + async def _send(self, content, **kwargs): call_count["n"] += 1 if call_count["n"] == 1: return SendResult(ok=False, error="boom") - return await super().send(content, **kwargs) + return await super()._send(content, **kwargs) channel = FlakeyChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -315,12 +375,14 @@ async def test_send_failure_aborts_case(db_session): # ── dynamic case generation failure ────────────────────────────────────── + async def test_dynamic_generation_failure_records_case_error(db_session): """A dynamic case whose message generation fails must persist the reason into run.summary.case_errors — not just emit it transiently. Otherwise a run shows 0 rules / failed with no discoverable cause.""" scenario = Scenario( - id="s-1", name="dynamic", + id="s-1", + name="dynamic", cases=[Case(id="dyn-1", type=CaseType.DYNAMIC, prompt="生成问题", turns=3)], llm_config=None, # 缺 llm_config → 生成消息立即失败 ) @@ -354,7 +416,9 @@ def _case_with( rule_pass_threshold: float = 0.6, ) -> Case: return Case( - id="c1", type=CaseType.SINGLE, messages=["hi"], + id="c1", + type=CaseType.SINGLE, + messages=["hi"], eval_rules=rules or [], expectations=expectations or Expectation(), rule_logic=rule_logic, @@ -364,10 +428,16 @@ def _case_with( async def test_expectation_fails_case_even_when_rules_pass(db_session): """期望不满足 → 用例不通过,即使显式规则全部通过。""" - scenario = Scenario(id="s1", name="s", cases=[_case_with( - rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]})], - expectations=Expectation(keywords_include=["__NOPE__"]), - )]) + scenario = Scenario( + id="s1", + name="s", + cases=[ + _case_with( + rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]})], + expectations=Expectation(keywords_include=["__NOPE__"]), + ) + ], + ) channel = MockChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -379,10 +449,16 @@ async def test_expectation_fails_case_even_when_rules_pass(db_session): async def test_expectation_and_rules_both_pass(db_session): """期望与规则都满足 → 通过,且期望派生判定同构落库、reason 可辨识来源。""" - scenario = Scenario(id="s1", name="s", cases=[_case_with( - rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]})], - expectations=Expectation(keywords_include=["echo"], response_time_max_ms=99999), - )]) + scenario = Scenario( + id="s1", + name="s", + cases=[ + _case_with( + rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]})], + expectations=Expectation(keywords_include=["echo"], response_time_max_ms=99999), + ) + ], + ) channel = MockChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -399,11 +475,17 @@ async def test_expectation_and_rules_both_pass(db_session): async def test_implicit_expectation_not_in_any_combination(db_session): """rule_logic=ANY 只组合显式规则:期望通过不能救活全败的显式规则组。""" - scenario = Scenario(id="s1", name="s", cases=[_case_with( - rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["__NOPE__"]})], - expectations=Expectation(keywords_include=["echo"]), # 通过 - rule_logic=RuleLogic.ANY, - )]) + scenario = Scenario( + id="s1", + name="s", + cases=[ + _case_with( + rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["__NOPE__"]})], + expectations=Expectation(keywords_include=["echo"]), # 通过 + rule_logic=RuleLogic.ANY, + ) + ], + ) channel = MockChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -414,12 +496,18 @@ async def test_implicit_expectation_not_in_any_combination(db_session): async def test_implicit_expectation_is_hard_constraint_over_weighted(db_session): """rule_logic=WEIGHTED 达标但期望不满足 → 仍不通过(期望是硬约束)。""" - scenario = Scenario(id="s1", name="s", cases=[_case_with( - rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=1.0)], - expectations=Expectation(keywords_include=["__NOPE__"]), - rule_logic=RuleLogic.WEIGHTED, - rule_pass_threshold=0.5, # 显式加权得分 1.0 ≥ 0.5 - )]) + scenario = Scenario( + id="s1", + name="s", + cases=[ + _case_with( + rules=[EvalRuleConfig(type="keyword_match", params={"keywords": ["echo"]}, weight=1.0)], + expectations=Expectation(keywords_include=["__NOPE__"]), + rule_logic=RuleLogic.WEIGHTED, + rule_pass_threshold=0.5, # 显式加权得分 1.0 ≥ 0.5 + ) + ], + ) channel = MockChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -430,12 +518,16 @@ async def test_implicit_expectation_is_hard_constraint_over_weighted(db_session) async def test_pure_expectation_case_behavior_unchanged(db_session): """纯期望用例(无显式规则):满足通过、不满足失败,与升级前一致。""" - scenario = Scenario(id="s1", name="s", cases=[ - Case(id="ok", type=CaseType.SINGLE, messages=["hi"], - expectations=Expectation(keywords_include=["echo"])), - Case(id="bad", type=CaseType.SINGLE, messages=["hi"], - expectations=Expectation(keywords_include=["__NOPE__"])), - ]) + scenario = Scenario( + id="s1", + name="s", + cases=[ + Case(id="ok", type=CaseType.SINGLE, messages=["hi"], expectations=Expectation(keywords_include=["echo"])), + Case( + id="bad", type=CaseType.SINGLE, messages=["hi"], expectations=Expectation(keywords_include=["__NOPE__"]) + ), + ], + ) channel = MockChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -447,10 +539,13 @@ async def test_pure_expectation_case_behavior_unchanged(db_session): # ── scenario_version snapshot (ticket 04) ──────────────────────────────── + async def test_engine_run_snapshots_scenario_version(db_session): """引擎直启(CLI 路径)创建的运行快照场景当前版本。""" scenario = Scenario( - id="s-1", name="versioned", version=3, + id="s-1", + name="versioned", + version=3, cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], ) channel = MockChannel() @@ -463,17 +558,22 @@ async def test_engine_run_snapshots_scenario_version(db_session): assert persisted.scenario_version == 3 - # ── 权威判定写入 summary.case_outcomes(判定语义收敛) ──────────────────── + async def test_summary_contains_case_outcomes_and_case_level_pass_rate(db_session): - scenario = Scenario(id="s-1", name="outcomes", cases=[ - # 连通用例(无规则无期望) - Case(id="conn", type=CaseType.SINGLE, messages=["ping"]), - # 判定失败用例(期望不满足) - Case(id="bad", type=CaseType.SINGLE, messages=["hi"], - expectations=Expectation(keywords_include=["__NOPE__"])), - ]) + scenario = Scenario( + id="s-1", + name="outcomes", + cases=[ + # 连通用例(无规则无期望) + Case(id="conn", type=CaseType.SINGLE, messages=["ping"]), + # 判定失败用例(期望不满足) + Case( + id="bad", type=CaseType.SINGLE, messages=["hi"], expectations=Expectation(keywords_include=["__NOPE__"]) + ), + ], + ) channel = MockChannel() engine = _build_engine(scenario, channel, session=db_session) @@ -488,12 +588,18 @@ async def test_summary_contains_case_outcomes_and_case_level_pass_rate(db_sessio async def test_connectivity_case_without_reply_fails(db_session): """连通用例没收到回复=故障=不通过(此前无条件判通过的 bug)。""" - scenario = Scenario(id="s-1", name="conn-fail", cases=[ - Case(id="conn", type=CaseType.SINGLE, messages=["ping"]), - ]) + scenario = Scenario( + id="s-1", + name="conn-fail", + cases=[ + Case(id="conn", type=CaseType.SINGLE, messages=["ping"]), + ], + ) channel = MockChannel(missing_reply=True) engine = _build_engine( - scenario, channel, session=db_session, + scenario, + channel, + session=db_session, timeout_config=TimeoutConfig(poll_reply=0.2), ) diff --git a/tests/unit/test_http_channel_and_rules.py b/tests/unit/test_http_channel_and_rules.py index e991d6c..092352f 100644 --- a/tests/unit/test_http_channel_and_rules.py +++ b/tests/unit/test_http_channel_and_rules.py @@ -67,7 +67,7 @@ async def test_http_send_extracts_msg_id(): mock_resp.raise_for_status = MagicMock() mock_resp.json = MagicMock(return_value={"data": {"id": "msg-42"}}) with patch.object(ch._client, "post", new=AsyncMock(return_value=mock_resp)): - result = await ch.send("hello") + result = await ch._send("hello") assert result.ok is True assert result.question_msg_id == "msg-42" @@ -75,7 +75,7 @@ async def test_http_send_extracts_msg_id(): async def test_http_send_failure(): ch = _make_channel() with patch.object(ch._client, "post", new=AsyncMock(side_effect=Exception("timeout"))): - result = await ch.send("hi") + result = await ch._send("hi") assert result.ok is False assert "timeout" in result.error @@ -92,7 +92,7 @@ async def test_http_poll_reply_found(): return mock_resp with patch.object(ch._client, "get", new=mock_get): - reply = await ch.poll_reply("msg-1", timeout=5.0) + reply = await ch._poll_reply("msg-1", timeout=5.0) assert reply is not None assert reply.content == "Hello world" assert reply.question_msg_id == "msg-1" @@ -108,7 +108,7 @@ async def test_http_poll_reply_timeout(): return mock_resp with patch.object(ch._client, "get", new=mock_get): - reply = await ch.poll_reply("msg-1", timeout=0.15, poll_interval=0.05) + reply = await ch._poll_reply("msg-1", timeout=0.15, poll_interval=0.05) assert reply is None @@ -128,7 +128,7 @@ async def test_http_poll_reply_readiness_flag(): return mock_resp with patch.object(ch._client, "get", new=mock_get): - reply = await ch.poll_reply("msg-1", timeout=5.0, poll_interval=0.05) + reply = await ch._poll_reply("msg-1", timeout=5.0, poll_interval=0.05) assert reply is not None assert reply.content == "final answer" @@ -243,7 +243,7 @@ async def test_openclaw_send_ok(): mock_resp.raise_for_status = MagicMock() mock_resp.json = MagicMock(return_value={"id": "chat-msg-1", "choices": [{"message": {"content": "reply"}}]}) with patch.object(ch._client, "post", new=AsyncMock(return_value=mock_resp)): - result = await ch.send("hello") + result = await ch._send("hello") assert result.ok is True assert result.question_msg_id == "chat-msg-1" @@ -251,7 +251,7 @@ async def test_openclaw_send_ok(): async def test_openclaw_send_failure(): ch = _make_openclaw_channel() with patch.object(ch._client, "post", new=AsyncMock(side_effect=Exception("timeout"))): - result = await ch.send("hi") + result = await ch._send("hi") assert result.ok is False @@ -261,7 +261,7 @@ async def test_openclaw_poll_reply_found(): mock_resp.status_code = 200 mock_resp.json = MagicMock(return_value={"choices": [{"message": {"content": "assistant reply"}}]}) with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)): - reply = await ch.poll_reply("chat-msg-1", timeout=5.0) + reply = await ch._poll_reply("chat-msg-1", timeout=5.0) assert reply is not None assert reply.content == "assistant reply" @@ -272,7 +272,7 @@ async def test_openclaw_poll_reply_timeout(): mock_resp.status_code = 404 mock_resp.json = MagicMock(return_value={}) with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)): - reply = await ch.poll_reply("chat-msg-1", timeout=0.15, poll_interval=0.05) + reply = await ch._poll_reply("chat-msg-1", timeout=0.15, poll_interval=0.05) assert reply is None diff --git a/tests/unit/test_intelligent_eval_model.py b/tests/unit/test_intelligent_eval_model.py index 5fa9be9..9cd446c 100644 --- a/tests/unit/test_intelligent_eval_model.py +++ b/tests/unit/test_intelligent_eval_model.py @@ -9,6 +9,7 @@ from agenteval.intelligent_eval.models import ( IntelligentEvalStatus, ) from agenteval.intelligent_eval.repository import ( + CompareAndSetStatus, IntelligentEvalMessageRepository, IntelligentEvalRepository, IntelligentEvalSessionRepository, @@ -33,15 +34,17 @@ def db_session(): class TestIntelligentEvalRepository: def test_create_and_get(self, db_session): repo = IntelligentEvalRepository(db_session) - ev = repo.create(IntelligentEval( - name="test-eval", - target_id="t1", - goal="evaluate customer service", - seeds={"personas": [], "goals": []}, - intent="test intent", - role_description="impatient customer", - time_window_hours=24, - )) + ev = repo.create( + IntelligentEval( + name="test-eval", + target_id="t1", + goal="evaluate customer service", + seeds={"personas": [], "goals": []}, + intent="test intent", + role_description="impatient customer", + time_window_hours=24, + ) + ) assert ev.id is not None assert ev.status == IntelligentEvalStatus.DRAFT assert ev.name == "test-eval" @@ -64,33 +67,113 @@ class TestIntelligentEvalRepository: ev = repo.create(IntelligentEval(name="eval", target_id="t1")) assert ev.status == IntelligentEvalStatus.DRAFT - ev = repo.transition_status(ev.id, IntelligentEvalStatus.PLANNING) + ev = repo._compare_and_set_status( + ev.id, expected_status=IntelligentEvalStatus.DRAFT, new_status=IntelligentEvalStatus.PLANNING + ).evaluation assert ev.status == IntelligentEvalStatus.PLANNING - ev = repo.transition_status(ev.id, IntelligentEvalStatus.PENDING_APPROVAL) + ev = repo._submit_plan_if_planning(ev.id, {"dimensions": ["test"]}).evaluation assert ev.status == IntelligentEvalStatus.PENDING_APPROVAL - ev = repo.transition_status(ev.id, IntelligentEvalStatus.EXECUTING) + ev = repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.PENDING_APPROVAL, + new_status=IntelligentEvalStatus.EXECUTING, + ).evaluation assert ev.status == IntelligentEvalStatus.EXECUTING assert ev.started_at is not None - ev = repo.transition_status(ev.id, IntelligentEvalStatus.COMPLETED) + ev = repo._submit_report_if_executing(ev.id, {"summary": "done"}).evaluation assert ev.status == IntelligentEvalStatus.COMPLETED assert ev.completed_at is not None - def test_plan_and_report_json(self, db_session): + def test_compare_and_set_status_reports_conflict_without_overwrite(self, db_session): repo = IntelligentEvalRepository(db_session) ev = repo.create(IntelligentEval(name="eval", target_id="t1")) + applied = repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.DRAFT, + new_status=IntelligentEvalStatus.PLANNING, + ) + assert applied.status is CompareAndSetStatus.APPLIED + assert applied.evaluation is not None + assert applied.evaluation.status is IntelligentEvalStatus.PLANNING + + conflict = repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.DRAFT, + new_status=IntelligentEvalStatus.EXECUTING, + ) + assert conflict.status is CompareAndSetStatus.CONFLICT + assert repo.get(ev.id).status is IntelligentEvalStatus.PLANNING + + def test_compare_and_set_status_reports_missing(self, db_session): + result = IntelligentEvalRepository(db_session)._compare_and_set_status( + "missing", + expected_status=IntelligentEvalStatus.DRAFT, + new_status=IntelligentEvalStatus.PLANNING, + ) + assert result.status is CompareAndSetStatus.NOT_FOUND + + def test_compare_and_set_status_rolls_back_failed_transaction(self, db_session, monkeypatch): + repo = IntelligentEvalRepository(db_session) + ev = repo.create(IntelligentEval(name="eval", target_id="t1")) + + def fail_commit(): + raise RuntimeError("commit failed") + + monkeypatch.setattr(db_session, "commit", fail_commit) + with pytest.raises(RuntimeError, match="commit failed"): + repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.DRAFT, + new_status=IntelligentEvalStatus.PLANNING, + ) + + monkeypatch.undo() + assert repo.get(ev.id).status is IntelligentEvalStatus.DRAFT + + def test_conditional_plan_write_updates_plan_and_status_together(self, db_session): + repo = IntelligentEvalRepository(db_session) + ev = repo.create(IntelligentEval(name="eval", target_id="t1", status=IntelligentEvalStatus.PLANNING)) + + result = repo._submit_plan_if_planning(ev.id, {"dimensions": ["退货"]}) + + assert result.status is CompareAndSetStatus.APPLIED + updated = repo.get(ev.id) + assert updated.status is IntelligentEvalStatus.PENDING_APPROVAL + assert updated.plan == {"dimensions": ["退货"]} + assert updated.plan_feedback is None + + def test_conditional_plan_write_failure_leaves_state_unchanged(self, db_session, monkeypatch): + repo = IntelligentEvalRepository(db_session) + ev = repo.create(IntelligentEval(name="eval", target_id="t1", status=IntelligentEvalStatus.PLANNING)) + + monkeypatch.setattr(db_session, "commit", lambda: (_ for _ in ()).throw(RuntimeError("commit failed"))) + with pytest.raises(RuntimeError, match="commit failed"): + repo._submit_plan_if_planning(ev.id, {"dimensions": ["退货"]}) + + monkeypatch.undo() + unchanged = repo.get(ev.id) + assert unchanged.status is IntelligentEvalStatus.PLANNING + assert unchanged.plan is None + + def test_plan_and_report_json(self, db_session): + repo = IntelligentEvalRepository(db_session) + ev = repo.create(IntelligentEval(name="eval", target_id="t1", status=IntelligentEvalStatus.PLANNING)) + plan = {"dimensions": ["退货"], "virtual_users": [], "estimated_sessions": 3} - ev.plan = plan - ev.status = IntelligentEvalStatus.PENDING_APPROVAL - ev = repo.update(ev) + ev = repo._submit_plan_if_planning(ev.id, plan).evaluation assert ev.plan == plan + ev = repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.PENDING_APPROVAL, + new_status=IntelligentEvalStatus.EXECUTING, + ).evaluation report = {"summary": "good", "findings": []} - ev.report = report - ev = repo.update(ev) + ev = repo._submit_report_if_executing(ev.id, report).evaluation assert ev.report == report @@ -100,13 +183,15 @@ class TestIntelligentEvalSessionRepository: ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1")) sess_repo = IntelligentEvalSessionRepository(db_session) - sess = sess_repo.create(IntelligentEvalSession( - eval_id=ev.id, - target_id="t1", - persona={"name": "user1", "patience": "low"}, - goal="complete return", - dimension="退货流程", - )) + sess = sess_repo.create( + IntelligentEvalSession( + eval_id=ev.id, + target_id="t1", + persona={"name": "user1", "patience": "low"}, + goal="complete return", + dimension="退货流程", + ) + ) assert sess.id is not None assert sess.status == IntelligentEvalSessionStatus.RUNNING assert sess.persona == {"name": "user1", "patience": "low"} @@ -114,6 +199,20 @@ class TestIntelligentEvalSessionRepository: sessions = sess_repo.list_by_eval(ev.id) assert len(sessions) == 1 + def test_list_by_evals_batches_and_preserves_empty_groups(self, db_session): + eval_repo = IntelligentEvalRepository(db_session) + first = eval_repo.create(IntelligentEval(name="first", target_id="t1")) + second = eval_repo.create(IntelligentEval(name="second", target_id="t1")) + + sess_repo = IntelligentEvalSessionRepository(db_session) + sess_repo.create(IntelligentEvalSession(eval_id=first.id, target_id="t1", goal="first")) + + grouped = sess_repo.list_by_evals([first.id, second.id, "missing"]) + + assert [item.goal for item in grouped[first.id]] == ["first"] + assert grouped[second.id] == [] + assert grouped["missing"] == [] + def test_close_session(self, db_session): eval_repo = IntelligentEvalRepository(db_session) ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1")) @@ -140,6 +239,62 @@ class TestIntelligentEvalSessionRepository: updated = sess_repo.get(sess.id) assert updated.turn_count == 2 + def test_close_if_running_is_conditional(self, db_session): + eval_repo = IntelligentEvalRepository(db_session) + ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1")) + + sess_repo = IntelligentEvalSessionRepository(db_session) + sess = sess_repo.create(IntelligentEvalSession(eval_id=ev.id, target_id="t1")) + status, closed = sess_repo._close_if_running(sess.id, {"goal_achieved": True}) + + assert status is CompareAndSetStatus.APPLIED + assert closed is not None + assert closed.status is IntelligentEvalSessionStatus.COMPLETED + + status, closed = sess_repo._close_if_running(sess.id, {"goal_achieved": False}) + assert status is CompareAndSetStatus.CONFLICT + assert closed is None + + def test_create_if_executing_uses_parent_target_and_rejects_other_states(self, db_session): + eval_repo = IntelligentEvalRepository(db_session) + ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1")) + sess_repo = IntelligentEvalSessionRepository(db_session) + + status, created = sess_repo._create_if_executing( + IntelligentEvalSession(eval_id=ev.id, target_id="wrong", goal="goal") + ) + assert status is CompareAndSetStatus.CONFLICT + assert created is None + + eval_repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.DRAFT, + new_status=IntelligentEvalStatus.EXECUTING, + ) + status, created = sess_repo._create_if_executing( + IntelligentEvalSession(eval_id=ev.id, target_id="wrong", goal="goal") + ) + assert status is CompareAndSetStatus.APPLIED + assert created is not None + assert created.target_id == "t1" + + def test_user_message_and_turn_count_share_one_transaction(self, db_session): + eval_repo = IntelligentEvalRepository(db_session) + ev = eval_repo.create(IntelligentEval(name="eval", target_id="t1")) + eval_repo._compare_and_set_status( + ev.id, + expected_status=IntelligentEvalStatus.DRAFT, + new_status=IntelligentEvalStatus.EXECUTING, + ) + sess_repo = IntelligentEvalSessionRepository(db_session) + _, sess = sess_repo._create_if_executing(IntelligentEvalSession(eval_id=ev.id, target_id="t1")) + + message_repo = IntelligentEvalMessageRepository(db_session) + message = IntelligentEvalMessage(session_id=sess.id, content="hello") + assert message_repo._create_user_and_increment(message) is CompareAndSetStatus.APPLIED + assert sess_repo.get(sess.id).turn_count == 1 + assert message_repo.list_by_session(sess.id)[0].content == "hello" + class TestIntelligentEvalMessageRepository: def test_create_and_list(self, db_session): diff --git a/tests/unit/test_intelligent_eval_read_model.py b/tests/unit/test_intelligent_eval_read_model.py new file mode 100644 index 0000000..9c9ec93 --- /dev/null +++ b/tests/unit/test_intelligent_eval_read_model.py @@ -0,0 +1,65 @@ +"""Pure contract tests for intelligent-evaluation read projections.""" + +from datetime import datetime, timezone + +from agenteval.intelligent_eval.models import ( + IntelligentEval, + IntelligentEvalSession, + IntelligentEvalSessionStatus, + IntelligentEvalStatus, +) +from agenteval.intelligent_eval.read_model import project_detail, project_list_item + + +def _evaluation() -> IntelligentEval: + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + return IntelligentEval( + id="eval-1", + name="客服评估", + target_id="target-1", + status=IntelligentEvalStatus.EXECUTING, + goal="验证退货流程", + seeds={"personas": ["老客户"]}, + intent="流程覆盖", + role_description="模拟用户", + plan={"dimensions": ["退货"]}, + time_window_hours=24, + created_at=now, + updated_at=now, + ) + + +def _session(session_id: str, status: IntelligentEvalSessionStatus) -> IntelligentEvalSession: + return IntelligentEvalSession( + id=session_id, + eval_id="eval-1", + target_id="target-1", + persona={"name": session_id}, + goal="完成退货", + dimension="退货", + status=status, + turn_count=2, + ) + + +def test_list_projection_contains_stable_fields_and_counts() -> None: + projection = project_list_item( + _evaluation(), + [_session("s-1", IntelligentEvalSessionStatus.RUNNING), _session("s-2", IntelligentEvalSessionStatus.COMPLETED)], + ) + + assert projection.id == "eval-1" + assert projection.session_count == 2 + assert projection.completed_sessions == 1 + assert projection.plan == {"dimensions": ["退货"]} + + +def test_detail_projection_contains_session_metadata_without_messages() -> None: + projection = project_detail(_evaluation(), [_session("s-1", IntelligentEvalSessionStatus.COMPLETED)]) + payload = projection.model_dump(mode="json") + + assert payload["sessions"][0]["id"] == "s-1" + assert payload["sessions"][0]["turn_count"] == 2 + assert "messages" not in payload + assert "content" not in payload["sessions"][0] + diff --git a/tests/unit/test_llm_task_lifecycle.py b/tests/unit/test_llm_task_lifecycle.py index c3c1dcb..4b9ff63 100644 --- a/tests/unit/test_llm_task_lifecycle.py +++ b/tests/unit/test_llm_task_lifecycle.py @@ -98,6 +98,33 @@ def test_mark_orphans_failed_flips_generating_analysis(db_session): assert repo.get_by_campaign("c-done").status == "completed" +def test_enqueue_analysis_persists_before_launch(db_session, monkeypatch): + from agenteval.evaluation import analysis + + launched = [] + monkeypatch.setattr(analysis, "get_session", lambda: db_session) + monkeypatch.setattr(analysis, "start_campaign_analysis", lambda cid, *, triggered_by: launched.append((cid, triggered_by))) + + analysis.enqueue_campaign_analysis("c-queued", triggered_by="auto") + + assert launched == [("c-queued", "auto")] + row = CampaignAnalysisRepository(db_session).get_by_campaign("c-queued") + assert row.status == "queued" + assert row.triggered_by == "auto" + + +def test_resume_queued_analysis_relaunches_persisted_jobs(db_session, monkeypatch): + from agenteval.evaluation import analysis + + repo = CampaignAnalysisRepository(db_session) + repo.enqueue("c-queued", triggered_by="auto") + launched = [] + monkeypatch.setattr(analysis, "start_campaign_analysis", lambda cid, *, triggered_by: launched.append((cid, triggered_by))) + + assert analysis.resume_queued_campaign_analysis(db_session) == 1 + assert launched == [("c-queued", "auto")] + + def test_mark_orphans_failed_flips_generating_comparison(db_session): repo = CampaignPeriodComparisonRepository(db_session) repo.upsert("c-gen", status="generating", baseline_campaign_id="b-1", triggered_by="auto") diff --git a/tests/unit/test_orphan_runs.py b/tests/unit/test_orphan_runs.py index facf6e7..a70a079 100644 --- a/tests/unit/test_orphan_runs.py +++ b/tests/unit/test_orphan_runs.py @@ -1,7 +1,7 @@ """Startup cleanup of orphan runs (interrupted by server restart).""" -from agenteval.models import EvalRun, RunStatus -from agenteval.storage.repository import RunRepository +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, EvalRun, RunStatus +from agenteval.storage.repository import CampaignRepository, RunRepository def _make_run(session, status: RunStatus) -> str: @@ -38,6 +38,46 @@ def test_mark_orphans_failed_noop_when_clean(db_session): assert repo.mark_orphans_failed() == 0 +def test_mark_orphans_preserves_only_recoverable_campaign_pending(db_session): + campaign = CampaignRepository(db_session).create( + Campaign( + id="campaign-1", + name="running", + target_id="t-1", + status=CampaignStatus.RUNNING, + window_seconds=60, + plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=2)], + ) + ) + repo = RunRepository(db_session) + pending = repo.create( + EvalRun( + target_id="t-1", + scenario_id="s-1", + campaign_id=campaign.id, + campaign_plan_index=0, + campaign_occurrence_index=0, + status=RunStatus.PENDING, + ) + ) + running = repo.create( + EvalRun( + target_id="t-1", + scenario_id="s-1", + campaign_id=campaign.id, + campaign_plan_index=0, + campaign_occurrence_index=1, + status=RunStatus.RUNNING, + ) + ) + + count = repo.mark_orphans_failed() + + assert count == 1 + assert repo.get(pending.id).status is RunStatus.PENDING + assert repo.get(running.id).status is RunStatus.FAILED + + def test_update_preserves_scenario_version_and_triggered_by(db_session): """update() 不得丢字段:scenario_version / triggered_by 必须回写(漂移回归)。""" from agenteval.models import RunTrigger diff --git a/tests/unit/test_repository.py b/tests/unit/test_repository.py index cc6abef..149f5d2 100644 --- a/tests/unit/test_repository.py +++ b/tests/unit/test_repository.py @@ -19,9 +19,11 @@ from agenteval.models import ( RunStatus, RunSummary, TargetStatus, + Turn, ) from agenteval.storage.repository import ( CampaignRepository, + ResultRepository, RunRepository, TargetRepository, ) @@ -90,6 +92,28 @@ def test_run_summary_json_round_trip(db_session): assert fetched.summary.pass_rate == 0.5 +def test_campaign_child_identity_round_trip(db_session): + """Campaign plan identity survives the repository's model conversion seam.""" + TargetRepository(db_session).create(_make_target()) + repo = RunRepository(db_session) + repo.create( + EvalRun( + id="child-1", + target_id="t-1", + scenario_id="s-1", + campaign_id="campaign-1", + campaign_plan_index=2, + campaign_occurrence_index=1, + ) + ) + + fetched = repo.get("child-1") + assert fetched is not None + assert fetched.campaign_id == "campaign-1" + assert fetched.campaign_plan_index == 2 + assert fetched.campaign_occurrence_index == 1 + + def test_campaign_crud_and_plan_round_trip(db_session): TargetRepository(db_session).create(_make_target()) repo = CampaignRepository(db_session) @@ -111,3 +135,48 @@ def test_campaign_crud_and_plan_round_trip(db_session): # Campaign inherits the shared delete() from the base repository. assert repo.delete("cp-1") is True assert repo.get("cp-1") is None + + +def test_update_turn_exchange_preserves_sent_fact(db_session): + TargetRepository(db_session).create(_make_target()) + RunRepository(db_session).create(EvalRun(id="r-1", target_id="t-1", scenario_id="s-1", status=RunStatus.RUNNING)) + repo = ResultRepository(db_session) + repo.save_turn( + Turn( + id="turn-1", + run_id="r-1", + case_id="case-1", + round_index=1, + sent_message={"msgType": "text", "msgBody": {"content": "hello"}}, + sent_at=datetime(2026, 8, 6, 1, 0, tzinfo=timezone.utc), + ) + ) + + updated = repo.update_turn_exchange( + "turn-1", + question_msg_id="question-1", + reply={"msgBody": {"content": "world"}}, + received_at=datetime(2026, 8, 6, 1, 0, 1, tzinfo=timezone.utc), + latency_ms=1000, + ) + + assert updated is not None + assert updated.get_sent_message() == {"msgType": "text", "msgBody": {"content": "hello"}} + assert updated.case_id == "case-1" + assert updated.round_index == 1 + assert updated.question_msg_id == "question-1" + assert updated.get_reply() == {"msgBody": {"content": "world"}} + assert updated.latency_ms == 1000 + + +def test_update_turn_exchange_returns_none_for_unknown_turn(db_session): + assert ( + ResultRepository(db_session).update_turn_exchange( + "missing", + question_msg_id="question-1", + reply=None, + received_at=None, + latency_ms=None, + ) + is None + )