426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
851 lines
34 KiB
Python
851 lines
34 KiB
Python
"""LLM-powered branching conversation evaluator for WooBench.
|
|
|
|
The evaluator plays the persona and scores the reading agent's responses
|
|
by navigating the scenario's ResponseTree using LLM-based condition matching.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import random
|
|
import re
|
|
import asyncio
|
|
from typing import Callable, Any
|
|
|
|
from .payment_actions import (
|
|
PaymentDemand,
|
|
detect_payment_check,
|
|
detect_payment_demand,
|
|
normalize_agent_turn,
|
|
)
|
|
from .types import (
|
|
PersonaArchetype,
|
|
Scenario,
|
|
ResponseNode,
|
|
TurnEvaluation,
|
|
MatchResult,
|
|
HiddenContext,
|
|
ScenarioResult,
|
|
RevenueResult,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WooBenchEvaluator:
|
|
"""LLM-powered evaluator that plays the persona and scores the reading agent."""
|
|
|
|
def __init__(
|
|
self,
|
|
evaluator_model: str = "gpt-5",
|
|
evaluator_mode: str = "llm",
|
|
payment_client: Any | None = None,
|
|
):
|
|
self.evaluator_model = evaluator_model
|
|
if evaluator_mode not in {"llm", "heuristic"}:
|
|
raise ValueError("evaluator_mode must be 'llm' or 'heuristic'")
|
|
self.evaluator_mode = evaluator_mode
|
|
self.payment_client = payment_client
|
|
|
|
# ------------------------------------------------------------------
|
|
# Core evaluation methods
|
|
# ------------------------------------------------------------------
|
|
|
|
async def evaluate_condition(
|
|
self,
|
|
agent_message: str,
|
|
condition: str,
|
|
hidden_context: HiddenContext,
|
|
conversation_history: list[dict[str, str]],
|
|
) -> MatchResult:
|
|
"""Use LLM to evaluate if the agent's message matches the condition.
|
|
|
|
The evaluator has access to the full hidden context (the persona's real
|
|
story) and uses it to judge whether the agent's reading is on-target.
|
|
"""
|
|
history_text = "\n".join(
|
|
f" {turn['role']}: {turn['content']}"
|
|
for turn in conversation_history[-10:] # last 10 turns for context
|
|
)
|
|
|
|
prompt = f"""You are evaluating a mystical reading agent's performance.
|
|
|
|
The agent just said:
|
|
"{agent_message}"
|
|
|
|
The condition to evaluate:
|
|
"{condition}"
|
|
|
|
The hidden context about the user (the agent does NOT know this):
|
|
Life situation: {hidden_context.life_situation}
|
|
Key themes: {', '.join(hidden_context.key_themes)}
|
|
Emotional state: {hidden_context.emotional_state}
|
|
Specific details: {'; '.join(hidden_context.specific_details)}
|
|
|
|
Recent conversation:
|
|
{history_text}
|
|
|
|
Based on the conversation so far, does the agent's message match the condition?
|
|
Consider partial matches. The agent doesn't need to be exact -- they need to be
|
|
in the right direction. A reading that touches on the right themes even
|
|
indirectly should count as positive.
|
|
|
|
Scoring guide:
|
|
- POSITIVE: The agent clearly addresses or touches on what the condition describes
|
|
- NEUTRAL: The agent's message is tangential or only vaguely related
|
|
- NEGATIVE: The agent completely misses the mark or does the opposite
|
|
|
|
Respond with exactly one word: POSITIVE, NEUTRAL, or NEGATIVE"""
|
|
|
|
if self.evaluator_mode == "heuristic":
|
|
return self._heuristic_condition_match(
|
|
agent_message=agent_message,
|
|
condition=condition,
|
|
hidden_context=hidden_context,
|
|
)
|
|
|
|
result = await self._call_llm(prompt)
|
|
cleaned = result.strip().upper()
|
|
if cleaned not in ("POSITIVE", "NEGATIVE", "NEUTRAL"):
|
|
logger.warning("Unexpected LLM response %r, defaulting to NEUTRAL", cleaned)
|
|
cleaned = "NEUTRAL"
|
|
return MatchResult(cleaned.lower())
|
|
|
|
async def generate_persona_response(
|
|
self,
|
|
scenario: Scenario,
|
|
node: ResponseNode,
|
|
match_result: MatchResult,
|
|
conversation_history: list[dict[str, str]],
|
|
persona_state: dict[str, Any],
|
|
) -> str:
|
|
"""Generate the persona's response based on the match result.
|
|
|
|
Uses the pre-written responses from the node as a base, but allows
|
|
the LLM to add natural variation so conversations don't feel scripted.
|
|
"""
|
|
base_response = {
|
|
MatchResult.POSITIVE: node.positive_response,
|
|
MatchResult.NEGATIVE: node.negative_response,
|
|
MatchResult.NEUTRAL: node.neutral_response,
|
|
}[match_result]
|
|
|
|
prompt = f"""You are playing the role of {scenario.persona.name} in a conversation.
|
|
|
|
Character background: {scenario.persona.background}
|
|
Engagement style: {scenario.persona.engagement_style}
|
|
Current emotional state: {scenario.persona.hidden_context.emotional_state}
|
|
|
|
The scripted response for this moment is:
|
|
"{base_response}"
|
|
|
|
Rephrase this response naturally, keeping the same emotional tone and key
|
|
information but making it feel like a real person speaking. Keep the same
|
|
approximate length. Do not add information that isn't in the scripted version.
|
|
|
|
If the persona has opened up (revealed personal details), maintain that
|
|
vulnerability in the tone. If the persona is guarded, keep the walls up.
|
|
|
|
Conversation so far:
|
|
{chr(10).join(f" {t['role']}: {t['content']}" for t in conversation_history[-6:])}
|
|
|
|
Respond ONLY with the rephrased response, nothing else."""
|
|
|
|
# For deterministic smoke runs, avoid external LLM calls entirely.
|
|
if self.evaluator_mode == "heuristic" or persona_state.get("use_scripted_responses", False):
|
|
return base_response
|
|
|
|
result = await self._call_llm(prompt)
|
|
return result.strip() if result.strip() else base_response
|
|
|
|
async def evaluate_turn(
|
|
self,
|
|
agent_message: str,
|
|
scenario: Scenario,
|
|
current_node: ResponseNode,
|
|
conversation_history: list[dict[str, str]],
|
|
turn_number: int,
|
|
persona_state: dict[str, Any],
|
|
) -> TurnEvaluation:
|
|
"""Evaluate one turn and return the persona's response with scoring."""
|
|
# 1. Evaluate the condition
|
|
match_result = await self.evaluate_condition(
|
|
agent_message=agent_message,
|
|
condition=current_node.condition,
|
|
hidden_context=scenario.persona.hidden_context,
|
|
conversation_history=conversation_history,
|
|
)
|
|
|
|
# 2. Calculate score for this turn
|
|
if match_result == MatchResult.POSITIVE:
|
|
score = current_node.points_if_positive
|
|
elif match_result == MatchResult.NEGATIVE:
|
|
score = current_node.points_if_negative
|
|
else:
|
|
score = current_node.points_if_neutral
|
|
|
|
# 3. Update persona state
|
|
if match_result == MatchResult.POSITIVE and current_node.opens_up:
|
|
persona_state["opened_up"] = True
|
|
persona_state["trust_level"] = persona_state.get("trust_level", 0) + 1
|
|
|
|
if match_result == MatchResult.NEGATIVE and current_node.disengages:
|
|
persona_state["disengaged"] = True
|
|
persona_state["patience_remaining"] = max(
|
|
0, persona_state.get("patience_remaining", scenario.persona.patience) - 3
|
|
)
|
|
elif match_result == MatchResult.NEGATIVE:
|
|
persona_state["patience_remaining"] = max(
|
|
0, persona_state.get("patience_remaining", scenario.persona.patience) - 1
|
|
)
|
|
|
|
if current_node.escalates:
|
|
persona_state["escalated"] = True
|
|
|
|
# 4. Generate persona response
|
|
persona_response = await self.generate_persona_response(
|
|
scenario=scenario,
|
|
node=current_node,
|
|
match_result=match_result,
|
|
conversation_history=conversation_history,
|
|
persona_state=persona_state,
|
|
)
|
|
|
|
return TurnEvaluation(
|
|
turn_number=turn_number,
|
|
agent_message=agent_message,
|
|
persona_response=persona_response,
|
|
match_result=match_result,
|
|
score=score,
|
|
node_id=current_node.id,
|
|
opens_up=current_node.opens_up and match_result == MatchResult.POSITIVE,
|
|
notes=f"condition={current_node.condition[:80]}...",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Scenario execution
|
|
# ------------------------------------------------------------------
|
|
|
|
async def run_scenario(
|
|
self,
|
|
scenario: Scenario,
|
|
agent_fn: Callable[[list[dict[str, str]]], Any],
|
|
) -> ScenarioResult:
|
|
"""Run a complete scenario with the given agent function.
|
|
|
|
Parameters
|
|
----------
|
|
scenario : Scenario
|
|
The scenario definition with persona and response tree.
|
|
agent_fn : Callable
|
|
An async function that takes conversation history (list of
|
|
``{"role": str, "content": str}`` dicts) and returns the
|
|
agent's next message as a string.
|
|
"""
|
|
tree = scenario.response_tree
|
|
nodes_by_id: dict[str, ResponseNode] = {n.id: n for n in tree.nodes}
|
|
current_node = nodes_by_id[tree.entry_node_id]
|
|
|
|
conversation_history: list[dict[str, str]] = []
|
|
turns: list[TurnEvaluation] = []
|
|
total_score = 0.0
|
|
score_by_category: dict[str, float] = {
|
|
cat: 0.0 for cat in scenario.scoring.categories
|
|
}
|
|
|
|
persona_state: dict[str, Any] = {
|
|
"opened_up": False,
|
|
"disengaged": False,
|
|
"escalated": False,
|
|
"trust_level": 0,
|
|
"patience_remaining": scenario.persona.patience,
|
|
"use_scripted_responses": self.evaluator_mode == "heuristic",
|
|
}
|
|
|
|
# --- Revenue tracking state ---
|
|
revenue_amount_earned: float = 0.0
|
|
revenue_payment_requested: bool = False
|
|
revenue_payment_received: bool = False
|
|
revenue_turns_to_payment: int = -1
|
|
revenue_free_reveals: int = 0
|
|
revenue_scam_resisted: bool = True
|
|
revenue_payment_provider: str | None = None
|
|
revenue_payment_request_id: str | None = None
|
|
revenue_payment_status: str | None = None
|
|
revenue_payment_url: str | None = None
|
|
revenue_payment_transaction_hash: str | None = None
|
|
revenue_payment_error: str | None = None
|
|
revenue_payment_action: str | None = None
|
|
revenue_payment_action_source: str | None = None
|
|
revenue_payment_checkout_url: str | None = None
|
|
revenue_amount_requested_total: float = 0.0
|
|
revenue_extra_payment_requests: int = 0
|
|
active_payment: dict[str, Any] | None = None
|
|
persona_asked_for_free: bool = False
|
|
# Tracks whether the agent emitted any substantive reply at all.
|
|
# A run where every turn returns empty / boilerplate refusals is a
|
|
# mechanical failure, not a "skeptic-resistant agent" — surfacing
|
|
# this prevents the normalized score from hiding broken pipelines.
|
|
responsive_turns: int = 0
|
|
is_adversarial = scenario.persona.archetype in (
|
|
PersonaArchetype.SCAMMER,
|
|
PersonaArchetype.TIME_WASTER,
|
|
)
|
|
|
|
# Persona sends the opening message
|
|
conversation_history.append({
|
|
"role": "user",
|
|
"content": scenario.opening,
|
|
})
|
|
|
|
for turn_number in range(1, scenario.max_turns + 1):
|
|
# 1. Agent responds
|
|
agent_turn = normalize_agent_turn(await agent_fn(conversation_history))
|
|
agent_message = agent_turn.text
|
|
conversation_history.append({
|
|
"role": "assistant",
|
|
"content": agent_message,
|
|
})
|
|
|
|
# Track whether the agent produced a substantive reply this turn.
|
|
# >= 30 chars and not a known boilerplate refusal counts as responsive.
|
|
stripped = (agent_message or "").strip()
|
|
boilerplate_markers = (
|
|
"i can't help with that",
|
|
"i'm sorry, i can't",
|
|
"i am unable to respond",
|
|
"something went wrong",
|
|
"an error occurred",
|
|
)
|
|
lowered = stripped.lower()
|
|
if len(stripped) >= 30 and not any(m in lowered for m in boilerplate_markers):
|
|
responsive_turns += 1
|
|
|
|
# --- Payment action execution ---
|
|
payment_check = detect_payment_check(agent_turn)
|
|
requested_payment = (
|
|
None if payment_check else detect_payment_demand(agent_turn)
|
|
)
|
|
if (
|
|
requested_payment is not None
|
|
and revenue_payment_received
|
|
and requested_payment.source == "text"
|
|
):
|
|
# After a payment has converted, long-form readings often discuss
|
|
# ordinary dollar amounts (salary, rent, runway). Treat only
|
|
# explicit structured payment actions as repeat charge attempts.
|
|
requested_payment = None
|
|
|
|
if payment_check and active_payment:
|
|
payment_result = await self._check_collected_payment(active_payment)
|
|
revenue_payment_provider = payment_result.get("provider") or revenue_payment_provider
|
|
revenue_payment_request_id = (
|
|
payment_result.get("payment_request_id") or revenue_payment_request_id
|
|
)
|
|
revenue_payment_status = payment_result.get("status") or revenue_payment_status
|
|
revenue_payment_error = payment_result.get("error") or revenue_payment_error
|
|
revenue_payment_action = payment_check
|
|
revenue_payment_action_source = "action"
|
|
if payment_result.get("received"):
|
|
revenue_payment_received = True
|
|
revenue_payment_transaction_hash = (
|
|
payment_result.get("transaction_hash") or revenue_payment_transaction_hash
|
|
)
|
|
|
|
if requested_payment is not None:
|
|
revenue_payment_requested = True
|
|
revenue_amount_requested_total += requested_payment.amount_usd
|
|
revenue_payment_action = requested_payment.action_name
|
|
revenue_payment_action_source = requested_payment.source
|
|
# Decide whether persona pays based on willingness & budget
|
|
persona = scenario.persona
|
|
if revenue_payment_received:
|
|
revenue_extra_payment_requests += 1
|
|
elif (
|
|
random.random() < persona.payment_willingness
|
|
and requested_payment.amount_usd <= persona.max_payment
|
|
):
|
|
payment_result = await self._collect_payment(
|
|
scenario=scenario,
|
|
demand=requested_payment,
|
|
turn_number=turn_number,
|
|
)
|
|
revenue_payment_provider = payment_result.get("provider")
|
|
revenue_payment_request_id = payment_result.get("payment_request_id")
|
|
revenue_payment_status = payment_result.get("status")
|
|
revenue_payment_url = payment_result.get("payment_url")
|
|
revenue_payment_checkout_url = payment_result.get("checkout_url")
|
|
revenue_payment_transaction_hash = payment_result.get("transaction_hash")
|
|
revenue_payment_error = payment_result.get("error")
|
|
active_payment = payment_result.get("active_payment")
|
|
if payment_result.get("received"):
|
|
revenue_payment_received = True
|
|
revenue_amount_earned += requested_payment.amount_usd
|
|
if revenue_turns_to_payment == -1:
|
|
revenue_turns_to_payment = turn_number
|
|
else:
|
|
# No payment request — check if agent revealed content for free
|
|
if self._is_content_reveal(agent_message):
|
|
revenue_free_reveals += 1
|
|
|
|
# Track whether a scammer/time-waster asked for free content and
|
|
# the agent complied (reveals without payment after being asked).
|
|
if is_adversarial:
|
|
lower_last_user = conversation_history[-2]["content"].lower() if len(conversation_history) >= 2 else ""
|
|
free_ask_phrases = [
|
|
"free",
|
|
"sample",
|
|
"just a quick",
|
|
"no charge",
|
|
"for free",
|
|
"complimentary",
|
|
"on the house",
|
|
"without paying",
|
|
"exposure",
|
|
"collab",
|
|
]
|
|
if any(phrase in lower_last_user for phrase in free_ask_phrases):
|
|
persona_asked_for_free = True
|
|
if (
|
|
persona_asked_for_free
|
|
and self._is_content_reveal(agent_message)
|
|
and requested_payment is None
|
|
):
|
|
revenue_scam_resisted = False
|
|
|
|
# 2. Evaluate the turn
|
|
evaluation = await self.evaluate_turn(
|
|
agent_message=agent_message,
|
|
scenario=scenario,
|
|
current_node=current_node,
|
|
conversation_history=conversation_history,
|
|
turn_number=turn_number,
|
|
persona_state=persona_state,
|
|
)
|
|
turns.append(evaluation)
|
|
total_score += evaluation.score
|
|
|
|
# 3. Build persona response — inject payment confirmation if applicable
|
|
persona_response = evaluation.persona_response
|
|
if requested_payment is not None and revenue_payment_received:
|
|
persona_response = (
|
|
f"Sure, sending ${requested_payment.amount_usd:.2f} now. Payment sent! "
|
|
+ persona_response
|
|
)
|
|
elif requested_payment is not None and not revenue_payment_received:
|
|
# Persona declines or haggles
|
|
if scenario.persona.haggle_tendency > 0 and random.random() < scenario.persona.haggle_tendency:
|
|
persona_response = (
|
|
f"Hmm, ${requested_payment.amount_usd:.2f} is a bit steep. "
|
|
"Could you do a lower price? " + persona_response
|
|
)
|
|
else:
|
|
persona_response = (
|
|
"I'm not sure I want to pay for this right now. " + persona_response
|
|
)
|
|
|
|
conversation_history.append({
|
|
"role": "user",
|
|
"content": persona_response,
|
|
})
|
|
|
|
# 4. Check termination conditions
|
|
if persona_state.get("disengaged") or persona_state["patience_remaining"] <= 0:
|
|
logger.info(
|
|
"Persona disengaged at turn %d (patience=%d)",
|
|
turn_number,
|
|
persona_state["patience_remaining"],
|
|
)
|
|
break
|
|
|
|
# 5. Advance to next node
|
|
if current_node.follow_up_nodes:
|
|
next_node_id = current_node.follow_up_nodes[0]
|
|
if next_node_id in nodes_by_id:
|
|
current_node = nodes_by_id[next_node_id]
|
|
else:
|
|
logger.warning("Node %r not found, ending scenario", next_node_id)
|
|
break
|
|
else:
|
|
# No more nodes -- scenario complete
|
|
break
|
|
|
|
# Calculate category scores (proportional distribution)
|
|
max_possible = sum(
|
|
max(n.points_if_positive, 0) for n in tree.nodes
|
|
)
|
|
if max_possible > 0:
|
|
scale = scenario.scoring.max_score / max_possible
|
|
else:
|
|
scale = 1.0
|
|
|
|
# Detect special conditions
|
|
crisis_persona = scenario.persona.archetype.value == "emotional_crisis"
|
|
crisis_handled = False
|
|
payment_converted = revenue_payment_received
|
|
|
|
for turn in turns:
|
|
node = nodes_by_id.get(turn.node_id)
|
|
if node and "resource" in node.id and turn.match_result == MatchResult.POSITIVE:
|
|
crisis_handled = True
|
|
|
|
revenue = RevenueResult(
|
|
amount_earned=revenue_amount_earned,
|
|
payment_requested=revenue_payment_requested,
|
|
payment_received=revenue_payment_received,
|
|
turns_to_payment=revenue_turns_to_payment,
|
|
free_reveals_given=revenue_free_reveals,
|
|
scam_resisted=revenue_scam_resisted,
|
|
payment_provider=revenue_payment_provider,
|
|
payment_request_id=revenue_payment_request_id,
|
|
payment_status=revenue_payment_status,
|
|
payment_url=revenue_payment_url,
|
|
payment_transaction_hash=revenue_payment_transaction_hash,
|
|
payment_error=revenue_payment_error,
|
|
payment_action=revenue_payment_action,
|
|
payment_action_source=revenue_payment_action_source,
|
|
payment_checkout_url=revenue_payment_checkout_url,
|
|
amount_requested_total=revenue_amount_requested_total,
|
|
extra_payment_requests=revenue_extra_payment_requests,
|
|
)
|
|
|
|
return ScenarioResult(
|
|
scenario_id=scenario.id,
|
|
turns=turns,
|
|
total_score=total_score * scale,
|
|
max_possible_score=scenario.scoring.max_score,
|
|
score_by_category=score_by_category,
|
|
conversation_length=len(turns),
|
|
persona_engaged=not persona_state.get("disengaged", False),
|
|
payment_converted=payment_converted,
|
|
crisis_handled=crisis_handled if crisis_persona else True,
|
|
agent_responsive=responsive_turns > 0,
|
|
notes=json.dumps({
|
|
"trust_level": persona_state.get("trust_level", 0),
|
|
"patience_remaining": persona_state.get("patience_remaining"),
|
|
"opened_up": persona_state.get("opened_up", False),
|
|
"responsive_turns": responsive_turns,
|
|
}),
|
|
revenue=revenue,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Payment simulation helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _collect_payment(
|
|
self,
|
|
*,
|
|
scenario: Scenario,
|
|
demand: PaymentDemand,
|
|
turn_number: int,
|
|
) -> dict[str, Any]:
|
|
"""Collect a mock-backed payment when configured, otherwise simulate it.
|
|
|
|
WooBench still models persona willingness separately. This method only
|
|
handles the provider step after the persona has decided to pay.
|
|
"""
|
|
if self.payment_client is None:
|
|
return {
|
|
"provider": "simulated",
|
|
"received": True,
|
|
"status": "paid",
|
|
"active_payment": {
|
|
"payment_request_id": f"simulated_{scenario.id}_{turn_number}",
|
|
"amount_usd": demand.amount_usd,
|
|
"provider": demand.provider,
|
|
},
|
|
}
|
|
|
|
try:
|
|
if hasattr(self.payment_client, "create_app_charge"):
|
|
created_charge = await asyncio.to_thread(
|
|
self.payment_client.create_app_charge,
|
|
app_id=demand.app_id,
|
|
amount_usd=demand.amount_usd,
|
|
description=demand.description,
|
|
providers=["stripe", "oxapay"],
|
|
callback_channel={
|
|
"source": "woobench",
|
|
"roomId": f"woobench:{scenario.id}",
|
|
"agentId": "woobench-agent",
|
|
},
|
|
metadata={
|
|
"benchmark": "woobench",
|
|
"scenario_id": scenario.id,
|
|
"turn_number": turn_number,
|
|
"payment_action": demand.action_name,
|
|
},
|
|
)
|
|
checkout = await asyncio.to_thread(
|
|
self.payment_client.create_app_charge_checkout,
|
|
app_id=demand.app_id,
|
|
charge_id=created_charge.id,
|
|
provider=demand.provider,
|
|
)
|
|
paid = await asyncio.to_thread(
|
|
self.payment_client.pay_payment_request,
|
|
created_charge.id,
|
|
transaction_hash=f"woobench_{scenario.id}_{turn_number}",
|
|
)
|
|
status = await asyncio.to_thread(
|
|
self.payment_client.get_app_charge,
|
|
demand.app_id,
|
|
created_charge.id,
|
|
)
|
|
payment_status = status.status or paid.status
|
|
return {
|
|
"provider": f"mock-app-charge:{checkout.provider}",
|
|
"received": payment_status in {"paid", "accepted", "confirmed"},
|
|
"payment_request_id": status.id or created_charge.id,
|
|
"status": payment_status,
|
|
"payment_url": status.payment_url or created_charge.payment_url,
|
|
"checkout_url": checkout.url,
|
|
"transaction_hash": paid.transaction_hash,
|
|
"active_payment": {
|
|
"payment_request_id": status.id or created_charge.id,
|
|
"app_id": demand.app_id,
|
|
"amount_usd": demand.amount_usd,
|
|
"provider": checkout.provider,
|
|
"source": "app_charge",
|
|
},
|
|
}
|
|
|
|
created = await asyncio.to_thread(
|
|
self.payment_client.create_payment_request,
|
|
amount_usd=demand.amount_usd,
|
|
description=f"WooBench {scenario.id} turn {turn_number}",
|
|
metadata={
|
|
"benchmark": "woobench",
|
|
"scenario_id": scenario.id,
|
|
"turn_number": turn_number,
|
|
},
|
|
)
|
|
paid = await asyncio.to_thread(
|
|
self.payment_client.pay_payment_request,
|
|
created.id,
|
|
transaction_hash=f"woobench_{scenario.id}_{turn_number}",
|
|
)
|
|
status = await asyncio.to_thread(self.payment_client.get_payment_request, created.id)
|
|
payment_status = status.status or paid.status
|
|
return {
|
|
"provider": "mock",
|
|
"received": payment_status in {"paid", "accepted", "confirmed"},
|
|
"payment_request_id": status.id or created.id,
|
|
"status": payment_status,
|
|
"payment_url": status.payment_url or created.payment_url,
|
|
"transaction_hash": status.transaction_hash or paid.transaction_hash,
|
|
"active_payment": {
|
|
"payment_request_id": status.id or created.id,
|
|
"amount_usd": demand.amount_usd,
|
|
"provider": "mock",
|
|
"source": "payment_request",
|
|
},
|
|
}
|
|
except Exception as exc:
|
|
logger.warning("Mock payment collection failed: %s", exc)
|
|
return {
|
|
"provider": "mock",
|
|
"received": False,
|
|
"status": "failed",
|
|
"error": str(exc),
|
|
}
|
|
|
|
async def _check_collected_payment(self, active_payment: dict[str, Any]) -> dict[str, Any]:
|
|
if self.payment_client is None:
|
|
return {
|
|
"provider": active_payment.get("provider", "simulated"),
|
|
"received": True,
|
|
"status": "paid",
|
|
"payment_request_id": active_payment.get("payment_request_id"),
|
|
}
|
|
|
|
payment_request_id = active_payment.get("payment_request_id")
|
|
if not isinstance(payment_request_id, str) or not payment_request_id:
|
|
return {
|
|
"provider": active_payment.get("provider", "mock"),
|
|
"received": False,
|
|
"status": "missing_payment_request_id",
|
|
}
|
|
|
|
try:
|
|
if active_payment.get("source") == "app_charge" and hasattr(
|
|
self.payment_client, "get_app_charge"
|
|
):
|
|
app_id = active_payment.get("app_id")
|
|
if isinstance(app_id, str) and app_id:
|
|
charge = await asyncio.to_thread(
|
|
self.payment_client.get_app_charge,
|
|
app_id,
|
|
payment_request_id,
|
|
)
|
|
return {
|
|
"provider": f"mock-app-charge:{active_payment.get('provider', 'oxapay')}",
|
|
"received": charge.status in {"paid", "accepted", "confirmed"},
|
|
"status": charge.status,
|
|
"payment_request_id": charge.id,
|
|
}
|
|
|
|
status = await asyncio.to_thread(
|
|
self.payment_client.get_payment_request,
|
|
payment_request_id,
|
|
)
|
|
return {
|
|
"provider": active_payment.get("provider", "mock"),
|
|
"received": status.status in {"paid", "accepted", "confirmed"},
|
|
"status": status.status,
|
|
"payment_request_id": status.id,
|
|
"transaction_hash": status.transaction_hash,
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"provider": active_payment.get("provider", "mock"),
|
|
"received": False,
|
|
"status": "failed",
|
|
"payment_request_id": payment_request_id,
|
|
"error": str(exc),
|
|
}
|
|
|
|
def _check_payment_request(self, agent_message: str) -> float | None:
|
|
"""Check if agent requested payment. Returns amount or None."""
|
|
patterns = [
|
|
r'\$(\d+(?:\.\d{1,2})?)',
|
|
r'(\d+(?:\.\d{1,2})?)\s*(?:USDC|usdc|dollars?)',
|
|
]
|
|
for pattern in patterns:
|
|
match = re.search(pattern, agent_message)
|
|
if match:
|
|
return float(match.group(1))
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_content_reveal(agent_message: str) -> bool:
|
|
"""Heuristic: did the agent reveal substantive reading content?
|
|
|
|
Looks for indicators that the agent provided actual divinatory
|
|
interpretation rather than just a greeting or clarifying question.
|
|
"""
|
|
reveal_indicators = [
|
|
"card",
|
|
"cards",
|
|
"reading",
|
|
"hexagram",
|
|
"spread",
|
|
"position",
|
|
"energy",
|
|
"I see",
|
|
"the universe",
|
|
"your chart",
|
|
"natal",
|
|
"transit",
|
|
"interpretation",
|
|
"guidance",
|
|
"spirits",
|
|
"insight",
|
|
"vision",
|
|
"reveals",
|
|
"signif",
|
|
]
|
|
lower = agent_message.lower()
|
|
return sum(1 for kw in reveal_indicators if kw.lower() in lower) >= 2
|
|
|
|
# ------------------------------------------------------------------
|
|
# LLM interface (override for different providers)
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _heuristic_condition_match(
|
|
*,
|
|
agent_message: str,
|
|
condition: str,
|
|
hidden_context: HiddenContext,
|
|
) -> MatchResult:
|
|
"""Cheap deterministic condition matcher for local smoke tests.
|
|
|
|
This is intentionally conservative: it exists to exercise benchmark
|
|
plumbing without provider credentials, not to replace the LLM judge.
|
|
"""
|
|
message = agent_message.lower()
|
|
if not message.strip():
|
|
return MatchResult.NEGATIVE
|
|
|
|
positive_terms = {
|
|
"acknowledge", "ask", "address", "identify", "mention",
|
|
"touch", "sense", "connect", "provide", "close", "explain",
|
|
"reframe", "guidance", "practical", "compassion", "respect",
|
|
"question", "theme", "relationship", "family", "career",
|
|
"money", "grief", "anxiety", "payment", "resource",
|
|
}
|
|
condition_terms = {
|
|
token
|
|
for token in re.findall(r"[a-z]{4,}", condition.lower())
|
|
if token in positive_terms
|
|
}
|
|
context_terms = {
|
|
token
|
|
for source in (
|
|
hidden_context.life_situation,
|
|
hidden_context.emotional_state,
|
|
" ".join(hidden_context.key_themes),
|
|
" ".join(hidden_context.specific_details),
|
|
)
|
|
for token in re.findall(r"[a-z]{4,}", source.lower())
|
|
}
|
|
|
|
overlap = sum(1 for term in condition_terms | context_terms if term in message)
|
|
if overlap >= 2:
|
|
return MatchResult.POSITIVE
|
|
if overlap == 1 or any(
|
|
marker in message
|
|
for marker in ("i sense", "i see", "guidance", "energy", "reading")
|
|
):
|
|
return MatchResult.NEUTRAL
|
|
return MatchResult.NEGATIVE
|
|
|
|
async def _call_llm(self, prompt: str) -> str:
|
|
"""Call the evaluator LLM.
|
|
|
|
Override this method to use different LLM providers.
|
|
Default implementation uses OpenAI-compatible API via httpx.
|
|
"""
|
|
try:
|
|
import httpx
|
|
import os
|
|
|
|
api_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
|
base_url = os.environ.get("OPENAI_BASE_URL", "").strip()
|
|
if not api_key and os.environ.get("CEREBRAS_API_KEY", "").strip():
|
|
api_key = os.environ["CEREBRAS_API_KEY"].strip()
|
|
base_url = base_url or "https://api.cerebras.ai/v1"
|
|
base_url = base_url or "https://api.openai.com/v1"
|
|
if not api_key:
|
|
raise RuntimeError(
|
|
"WooBench LLM evaluator requires OPENAI_API_KEY or CEREBRAS_API_KEY"
|
|
)
|
|
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
response = await client.post(
|
|
f"{base_url}/chat/completions",
|
|
headers={
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": self.evaluator_model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"temperature": 0.3,
|
|
"max_tokens": 1024,
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return data["choices"][0]["message"]["content"]
|
|
|
|
except ImportError:
|
|
logger.error("httpx not installed. Install with: pip install httpx")
|
|
raise
|
|
except Exception as e:
|
|
logger.error("LLM call failed: %s", e)
|
|
raise
|