feat(v1.3.1): Phase 2 成本/放弃率/Go-No-Go 基础设施生效 #34
@ -208,12 +208,14 @@ class EvalEngine:
|
|||||||
|
|
||||||
results = self.run_repo.get_results(run.id)
|
results = self.run_repo.get_results(run.id)
|
||||||
turns = self.run_repo.get_turns(run.id)
|
turns = self.run_repo.get_turns(run.id)
|
||||||
|
usage = self.model_gateway.total_usage
|
||||||
summary = build_run_summary(
|
summary = build_run_summary(
|
||||||
case_outcomes=case_outcomes,
|
case_outcomes=case_outcomes,
|
||||||
latencies=[t.latency_ms for t in turns if t.latency_ms is not None],
|
latencies=[t.latency_ms for t in turns if t.latency_ms is not None],
|
||||||
rule_passes=[r.passed for r in results],
|
rule_passes=[r.passed for r in results],
|
||||||
case_errors=self._case_errors or None,
|
case_errors=self._case_errors or None,
|
||||||
model_configs=resolved_snapshot or None,
|
model_configs=resolved_snapshot or None,
|
||||||
|
eval_token_usage=usage if usage["total_tokens"] > 0 else None,
|
||||||
)
|
)
|
||||||
run.status = RunStatus.COMPLETED
|
run.status = RunStatus.COMPLETED
|
||||||
run.completed_at = utc_now()
|
run.completed_at = utc_now()
|
||||||
@ -393,7 +395,7 @@ class EvalEngine:
|
|||||||
"error": outcome.reason,
|
"error": outcome.reason,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return failed, 0, 0
|
return CaseOutcome(passed=False, connectivity=False, abandoned=bool(dialog)), 0, 0
|
||||||
|
|
||||||
if turn is None:
|
if turn is None:
|
||||||
raise RuntimeError("channel exchange succeeded without invoking the sent hook")
|
raise RuntimeError("channel exchange succeeded without invoking the sent hook")
|
||||||
@ -410,7 +412,7 @@ class EvalEngine:
|
|||||||
"error": f"poll_reply 异常: {outcome.reason}",
|
"error": f"poll_reply 异常: {outcome.reason}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return failed, 0, 0
|
return CaseOutcome(passed=False, connectivity=False, abandoned=bool(dialog)), 0, 0
|
||||||
|
|
||||||
dialog.append(turn)
|
dialog.append(turn)
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,8 @@ class CaseOutcome:
|
|||||||
|
|
||||||
passed: bool
|
passed: bool
|
||||||
connectivity: bool
|
connectivity: bool
|
||||||
|
# 对话中途放弃:已有完成的轮次,但后续发送/接收失败导致对话未走完
|
||||||
|
abandoned: bool = False
|
||||||
|
|
||||||
|
|
||||||
def combine_case_outcome(
|
def combine_case_outcome(
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from typing import Any, Optional
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from agenteval.evaluation.case_verdict import build_case_evidence, resolve_case_verdicts
|
from agenteval.evaluation.case_verdict import build_case_evidence, resolve_case_verdicts
|
||||||
from agenteval.evaluation.go_no_go import evaluate_go_no_go
|
from agenteval.evaluation.go_no_go import AcceptanceCriteria, evaluate_go_no_go
|
||||||
from agenteval.evaluation.metrics import aggregate_runs
|
from agenteval.evaluation.metrics import aggregate_runs
|
||||||
from agenteval.evaluation.report_render import render_html, render_json, render_markdown
|
from agenteval.evaluation.report_render import render_html, render_json, render_markdown
|
||||||
from agenteval.models import Campaign, EvalRun, RunStatus, RunSummary
|
from agenteval.models import Campaign, EvalRun, RunStatus, RunSummary
|
||||||
@ -108,16 +108,22 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
|
|||||||
"total_cases": total_cases,
|
"total_cases": total_cases,
|
||||||
"passed_cases": passed_cases,
|
"passed_cases": passed_cases,
|
||||||
"failed_cases": summary.failed_cases,
|
"failed_cases": summary.failed_cases,
|
||||||
|
"abandoned_cases": summary.abandoned_cases,
|
||||||
|
"abandonment_rate": summary.abandonment_rate,
|
||||||
"total_rules": summary.total_rules,
|
"total_rules": summary.total_rules,
|
||||||
"passed_rules": summary.passed_rules,
|
"passed_rules": summary.passed_rules,
|
||||||
"pass_rate": summary.pass_rate if summary.pass_rate is not None else 0.0,
|
"pass_rate": summary.pass_rate if summary.pass_rate is not None else 0.0,
|
||||||
"connectivity_cases": connectivity_count,
|
"connectivity_cases": connectivity_count,
|
||||||
"judged_pass_rate": judged_pass_rate,
|
"judged_pass_rate": judged_pass_rate,
|
||||||
"avg_latency_ms": summary.avg_latency_ms,
|
"avg_latency_ms": summary.avg_latency_ms,
|
||||||
|
"eval_token_usage": summary.eval_token_usage,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate go/no-go verdict
|
# Generate go/no-go verdict(场景级验收标准优先,缺省用全局默认)
|
||||||
verdict = evaluate_go_no_go(summary_dict)
|
criteria = None
|
||||||
|
if scenario is not None and scenario.acceptance_criteria:
|
||||||
|
criteria = AcceptanceCriteria(**scenario.acceptance_criteria)
|
||||||
|
verdict = evaluate_go_no_go(summary_dict, criteria)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"run_id": run.id,
|
"run_id": run.id,
|
||||||
|
|||||||
@ -20,13 +20,16 @@ def build_run_summary(
|
|||||||
rule_passes: Sequence[bool],
|
rule_passes: Sequence[bool],
|
||||||
case_errors: Optional[list[dict[str, str]]] = None,
|
case_errors: Optional[list[dict[str, str]]] = None,
|
||||||
model_configs: Optional[dict[str, Any]] = None,
|
model_configs: Optional[dict[str, Any]] = None,
|
||||||
|
eval_token_usage: Optional[dict[str, int]] = None,
|
||||||
) -> RunSummary:
|
) -> RunSummary:
|
||||||
"""Compute a run's summary口径 from its authoritative case outcomes."""
|
"""Compute a run's summary口径 from its authoritative case outcomes."""
|
||||||
total_cases = len(case_outcomes)
|
total_cases = len(case_outcomes)
|
||||||
passed_cases = sum(1 for o in case_outcomes.values() if o.passed)
|
passed_cases = sum(1 for o in case_outcomes.values() if o.passed)
|
||||||
connectivity_count = sum(1 for o in case_outcomes.values() if o.connectivity)
|
connectivity_count = sum(1 for o in case_outcomes.values() if o.connectivity)
|
||||||
|
abandoned_cases = sum(1 for o in case_outcomes.values() if o.abandoned)
|
||||||
|
|
||||||
pass_rate = round(passed_cases / total_cases, 4) if total_cases else 0.0
|
pass_rate = round(passed_cases / total_cases, 4) if total_cases else 0.0
|
||||||
|
abandonment_rate = round(abandoned_cases / total_cases, 4) if total_cases else None
|
||||||
# 连通用例按引擎口径计通过,判定型通过数 = 总通过数 - 连通用例数
|
# 连通用例按引擎口径计通过,判定型通过数 = 总通过数 - 连通用例数
|
||||||
judged_total = total_cases - connectivity_count
|
judged_total = total_cases - connectivity_count
|
||||||
judged_pass_rate = (
|
judged_pass_rate = (
|
||||||
@ -39,13 +42,18 @@ def build_run_summary(
|
|||||||
total_cases=total_cases,
|
total_cases=total_cases,
|
||||||
passed_cases=passed_cases,
|
passed_cases=passed_cases,
|
||||||
failed_cases=total_cases - passed_cases,
|
failed_cases=total_cases - passed_cases,
|
||||||
|
abandoned_cases=abandoned_cases,
|
||||||
total_rules=len(rule_passes),
|
total_rules=len(rule_passes),
|
||||||
passed_rules=sum(1 for p in rule_passes if p),
|
passed_rules=sum(1 for p in rule_passes if p),
|
||||||
pass_rate=pass_rate,
|
pass_rate=pass_rate,
|
||||||
judged_pass_rate=judged_pass_rate,
|
judged_pass_rate=judged_pass_rate,
|
||||||
|
abandonment_rate=abandonment_rate,
|
||||||
avg_latency_ms=avg_latency_ms,
|
avg_latency_ms=avg_latency_ms,
|
||||||
|
eval_token_usage=eval_token_usage,
|
||||||
case_outcomes={
|
case_outcomes={
|
||||||
case_id: CaseOutcomeSummary(passed=o.passed, connectivity=o.connectivity)
|
case_id: CaseOutcomeSummary(
|
||||||
|
passed=o.passed, connectivity=o.connectivity, abandoned=o.abandoned
|
||||||
|
)
|
||||||
for case_id, o in case_outcomes.items()
|
for case_id, o in case_outcomes.items()
|
||||||
},
|
},
|
||||||
case_errors=case_errors or [],
|
case_errors=case_errors or [],
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from agenteval.model_protocols import ProtocolAdapterError, get_protocol_adapter
|
from agenteval.model_protocols import ModelProtocolAdapter, ProtocolAdapterError, get_protocol_adapter
|
||||||
from agenteval.services.model_configs import ModelRuntimeConfig
|
from agenteval.services.model_configs import ModelRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
@ -16,6 +16,15 @@ class ModelGateway:
|
|||||||
def __init__(self, timeout: float = 60.0, transport: httpx.AsyncBaseTransport | None = None):
|
def __init__(self, timeout: float = 60.0, transport: httpx.AsyncBaseTransport | None = None):
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
# 评测侧 LLM 调用的累计 token 用量(引擎结束时写入 run summary)
|
||||||
|
self.total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||||
|
|
||||||
|
def _record_usage(self, adapter: ModelProtocolAdapter, data: dict[str, Any]) -> None:
|
||||||
|
usage = adapter.parse_usage(data)
|
||||||
|
if not usage:
|
||||||
|
return
|
||||||
|
for key in self.total_usage:
|
||||||
|
self.total_usage[key] += int(usage.get(key) or 0)
|
||||||
|
|
||||||
async def _post(self, config: ModelRuntimeConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
async def _post(self, config: ModelRuntimeConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
adapter = self._adapter(config)
|
adapter = self._adapter(config)
|
||||||
@ -55,7 +64,9 @@ class ModelGateway:
|
|||||||
adapter = self._adapter(config)
|
adapter = self._adapter(config)
|
||||||
try:
|
try:
|
||||||
payload = adapter.chat_payload(config.model_name, messages, temperature)
|
payload = adapter.chat_payload(config.model_name, messages, temperature)
|
||||||
return adapter.parse_chat(await self._post(config, payload))
|
data = await self._post(config, payload)
|
||||||
|
self._record_usage(adapter, data)
|
||||||
|
return adapter.parse_chat(data)
|
||||||
except ProtocolAdapterError as exc:
|
except ProtocolAdapterError as exc:
|
||||||
raise ModelGatewayError(str(exc)) from exc
|
raise ModelGatewayError(str(exc)) from exc
|
||||||
|
|
||||||
@ -63,7 +74,9 @@ class ModelGateway:
|
|||||||
adapter = self._adapter(config)
|
adapter = self._adapter(config)
|
||||||
try:
|
try:
|
||||||
payload = adapter.embedding_payload(config.model_name, inputs)
|
payload = adapter.embedding_payload(config.model_name, inputs)
|
||||||
return adapter.parse_embeddings(await self._post(config, payload))
|
data = await self._post(config, payload)
|
||||||
|
self._record_usage(adapter, data)
|
||||||
|
return adapter.parse_embeddings(data)
|
||||||
except ProtocolAdapterError as exc:
|
except ProtocolAdapterError as exc:
|
||||||
raise ModelGatewayError(str(exc)) from exc
|
raise ModelGatewayError(str(exc)) from exc
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,10 @@ class ModelProtocolAdapter:
|
|||||||
def parse_moderation(self, data: dict[str, Any]) -> dict[str, Any]:
|
def parse_moderation(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
self._unsupported(ModelCapability.MODERATION)
|
self._unsupported(ModelCapability.MODERATION)
|
||||||
|
|
||||||
|
def parse_usage(self, data: dict[str, Any]) -> dict[str, int] | None:
|
||||||
|
"""Extract token usage from a response; None for protocols that omit it."""
|
||||||
|
return None
|
||||||
|
|
||||||
def _unsupported(self, capability: ModelCapability) -> None:
|
def _unsupported(self, capability: ModelCapability) -> None:
|
||||||
raise ProtocolAdapterError(f"{self.protocol.value} 协议不支持 {capability.value} 能力")
|
raise ProtocolAdapterError(f"{self.protocol.value} 协议不支持 {capability.value} 能力")
|
||||||
|
|
||||||
|
|||||||
@ -131,6 +131,8 @@ class Scenario(BaseModel):
|
|||||||
cases: list[Case] = Field(default_factory=list)
|
cases: list[Case] = Field(default_factory=list)
|
||||||
model_bindings: dict[ModelPurpose, str] = Field(default_factory=dict)
|
model_bindings: dict[ModelPurpose, str] = Field(default_factory=dict)
|
||||||
llm_config: Optional[dict[str, Any]] = None
|
llm_config: Optional[dict[str, Any]] = None
|
||||||
|
# 场景级 Go/No-Go 验收标准(键同 go_no_go.AcceptanceCriteria);空则用全局默认
|
||||||
|
acceptance_criteria: Optional[dict[str, Any]] = None
|
||||||
# 考纲版本,由系统维护(ADR-0001):API 传入值会被忽略
|
# 考纲版本,由系统维护(ADR-0001):API 传入值会被忽略
|
||||||
version: int = 1
|
version: int = 1
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@ -170,6 +172,8 @@ class CaseOutcomeSummary(BaseModel):
|
|||||||
|
|
||||||
passed: bool = False
|
passed: bool = False
|
||||||
connectivity: bool = False
|
connectivity: bool = False
|
||||||
|
# 对话中途放弃:已有完成的轮次,但后续发送/接收失败导致对话未走完
|
||||||
|
abandoned: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RunSummary(BaseModel):
|
class RunSummary(BaseModel):
|
||||||
@ -194,6 +198,8 @@ class RunSummary(BaseModel):
|
|||||||
# 用户放弃率:abandoned_cases / total_cases
|
# 用户放弃率:abandoned_cases / total_cases
|
||||||
abandonment_rate: Optional[float] = None
|
abandonment_rate: Optional[float] = None
|
||||||
avg_latency_ms: Optional[float] = None
|
avg_latency_ms: Optional[float] = None
|
||||||
|
# 评测侧 LLM 调用累计 token 用量(judge/generator 等,引擎经 ModelGateway 统计)
|
||||||
|
eval_token_usage: Optional[dict[str, int]] = None
|
||||||
case_outcomes: dict[str, CaseOutcomeSummary] = Field(default_factory=dict)
|
case_outcomes: dict[str, CaseOutcomeSummary] = Field(default_factory=dict)
|
||||||
case_errors: list[dict[str, str]] = Field(default_factory=list)
|
case_errors: list[dict[str, str]] = Field(default_factory=list)
|
||||||
model_configs: dict[str, Any] = Field(default_factory=dict)
|
model_configs: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|||||||
@ -47,6 +47,7 @@ class ScenarioDB(SQLModel, table=True):
|
|||||||
tags: str = "[]"
|
tags: str = "[]"
|
||||||
cases: str = "[]"
|
cases: str = "[]"
|
||||||
llm_config: Optional[str] = None
|
llm_config: Optional[str] = None
|
||||||
|
acceptance_criteria: Optional[str] = None
|
||||||
version: int = Field(default=1)
|
version: int = Field(default=1)
|
||||||
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
created_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
updated_at: Optional[datetime] = Field(default_factory=utc_now)
|
updated_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||||
@ -74,6 +75,12 @@ class ScenarioDB(SQLModel, table=True):
|
|||||||
def set_llm_config(self, config: Optional[dict[str, Any]]) -> None:
|
def set_llm_config(self, config: Optional[dict[str, Any]]) -> None:
|
||||||
self.llm_config = _json_dumps(config) if config else None
|
self.llm_config = _json_dumps(config) if config else None
|
||||||
|
|
||||||
|
def get_acceptance_criteria(self) -> Optional[dict[str, Any]]:
|
||||||
|
return _json_loads(self.acceptance_criteria) if self.acceptance_criteria else None
|
||||||
|
|
||||||
|
def set_acceptance_criteria(self, criteria: Optional[dict[str, Any]]) -> None:
|
||||||
|
self.acceptance_criteria = _json_dumps(criteria) if criteria else None
|
||||||
|
|
||||||
|
|
||||||
class EvalRunDB(SQLModel, table=True):
|
class EvalRunDB(SQLModel, table=True):
|
||||||
"""Database table for evaluation runs."""
|
"""Database table for evaluation runs."""
|
||||||
|
|||||||
@ -29,6 +29,7 @@ class ScenarioRepository(BaseRepository[Scenario, ScenarioDB]):
|
|||||||
# mode="json" 与 update() 的考纲比较保持同一序列化形态,避免假升版
|
# mode="json" 与 update() 的考纲比较保持同一序列化形态,避免假升版
|
||||||
db.set_cases([case.model_dump(mode="json") for case in scenario.cases])
|
db.set_cases([case.model_dump(mode="json") for case in scenario.cases])
|
||||||
db.set_llm_config(scenario.llm_config)
|
db.set_llm_config(scenario.llm_config)
|
||||||
|
db.set_acceptance_criteria(scenario.acceptance_criteria)
|
||||||
return db
|
return db
|
||||||
|
|
||||||
def _from_db(self, db: ScenarioDB) -> Scenario:
|
def _from_db(self, db: ScenarioDB) -> Scenario:
|
||||||
@ -41,6 +42,7 @@ class ScenarioRepository(BaseRepository[Scenario, ScenarioDB]):
|
|||||||
cases=[Case(**case) for case in db.get_cases()],
|
cases=[Case(**case) for case in db.get_cases()],
|
||||||
model_bindings=bindings,
|
model_bindings=bindings,
|
||||||
llm_config=db.get_llm_config(),
|
llm_config=db.get_llm_config(),
|
||||||
|
acceptance_criteria=db.get_acceptance_criteria(),
|
||||||
version=db.version or 1,
|
version=db.version or 1,
|
||||||
created_at=db.created_at,
|
created_at=db.created_at,
|
||||||
updated_at=db.updated_at,
|
updated_at=db.updated_at,
|
||||||
@ -84,6 +86,7 @@ class ScenarioRepository(BaseRepository[Scenario, ScenarioDB]):
|
|||||||
existing.set_tags(scenario.tags)
|
existing.set_tags(scenario.tags)
|
||||||
existing.set_cases(new_cases)
|
existing.set_cases(new_cases)
|
||||||
existing.set_llm_config(scenario.llm_config)
|
existing.set_llm_config(scenario.llm_config)
|
||||||
|
existing.set_acceptance_criteria(scenario.acceptance_criteria)
|
||||||
existing.updated_at = utc_now()
|
existing.updated_at = utc_now()
|
||||||
self.session.add(existing)
|
self.session.add(existing)
|
||||||
ScenarioModelBindingRepository(self.session).replace_for_scenario(existing.id or "", bindings)
|
ScenarioModelBindingRepository(self.session).replace_for_scenario(existing.id or "", bindings)
|
||||||
@ -130,6 +133,7 @@ class ScenarioRepository(BaseRepository[Scenario, ScenarioDB]):
|
|||||||
cases=[Case(**case) for case in db.get_cases()],
|
cases=[Case(**case) for case in db.get_cases()],
|
||||||
model_bindings=bindings,
|
model_bindings=bindings,
|
||||||
llm_config=db.get_llm_config(),
|
llm_config=db.get_llm_config(),
|
||||||
|
acceptance_criteria=db.get_acceptance_criteria(),
|
||||||
version=db.version or 1,
|
version=db.version or 1,
|
||||||
created_at=db.created_at,
|
created_at=db.created_at,
|
||||||
updated_at=db.updated_at,
|
updated_at=db.updated_at,
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
"""add acceptance_criteria column to scenarios
|
||||||
|
|
||||||
|
Revision ID: 3f8a2c91d4e7
|
||||||
|
Revises: d5f6193ba7c8
|
||||||
|
Create Date: 2026-08-25 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '3f8a2c91d4e7'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'd5f6193ba7c8'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(inspector, table_name: str, column_name: str) -> bool:
|
||||||
|
columns = inspector.get_columns(table_name)
|
||||||
|
return any(col['name'] == column_name for col in columns)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add scenarios.acceptance_criteria for per-scenario go/no-go thresholds."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
|
||||||
|
if not _column_exists(inspector, 'scenarios', 'acceptance_criteria'):
|
||||||
|
with op.batch_alter_table('scenarios', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('acceptance_criteria', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
|
||||||
|
if _column_exists(inspector, 'scenarios', 'acceptance_criteria'):
|
||||||
|
with op.batch_alter_table('scenarios', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('acceptance_criteria')
|
||||||
@ -10,6 +10,7 @@ from tests.unit.mock_channel import MockChannel
|
|||||||
class FakeGateway:
|
class FakeGateway:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.chat_calls = 0
|
self.chat_calls = 0
|
||||||
|
self.total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||||
|
|
||||||
async def chat(self, config, messages, temperature=0.2):
|
async def chat(self, config, messages, temperature=0.2):
|
||||||
self.chat_calls += 1
|
self.chat_calls += 1
|
||||||
|
|||||||
255
tests/unit/test_phase2_wiring.py
Normal file
255
tests/unit/test_phase2_wiring.py
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
"""Phase 2 (v1.3.1) wiring tests: token usage, abandonment rate, go/no-go config."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from agenteval.channels.base import ChannelTransportError
|
||||||
|
from agenteval.evaluation.judgement import CaseOutcome
|
||||||
|
from agenteval.evaluation.run_summary import build_run_summary
|
||||||
|
from agenteval.model_gateway import ModelGateway
|
||||||
|
from agenteval.models import (
|
||||||
|
Case,
|
||||||
|
CaseType,
|
||||||
|
ChannelType,
|
||||||
|
EvalTarget,
|
||||||
|
ModelCapability,
|
||||||
|
PlatformType,
|
||||||
|
RunStatus,
|
||||||
|
Scenario,
|
||||||
|
TargetStatus,
|
||||||
|
)
|
||||||
|
from agenteval.services.model_configs import ModelRuntimeConfig
|
||||||
|
from agenteval.storage.repository import RunRepository, ScenarioRepository
|
||||||
|
|
||||||
|
from tests.unit.mock_channel import MockChannel
|
||||||
|
from tests.unit.test_engine import _build_engine
|
||||||
|
|
||||||
|
# ── 2.1 token usage accumulation ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_config() -> ModelRuntimeConfig:
|
||||||
|
return ModelRuntimeConfig(
|
||||||
|
id="cfg-1",
|
||||||
|
name="judge",
|
||||||
|
provider="openai_compatible",
|
||||||
|
capability=ModelCapability.CHAT,
|
||||||
|
endpoint_url="https://models.example.com/v1/chat/completions",
|
||||||
|
model_name="test-model",
|
||||||
|
api_key="k",
|
||||||
|
updated_at=datetime(2026, 8, 25),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_accumulates_token_usage():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [{"message": {"content": "ok"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = ModelGateway(transport=httpx.MockTransport(handler))
|
||||||
|
config = _runtime_config()
|
||||||
|
await gateway.chat(config, [{"role": "user", "content": "a"}])
|
||||||
|
await gateway.chat(config, [{"role": "user", "content": "b"}])
|
||||||
|
assert gateway.total_usage == {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gateway_usage_stays_zero_when_response_omits_it():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||||||
|
|
||||||
|
gateway = ModelGateway(transport=httpx.MockTransport(handler))
|
||||||
|
await gateway.chat(_runtime_config(), [{"role": "user", "content": "a"}])
|
||||||
|
assert gateway.total_usage["total_tokens"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2.2 abandonment rate ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_summary_counts_abandoned_cases():
|
||||||
|
outcomes = {
|
||||||
|
"c1": CaseOutcome(passed=True, connectivity=False),
|
||||||
|
"c2": CaseOutcome(passed=False, connectivity=False, abandoned=True),
|
||||||
|
}
|
||||||
|
summary = build_run_summary(
|
||||||
|
case_outcomes=outcomes,
|
||||||
|
latencies=[100],
|
||||||
|
rule_passes=[True],
|
||||||
|
eval_token_usage={"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
|
||||||
|
)
|
||||||
|
assert summary.abandoned_cases == 1
|
||||||
|
assert summary.abandonment_rate == 0.5
|
||||||
|
assert summary.eval_token_usage is not None
|
||||||
|
assert summary.eval_token_usage["total_tokens"] == 12
|
||||||
|
assert summary.case_outcomes["c2"].abandoned is True
|
||||||
|
assert summary.case_outcomes["c1"].abandoned is False
|
||||||
|
|
||||||
|
|
||||||
|
class _FailSecondPoll(MockChannel):
|
||||||
|
"""First poll succeeds, subsequent polls raise — dialog abandoned mid-way."""
|
||||||
|
|
||||||
|
async def _poll_reply(self, question_msg_id, timeout=30.0, poll_interval=1.0):
|
||||||
|
if self.poll_calls >= 1:
|
||||||
|
raise ChannelTransportError("upstream gone")
|
||||||
|
return await super()._poll_reply(question_msg_id, timeout, poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def _two_message_scenario() -> Scenario:
|
||||||
|
return Scenario(
|
||||||
|
id="s-1",
|
||||||
|
name="abandon",
|
||||||
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["m1", "m2"])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _target() -> EvalTarget:
|
||||||
|
return EvalTarget(
|
||||||
|
id="t-1",
|
||||||
|
name="mock-target",
|
||||||
|
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
|
||||||
|
channel_type=ChannelType.TUTU_API,
|
||||||
|
channel_config={
|
||||||
|
"base_url": "http://mock",
|
||||||
|
"token": "x",
|
||||||
|
"tenant": "t",
|
||||||
|
"chat_channel_id": "c",
|
||||||
|
"chat_contact_id": "u",
|
||||||
|
},
|
||||||
|
status=TargetStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_engine_marks_mid_dialog_failure_as_abandoned(db_session):
|
||||||
|
engine = _build_engine(_two_message_scenario(), _FailSecondPoll(), session=db_session)
|
||||||
|
run = await engine.run()
|
||||||
|
assert run.status == RunStatus.COMPLETED
|
||||||
|
assert run.summary.abandoned_cases == 1
|
||||||
|
assert run.summary.abandonment_rate == 1.0
|
||||||
|
assert run.summary.case_outcomes["c1"].abandoned is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_engine_first_round_failure_is_not_abandoned(db_session):
|
||||||
|
# 第一轮发送就失败:连通性问题,不算放弃
|
||||||
|
engine = _build_engine(
|
||||||
|
_two_message_scenario(), MockChannel(send_ok=False), session=db_session
|
||||||
|
)
|
||||||
|
run = await engine.run()
|
||||||
|
assert run.summary.abandoned_cases == 0
|
||||||
|
assert run.summary.case_outcomes["c1"].abandoned is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2.3 go/no-go per-scenario acceptance criteria ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_acceptance_criteria_roundtrip(db_session):
|
||||||
|
repo = ScenarioRepository(db_session)
|
||||||
|
criteria = {"judged_pass_rate_min": 0.8, "avg_latency_max_ms": 5000}
|
||||||
|
scenario = Scenario(
|
||||||
|
name="criteria",
|
||||||
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
||||||
|
acceptance_criteria=criteria,
|
||||||
|
)
|
||||||
|
created = repo.create(scenario)
|
||||||
|
fetched = repo.get(created.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.acceptance_criteria == criteria
|
||||||
|
|
||||||
|
# 验收标准是报告配置,不属于考纲——变更不应升版
|
||||||
|
fetched.acceptance_criteria = {"judged_pass_rate_min": 0.7}
|
||||||
|
updated = repo.update(fetched)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.version == created.version
|
||||||
|
assert updated.acceptance_criteria == {"judged_pass_rate_min": 0.7}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_report_verdict_uses_scenario_criteria(report_seeded):
|
||||||
|
from agenteval.evaluation.report import generate_report
|
||||||
|
|
||||||
|
session, run_id, scenario_id = report_seeded
|
||||||
|
|
||||||
|
# 默认标准(judged ≥ 0.95)下该 run 为 no_go
|
||||||
|
report = generate_report(run_id, session)
|
||||||
|
assert report["go_no_go"]["decision"] == "no_go"
|
||||||
|
|
||||||
|
# 场景放宽标准后翻转为 go
|
||||||
|
repo = ScenarioRepository(session)
|
||||||
|
scenario = repo.get(scenario_id)
|
||||||
|
assert scenario is not None
|
||||||
|
scenario.acceptance_criteria = {"judged_pass_rate_min": 0.0, "pass_rate_min": 0.0}
|
||||||
|
repo.update(scenario)
|
||||||
|
|
||||||
|
report = generate_report(run_id, session)
|
||||||
|
assert report["go_no_go"]["decision"] == "go"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def report_seeded(db_session):
|
||||||
|
"""Seed a completed failing run (pass_rate 0) and return (session, run_id, scenario_id)."""
|
||||||
|
from agenteval.models import EvalResult, EvalRun, Turn
|
||||||
|
from agenteval.storage.repository import ResultRepository, TargetRepository
|
||||||
|
|
||||||
|
target = TargetRepository(db_session).create(
|
||||||
|
EvalTarget(
|
||||||
|
name="t",
|
||||||
|
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
|
||||||
|
channel_type=ChannelType.TUTU_API,
|
||||||
|
channel_config={},
|
||||||
|
status=TargetStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scenario = ScenarioRepository(db_session).create(
|
||||||
|
Scenario(name="s", cases=[Case(id="c0", type=CaseType.SINGLE, messages=["hi"])])
|
||||||
|
)
|
||||||
|
run = RunRepository(db_session).create(
|
||||||
|
EvalRun(
|
||||||
|
target_id=target.id,
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
scenario_version=1,
|
||||||
|
status=RunStatus.COMPLETED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result_repo = ResultRepository(db_session)
|
||||||
|
result_repo.save_turn(
|
||||||
|
Turn(
|
||||||
|
run_id=run.id,
|
||||||
|
case_id="c0",
|
||||||
|
round_index=1,
|
||||||
|
sent_message={"msgBody": {"content": "hi"}},
|
||||||
|
reply={"msgBody": {"content": "bad answer"}},
|
||||||
|
latency_ms=200,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_turn = RunRepository(db_session).get_turns(run.id)[-1]
|
||||||
|
result_repo.save_result(
|
||||||
|
EvalResult(
|
||||||
|
run_id=run.id,
|
||||||
|
case_id="c0",
|
||||||
|
turn_id=db_turn.id or "",
|
||||||
|
rule_type="keyword_match",
|
||||||
|
passed=False,
|
||||||
|
score=0.0,
|
||||||
|
reason="失败",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run.summary = {
|
||||||
|
"total_cases": 1,
|
||||||
|
"passed_cases": 0,
|
||||||
|
"failed_cases": 1,
|
||||||
|
"total_rules": 1,
|
||||||
|
"passed_rules": 0,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"judged_pass_rate": 0.0,
|
||||||
|
"avg_latency_ms": 200.0,
|
||||||
|
"case_outcomes": {"c0": {"passed": False, "connectivity": False}},
|
||||||
|
}
|
||||||
|
RunRepository(db_session).update(run)
|
||||||
|
return db_session, run.id, scenario.id
|
||||||
Loading…
Reference in New Issue
Block a user