Phase 2 and 3 of the platform rework. - Neutral request/response contract: external_id, metadata, X-AI-Key, run_id in the response; client-specific fields removed. - AI profile is a revision table: saving creates an inactive revision, activation is separate, try-run does not store a run. One chat-completions gateway serves AIHub and OpenAI-compatible profiles. - Add between and in rule operators. - Contracts moved to contracts/lead-scoring/ with a score-response fixture. - Admin Web updated for revisions, runs and the neutral fixture. - Consolidate tests; remove rewrite_architect.md and the stale handoff doc; rewrite spec, CONTEXT and ARCHITECTURE for the current design. Not verified: live AIHub/OpenAI behavior, Omni/EMS integration.
105 lines
3.7 KiB
Python
105 lines
3.7 KiB
Python
import asyncio
|
|
from datetime import UTC, datetime
|
|
from typing import cast
|
|
|
|
import pytest
|
|
|
|
from ai_platform.lead_scoring.application.ai_provider import (
|
|
AIProviderFailure,
|
|
Assessment,
|
|
ScoringAIProvider,
|
|
Signal,
|
|
)
|
|
from ai_platform.lead_scoring.application.repository import AIProfileRecord
|
|
from ai_platform.lead_scoring.application.scoring_execution import evaluate
|
|
|
|
|
|
def _profile(**overrides) -> AIProfileRecord:
|
|
fields = dict(
|
|
provider="aihub",
|
|
base_url="http://localhost/v1",
|
|
model="m",
|
|
timeout_seconds=5,
|
|
reasoning_effort="low",
|
|
instructions="Rubric riêng.",
|
|
include_reference_date=False,
|
|
timezone="Asia/Ho_Chi_Minh",
|
|
suggested_action_enabled=True,
|
|
revision=1,
|
|
active=True,
|
|
created_by="a@example.invalid",
|
|
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
)
|
|
return AIProfileRecord(**{**fields, **overrides})
|
|
|
|
|
|
class Stub:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
async def assess(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return Assessment([Signal("positive", "s", "d", "message-789")], 80, "Tóm tắt", "Gọi lại")
|
|
|
|
|
|
def _request(canonical_request, **rule) -> dict:
|
|
canonical_request["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
canonical_request["scoring_config"]["rules"][0].update(rule)
|
|
return canonical_request
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("description", [None, " ", "Khách chủ động hỏi điều kiện đăng ký"])
|
|
async def test_prompt_carries_profile_instructions_and_only_rule_descriptions(
|
|
canonical_request, description
|
|
):
|
|
stub = Stub()
|
|
request = _request(canonical_request, id="private-rule-id", description=description)
|
|
result = await evaluate(request, _profile(), "key", lambda p, k: stub)
|
|
|
|
call = stub.calls[0]
|
|
assert result.result_mode == "hybrid" and call["reasoning_effort"] == "low"
|
|
assert call["system_prompt"].startswith("Rubric riêng.")
|
|
has_description = bool(description and description.strip())
|
|
assert bool(description and description in call["user_prompt"]) is has_description
|
|
for excluded in ('"points"', '"rule_weight"', "private-rule-id", "key"):
|
|
assert excluded not in call["user_prompt"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_profile_switches_control_reference_date_and_suggested_action(canonical_request):
|
|
stub = Stub()
|
|
request = _request(canonical_request)
|
|
on = _profile(include_reference_date=True)
|
|
off = _profile(suggested_action_enabled=False)
|
|
|
|
dated = await evaluate(request, on, "key", lambda p, k: stub)
|
|
quiet = await evaluate(request, off, "key", lambda p, k: stub)
|
|
|
|
assert (
|
|
"Thời điểm chấm: Thứ Năm, 25/09/2025 17:00 (Asia/Ho_Chi_Minh)"
|
|
in stub.calls[0]["user_prompt"]
|
|
)
|
|
assert "Thời điểm chấm" not in stub.calls[1]["user_prompt"]
|
|
assert (dated.ai or {})["suggested_action"] == "Gọi lại"
|
|
assert (quiet.ai or {})["suggested_action"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_factory_failure_and_deadline_degrade_to_rule_score(canonical_request):
|
|
class Hanging:
|
|
async def assess(self, **_: object):
|
|
await asyncio.sleep(10)
|
|
|
|
def unsafe(profile, key):
|
|
raise AIProviderFailure("ai_url_not_allowed")
|
|
|
|
request = _request(canonical_request)
|
|
bad_url = await evaluate(request, _profile(), "key", unsafe)
|
|
slow = await evaluate(
|
|
request, _profile(timeout_seconds=1), "key", lambda p, k: cast(ScoringAIProvider, Hanging())
|
|
)
|
|
assert bad_url.error_code == "ai_url_not_allowed"
|
|
assert (slow.result_mode, slow.error_code) == ("rules_only_fallback", "ai_timeout")
|
|
assert slow.final_score == slow.rule_score
|