From d7e475331dbddccc9a6f5c79ae4c4ea763f49589 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Sun, 5 Apr 2026 02:43:32 -0600 Subject: [PATCH] Add promotion gate for tier-2 regulars --- backend/services/regulars_v2.py | 50 +++++++++++++++++++++++++++++++++ tests/test_regulars_v2.py | 24 ++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/backend/services/regulars_v2.py b/backend/services/regulars_v2.py index c2a6625..b84c1cf 100644 --- a/backend/services/regulars_v2.py +++ b/backend/services/regulars_v2.py @@ -1,7 +1,13 @@ from dataclasses import dataclass from pathlib import Path +import json import re +import httpx + +from ..config import settings +from .cost_tracker import cost_tracker + HOME = Path.home() VAULT = HOME / "code" / "dotfiles" SILAS_DIR = VAULT / "silas" @@ -49,3 +55,47 @@ def load_all_active_regulars() -> list[Regular]: for f in REGULARS_DIR.glob("*.md"): out.append(load_regular(f)) return out + + +PROMOTION_MODEL = "anthropic/claude-sonnet-4.6" + +PROMOTION_PROMPT = """You are evaluating whether a one-time caller should become a recurring character. + +CALLER: {name} +TRANSCRIPT: +{transcript} + +A recurring character must have a 3-5 episode arc with genuine progression — not just "calls weekly to complain about the same thing." The arc must have a possible resolution. + +Output JSON: +{{"promote": true|false, "arc_plan": "...", "reason": "..."}} + +Bar is HIGH. Only promote if the character has real internal conflict, growth potential, and a believable resolution trajectory.""" + + +async def _call_sonnet(prompt: str) -> dict: + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {settings.openrouter_api_key}"}, + json={ + "model": PROMOTION_MODEL, + "messages": [{"role": "user", "content": prompt}], + "response_format": {"type": "json_object"}, + "max_tokens": 500, + }, + ) + resp.raise_for_status() + data = resp.json() + usage = data.get("usage", {}) + cost_tracker.record_llm_call( + category="promotion_eval", + model=PROMOTION_MODEL, + usage_data=usage, + ) + return json.loads(data["choices"][0]["message"]["content"]) + + +async def evaluate_promotion(caller_name: str, call_transcript: str) -> dict: + prompt = PROMOTION_PROMPT.format(name=caller_name, transcript=call_transcript) + return await _call_sonnet(prompt) diff --git a/tests/test_regulars_v2.py b/tests/test_regulars_v2.py index b8b496b..2b1891c 100644 --- a/tests/test_regulars_v2.py +++ b/tests/test_regulars_v2.py @@ -1,3 +1,6 @@ +import asyncio +from unittest.mock import AsyncMock, patch + from backend.services.regulars_v2 import Regular, load_regular, REGULARS_DIR, SILAS_DIR @@ -25,3 +28,24 @@ Silas runs a small desert cult outside Truth or Consequences... assert reg.age == 54 assert "splintering" in reg.arc_state assert "Silas runs a small desert cult" in reg.lore_body + + +def test_evaluate_promotion_returns_arc_plan_when_worthy(): + from backend.services.regulars_v2 import evaluate_promotion + fake_response = { + "promote": True, + "arc_plan": "3 episodes. He'll start distant, then reveal he's actually the one who damaged the car, then resolve with an apology.", + "reason": "Has clear internal conflict with room to grow", + } + with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)): + result = asyncio.run(evaluate_promotion(caller_name="Bobby", call_transcript="...")) + assert result["promote"] is True + assert "3 episodes" in result["arc_plan"] + + +def test_evaluate_promotion_rejects_when_no_arc(): + from backend.services.regulars_v2 import evaluate_promotion + fake_response = {"promote": False, "arc_plan": None, "reason": "One-note complaint, no growth"} + with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)): + result = asyncio.run(evaluate_promotion(caller_name="Carl", call_transcript="...")) + assert result["promote"] is False