Add app credentials, account-scoped idempotency and per-app terminal notifications. Consolidate the unreleased migration baseline, preserve rule descriptions as AI context, and regenerate provider contracts with regression coverage.
178 lines
6.3 KiB
Python
178 lines
6.3 KiB
Python
import json
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from ai_platform.lead_scoring.application.repository import AIProfileRecord
|
|
from ai_platform.lead_scoring.application.scoring_execution import ScoringExecutionUseCases
|
|
from ai_platform.lead_scoring.application.scoring_execution_repository import (
|
|
Evidence,
|
|
Scoring,
|
|
ScoringExecutionRun,
|
|
ScoringResult,
|
|
)
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
|
|
|
|
|
|
class RunRepository:
|
|
def __init__(self, run: ScoringExecutionRun, profile: AIProfileRecord):
|
|
self.run = run
|
|
self.profile = profile
|
|
self.result: ScoringResult | None = None
|
|
|
|
async def find_run(self, run_id: str):
|
|
return self.run
|
|
|
|
async def find_profile(self, version: str):
|
|
assert version == self.run.profile_version
|
|
return self.profile
|
|
|
|
async def mark_running(self, run_id: str):
|
|
pass
|
|
|
|
async def complete(self, run_id: str, result: ScoringResult, completed_at: datetime):
|
|
self.result = result
|
|
|
|
async def fail(self, run_id: str, completed_at: datetime, error_code: str):
|
|
pytest.fail(f"execution failed: {error_code}")
|
|
|
|
async def mark_interrupted(self, completed_at: datetime):
|
|
pytest.fail("unexpected recovery")
|
|
|
|
|
|
class StubProvider:
|
|
def __init__(self):
|
|
self.models: list[str] = []
|
|
self.keys: list[str] = []
|
|
self.user_prompts: list[str] = []
|
|
self.system_prompts: list[str] = []
|
|
self.snapshots: list[dict] = []
|
|
|
|
async def extract_evidence(self, *, model, snapshot, vkey, timeout):
|
|
self.models.append(model)
|
|
self.keys.append(vkey)
|
|
self.snapshots.append(snapshot)
|
|
return [Evidence("interest", "Asked about enrollment", "mock-message-001")]
|
|
|
|
async def score(self, *, model, system_prompt, user_prompt, vkey, timeout):
|
|
self.models.append(model)
|
|
self.keys.append(vkey)
|
|
self.user_prompts.append(user_prompt)
|
|
self.system_prompts.append(system_prompt)
|
|
return Scoring(80, 0.8, "Evidence supports intent")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execution_uses_frozen_profile_provider_and_never_forwards_vkey_to_openai():
|
|
payload = json.loads(FIXTURE.read_text())
|
|
payload["ai_profile"] = "medium"
|
|
payload["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
run = ScoringExecutionRun("run-1", "pending", payload, "old-profile")
|
|
profile = AIProfileRecord(
|
|
"old-profile",
|
|
"gemini-3.5-flash-lite",
|
|
"prompt",
|
|
30,
|
|
{},
|
|
False,
|
|
"openai_compatible",
|
|
"http://127.0.0.1:8318",
|
|
)
|
|
repository = RunRepository(run, profile)
|
|
old_provider = StubProvider()
|
|
active_aihub_provider = StubProvider()
|
|
|
|
async def no_op():
|
|
pass
|
|
|
|
selected_urls: list[str] = []
|
|
|
|
def provider_for_url(url: str) -> StubProvider:
|
|
selected_urls.append(url)
|
|
return old_provider
|
|
|
|
cases = ScoringExecutionUseCases(
|
|
repository,
|
|
{"aihub": active_aihub_provider},
|
|
no_op,
|
|
no_op,
|
|
lambda: datetime(2026, 9, 28, tzinfo=UTC),
|
|
provider_for_url,
|
|
)
|
|
await cases.execute(run.id, "caller-vkey-should-not-reach-openai")
|
|
assert repository.result is not None and repository.result.result_mode == "hybrid"
|
|
assert selected_urls == ["http://127.0.0.1:8318"]
|
|
assert old_provider.models == ["gemini-3.5-flash-lite"] * 2
|
|
assert old_provider.keys == ["", ""]
|
|
assert active_aihub_provider.models == []
|
|
|
|
|
|
@pytest.mark.parametrize("provider_name", ["aihub", "openai_compatible"])
|
|
@pytest.mark.parametrize("description", [None, "", " ", "Khách chủ động hỏi điều kiện đăng ký"])
|
|
async def test_execution_uses_descriptions_only_as_supplemental_scoring_context(
|
|
provider_name, description
|
|
):
|
|
payload = json.loads(FIXTURE.read_text())
|
|
payload["ai_profile"] = "medium"
|
|
payload["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
payload["scoring_config"]["rules"][0].update(id="private-rule-id", description=description)
|
|
run = ScoringExecutionRun("run-1", "pending", payload, "profile-v1")
|
|
profile = AIProfileRecord("profile-v1", "model", "prompt", 30, {}, True, provider_name)
|
|
repository = RunRepository(run, profile)
|
|
provider = StubProvider()
|
|
|
|
async def no_op():
|
|
pass
|
|
|
|
cases = ScoringExecutionUseCases(
|
|
repository,
|
|
{provider_name: provider},
|
|
no_op,
|
|
no_op,
|
|
lambda: datetime(2026, 9, 28, tzinfo=UTC),
|
|
)
|
|
await cases.execute(run.id, "caller-vkey")
|
|
|
|
assert repository.result is not None and repository.result.result_mode == "hybrid"
|
|
assert provider.snapshots == [payload["snapshot"]]
|
|
assert len(provider.user_prompts) == 1
|
|
prompt = provider.user_prompts[0]
|
|
if description and description.strip():
|
|
assert json.loads(prompt.split("\nRULE_DESCRIPTIONS:\n", 1)[1]) == [description]
|
|
assert description not in provider.system_prompts[0]
|
|
else:
|
|
assert "RULE_DESCRIPTIONS:" not in prompt
|
|
for excluded in ('"points"', '"rule_weight"', '"ai_weight"', "private-rule-id", "caller-vkey"):
|
|
assert excluded not in prompt
|
|
|
|
|
|
@pytest.mark.parametrize("vkey", ["private-vkey", 'private"vkey', "private\\vkey"])
|
|
@pytest.mark.parametrize("location", ["description", "snapshot"])
|
|
async def test_vkey_in_prompt_data_falls_back_before_calling_ai(vkey, location):
|
|
payload = json.loads(FIXTURE.read_text())
|
|
payload["ai_profile"] = "medium"
|
|
payload["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
if location == "description":
|
|
payload["scoring_config"]["rules"][0]["description"] = vkey
|
|
else:
|
|
payload["snapshot"]["lead_profile"]["name"] = vkey
|
|
run = ScoringExecutionRun("run-1", "pending", payload, "profile-v1")
|
|
profile = AIProfileRecord("profile-v1", "model", "prompt", 30, {}, True)
|
|
repository = RunRepository(run, profile)
|
|
provider = StubProvider()
|
|
|
|
async def no_op():
|
|
pass
|
|
|
|
cases = ScoringExecutionUseCases(
|
|
repository, {"aihub": provider}, no_op, no_op, lambda: datetime(2026, 9, 28, tzinfo=UTC)
|
|
)
|
|
await cases.execute(run.id, vkey)
|
|
|
|
assert repository.result is not None
|
|
assert repository.result.error_code == "vkey_prompt_conflict"
|
|
assert repository.result.result_mode == "rules_only_fallback"
|
|
assert provider.models == []
|