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 == []