POST /v1/lead-scoring/score now evaluates in the request, applies an overall AI deadline, and stores the completed run only after scoring. Idempotency is kept by (app_id, account_id, request_id) with a body fingerprint. Removes the webhook outbox, scheduler/recovery loop, app lead_scoring permission, credential audit events, seed_demo and generate_secret scripts, and the tests that covered them. Trims tests to behavior of the new flow.
94 lines
3.4 KiB
Python
94 lines
3.4 KiB
Python
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
from ai_platform.lead_scoring.application.ai_provider import (
|
|
AIProviderFailure,
|
|
Evidence,
|
|
Scoring,
|
|
ScoringAIProvider,
|
|
)
|
|
from ai_platform.lead_scoring.application.repository import AIProfileRecord
|
|
from ai_platform.lead_scoring.application.scoring_execution import evaluate
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
|
|
|
|
|
|
class StubProvider:
|
|
def __init__(self):
|
|
self.calls: list[dict] = []
|
|
|
|
async def extract_evidence(self, *, model, snapshot, vkey, timeout):
|
|
self.calls.append({"model": model, "vkey": vkey, "snapshot": snapshot})
|
|
return [Evidence("interest", "Asked about enrollment", "mock-message-001")]
|
|
|
|
async def score(self, *, model, system_prompt, user_prompt, vkey, timeout):
|
|
self.calls.append(
|
|
{"model": model, "vkey": vkey, "system": system_prompt, "user": user_prompt}
|
|
)
|
|
return Scoring(80, 0.8, "Evidence supports intent")
|
|
|
|
|
|
def _request(**rule_overrides) -> dict:
|
|
payload = json.loads(FIXTURE.read_text())
|
|
payload["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
payload["scoring_config"]["rules"][0].update(rule_overrides)
|
|
return payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_compatible_profile_uses_its_own_url_and_never_receives_the_ai_key():
|
|
provider, aihub = StubProvider(), StubProvider()
|
|
profile = AIProfileRecord(
|
|
"p", "local-model", "prompt", 30, {}, True, "openai_compatible", "http://127.0.0.1:8318"
|
|
)
|
|
urls: list[str] = []
|
|
|
|
def provider_for_url(url: str) -> StubProvider:
|
|
urls.append(url)
|
|
return provider
|
|
|
|
result = await evaluate(_request(), profile, "caller-key", {"aihub": aihub}, provider_for_url)
|
|
assert result.result_mode == "hybrid"
|
|
assert urls == ["http://127.0.0.1:8318"]
|
|
assert [c["vkey"] for c in provider.calls] == ["", ""]
|
|
assert not aihub.calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("description", [None, " ", "Khách chủ động hỏi điều kiện đăng ký"])
|
|
async def test_rule_descriptions_are_prompt_context_and_nothing_else_about_rules_leaks(description):
|
|
provider = StubProvider()
|
|
profile = AIProfileRecord("p", "model", "prompt", 30, {}, True)
|
|
request = _request(id="private-rule-id", description=description)
|
|
await evaluate(request, profile, "caller-key", {"aihub": provider}, None)
|
|
|
|
prompt = provider.calls[1]["user"]
|
|
if description and description.strip():
|
|
assert json.loads(prompt.split("\nRULE_DESCRIPTIONS:\n", 1)[1]) == [description]
|
|
assert description not in provider.calls[1]["system"]
|
|
else:
|
|
assert "RULE_DESCRIPTIONS:" not in prompt
|
|
for excluded in ('"points"', '"rule_weight"', "private-rule-id", "caller-key"):
|
|
assert excluded not in prompt
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_deadline_degrades_to_rule_score():
|
|
class Hanging:
|
|
async def extract_evidence(self, **_: object):
|
|
await asyncio.sleep(10)
|
|
|
|
async def score(self, **_: object):
|
|
raise AIProviderFailure("unreachable")
|
|
|
|
profile = AIProfileRecord("p", "m", "prompt", 1, {}, True)
|
|
result = await evaluate(
|
|
_request(), profile, "key", {"aihub": cast(ScoringAIProvider, Hanging())}, None
|
|
)
|
|
assert (result.result_mode, result.error_code) == ("rules_only_fallback", "ai_timeout")
|
|
assert result.final_score == result.rule_score
|