test_ai.py 4.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. """外部协议使用 HTTP MockTransport 验证;这些用例不代表已验证真实模型识别精度。"""
  2. import time
  3. import httpx
  4. import pytest
  5. from sqlalchemy import select
  6. from app.domain.models import CacheEntry
  7. from app.infrastructure.ai import ai, AIClient, AIUnavailable, CircuitBreaker, parse_structured
  8. from app.infrastructure.database import Session
  9. from app.shared.config import settings
  10. from app.shared.schemas import RiskAssessment
  11. def test_structured_fences_and_invalid_values():
  12. raw = '模型说明\n```json\n{"risk_level":2,"risk_score":0.6,"risk_factors":["测试"],"reasoning":"明确测试依据"}\n```'
  13. assert parse_structured(raw, RiskAssessment).risk_level == 2
  14. with pytest.raises(AIUnavailable):
  15. parse_structured('{"risk_level":0,"risk_score":99}', RiskAssessment)
  16. def test_http_schema_key_and_cache(client, monkeypatch):
  17. monkeypatch.setattr(settings, 'llm_base_url', 'http://contract.test/v1')
  18. monkeypatch.setattr(settings, 'llm_model', 'contract-risk')
  19. monkeypatch.setattr(settings, 'ai_api_key', 'contract-key')
  20. calls = []
  21. def respond(request):
  22. calls.append(request)
  23. assert request.headers['Authorization'] == 'Bearer contract-key'
  24. return httpx.Response(200, json={'choices': [{'message': {'content': '{"risk_level":1,"risk_score":0.1,"risk_factors":[],"reasoning":"测试依据"}'}}], 'usage': {'total_tokens': 12}})
  25. async def exercise():
  26. model = AIClient()
  27. await model.http.aclose()
  28. model.http = httpx.AsyncClient(transport=httpx.MockTransport(respond))
  29. first, usage = await model.structured('测试请求', RiskAssessment)
  30. second, hit = await model.structured('测试请求', RiskAssessment)
  31. assert first == second and usage['total_tokens'] == 12 and hit['cache_hit'] is True
  32. async with Session() as session:
  33. assert await session.scalar(select(CacheEntry)) is not None
  34. await model.close()
  35. client.portal.call(exercise)
  36. assert len(calls) == 1
  37. def test_real_models_endpoint_uses_configured_key(client, headers, monkeypatch):
  38. monkeypatch.setattr(settings, 'vlm_base_url', 'http://contract.test/v1')
  39. monkeypatch.setattr(settings, 'vlm_model', 'test-vision')
  40. monkeypatch.setattr(settings, 'ai_api_key', 'contract-key')
  41. def respond(request):
  42. assert request.headers['Authorization'] == 'Bearer contract-key'
  43. return httpx.Response(200, json={'data': [{'id': 'test-vision'}]})
  44. old = ai.http
  45. monkeypatch.setattr(ai, 'http', httpx.AsyncClient(transport=httpx.MockTransport(respond)))
  46. try:
  47. response = client.post('/api/v1/agents/extract/test', headers=headers['carol'])
  48. assert response.status_code == 200 and response.json()['data']['status'] == 'success'
  49. finally:
  50. client.portal.call(ai.http.aclose)
  51. monkeypatch.setattr(ai, 'http', old)
  52. def test_embeddings_reject_fake_shape_and_fallback(client, monkeypatch):
  53. monkeypatch.setattr(settings, 'embedding_base_url', 'http://broken-embed.test')
  54. monkeypatch.setattr(settings, 'llm_base_url', 'http://fallback.test/v1')
  55. requests = []
  56. def respond(request):
  57. requests.append(request)
  58. if request.url.host == 'broken-embed.test':
  59. return httpx.Response(503)
  60. return httpx.Response(200, json={'data': [{'index': 0, 'embedding': [1.0] + [0.0] * 1023}]})
  61. async def exercise():
  62. model = AIClient(); await model.http.aclose()
  63. model.http = httpx.AsyncClient(transport=httpx.MockTransport(respond))
  64. assert len((await model.embeddings(['真实协议测试']))[0]) == 1024
  65. await model.close()
  66. model.http = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(200, json={'vectors': [[0.1, 0.2]]})))
  67. with pytest.raises(AIUnavailable):
  68. await model.embeddings(['维度非法测试'])
  69. await model.close()
  70. client.portal.call(exercise)
  71. assert len(requests) == 2
  72. def test_circuit_half_open_single_probe(client, monkeypatch):
  73. monkeypatch.setattr(settings, 'ai_failure_threshold', 2)
  74. monkeypatch.setattr(settings, 'ai_recovery_seconds', 1)
  75. async def exercise():
  76. breaker = CircuitBreaker()
  77. await breaker.enter(); await breaker.finish(False)
  78. await breaker.enter(); await breaker.finish(False)
  79. assert breaker.status() == 'open'
  80. with pytest.raises(AIUnavailable):
  81. await breaker.enter()
  82. breaker.opened_at = time.monotonic() - 2
  83. await breaker.enter()
  84. assert breaker.status() == 'half_open'
  85. with pytest.raises(AIUnavailable):
  86. await breaker.enter()
  87. await breaker.finish(True)
  88. assert breaker.status() == 'closed'
  89. client.portal.call(exercise)