ioc-scope-mce-core 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. ioc_scope_mce_core-2.0.0.dist-info/METADATA +47 -0
  2. ioc_scope_mce_core-2.0.0.dist-info/RECORD +74 -0
  3. ioc_scope_mce_core-2.0.0.dist-info/WHEEL +5 -0
  4. ioc_scope_mce_core-2.0.0.dist-info/top_level.txt +1 -0
  5. mce/__init__.py +27 -0
  6. mce/core/catalog.py +208 -0
  7. mce/core/config.py +33 -0
  8. mce/core/helpers.py +184 -0
  9. mce/core/metadata.py +192 -0
  10. mce/core/metric.py +163 -0
  11. mce/core/provider.py +36 -0
  12. mce/core/registry/__init__.py +98 -0
  13. mce/core/registry/discovery.py +739 -0
  14. mce/core/registry/provider.py +40 -0
  15. mce/core/registry/service.py +89 -0
  16. mce/core/specs/__init__.py +21 -0
  17. mce/core/specs/_registry.py +223 -0
  18. mce/core/specs/agent_to_agent_interactions.py +53 -0
  19. mce/core/specs/agent_to_tool_interactions.py +14 -0
  20. mce/core/specs/answer_correctness.py +18 -0
  21. mce/core/specs/answer_relevancy.py +18 -0
  22. mce/core/specs/bias.py +18 -0
  23. mce/core/specs/call_count.py +14 -0
  24. mce/core/specs/coherence.py +18 -0
  25. mce/core/specs/completion_rate.py +53 -0
  26. mce/core/specs/component_conflict_rate.py +18 -0
  27. mce/core/specs/consistency.py +18 -0
  28. mce/core/specs/context_preservation.py +18 -0
  29. mce/core/specs/conversation_completeness.py +18 -0
  30. mce/core/specs/cost.py +14 -0
  31. mce/core/specs/cycles_count.py +14 -0
  32. mce/core/specs/duration.py +14 -0
  33. mce/core/specs/general_structure_and_style.py +18 -0
  34. mce/core/specs/goal_success_rate.py +18 -0
  35. mce/core/specs/graph_determinism_score.py +14 -0
  36. mce/core/specs/groundedness.py +18 -0
  37. mce/core/specs/hallucination.py +18 -0
  38. mce/core/specs/information_retention.py +18 -0
  39. mce/core/specs/intent_recognition_accuracy.py +18 -0
  40. mce/core/specs/llm_average_confidence.py +14 -0
  41. mce/core/specs/llm_error_rate.py +76 -0
  42. mce/core/specs/llm_maximum_confidence.py +14 -0
  43. mce/core/specs/llm_minimum_confidence.py +14 -0
  44. mce/core/specs/response_completeness.py +18 -0
  45. mce/core/specs/role_adherence.py +18 -0
  46. mce/core/specs/sentiment.py +18 -0
  47. mce/core/specs/task_completion.py +18 -0
  48. mce/core/specs/task_delegation_accuracy.py +18 -0
  49. mce/core/specs/token_count.py +14 -0
  50. mce/core/specs/tonality.py +18 -0
  51. mce/core/specs/tool_error.py +14 -0
  52. mce/core/specs/tool_error_rate.py +14 -0
  53. mce/core/specs/tool_utilization_accuracy.py +95 -0
  54. mce/core/specs/topic_adherence.py +18 -0
  55. mce/core/specs/toxicity.py +18 -0
  56. mce/core/specs/workflow_cohesion_index.py +18 -0
  57. mce/core/specs/workflow_efficiency.py +90 -0
  58. mce/core/types.py +99 -0
  59. mce/engine/__init__.py +29 -0
  60. mce/engine/caching.py +611 -0
  61. mce/engine/context_builder.py +298 -0
  62. mce/engine/data/model_prices.json +1 -0
  63. mce/engine/engine.py +886 -0
  64. mce/engine/llm.py +338 -0
  65. mce/engine/pricing.py +146 -0
  66. mce/engine/scheduler.py +61 -0
  67. mce/engine/strategies.py +129 -0
  68. mce/engine/usage.py +84 -0
  69. mce/engine/worker.py +12 -0
  70. mce/providers/__init__.py +9 -0
  71. mce/providers/base_wrapper.py +133 -0
  72. mce/providers/catalog.py +28 -0
  73. mce/providers/template/__init__.py +4 -0
  74. mce/providers/virtual/aggregators.py +159 -0
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: ioc-scope-mce-core
3
+ Version: 2.0.0
4
+ Summary: Metrics Computation Engine core
5
+ Author-email: Outshift Open <oss-github@cisco.com>
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: <3.15,>=3.13
8
+ Requires-Dist: pydantic>=2.0.0
9
+ Requires-Dist: click>=8.0.0
10
+ Requires-Dist: python-dotenv>=1.0.0
11
+ Requires-Dist: requests>=2.28.0
12
+ Requires-Dist: networkx>=3.0.0
13
+ Requires-Dist: packaging>=23.0
14
+ Requires-Dist: pandas>=1.5.0
15
+ Requires-Dist: pydantic-settings>=2.10.1
16
+ Requires-Dist: typer>=0.9.0
17
+ Requires-Dist: rdflib>=7.0.0
18
+ Requires-Dist: pyyaml>=6.0.0
19
+ Requires-Dist: ioc-scope-ontology>=1.1.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: pytest-asyncio; extra == "dev"
23
+ Requires-Dist: black; extra == "dev"
24
+ Requires-Dist: flake8; extra == "dev"
25
+ Requires-Dist: mypy; extra == "dev"
26
+ Requires-Dist: httpx; extra == "dev"
27
+ Provides-Extra: neo4j
28
+ Requires-Dist: neo4j>=6.1.0; extra == "neo4j"
29
+ Provides-Extra: deepeval
30
+ Requires-Dist: ioc-scope-mce-provider-deepeval>=2.0.0; extra == "deepeval"
31
+ Provides-Extra: llm
32
+ Requires-Dist: ioc-scope-mce-provider-native>=2.0.0; extra == "llm"
33
+ Provides-Extra: opik
34
+ Requires-Dist: ioc-scope-mce-provider-opik>=2.0.0; extra == "opik"
35
+ Provides-Extra: ragas
36
+ Requires-Dist: ioc-scope-mce-provider-ragas>=2.0.0; extra == "ragas"
37
+ Provides-Extra: server
38
+ Requires-Dist: fastapi>=0.111.0; extra == "server"
39
+ Requires-Dist: uvicorn>=0.30.0; extra == "server"
40
+ Provides-Extra: all
41
+ Requires-Dist: ioc-scope-mce-provider-native>=2.0.0; extra == "all"
42
+ Requires-Dist: ioc-scope-mce-provider-sdk>=2.0.0; extra == "all"
43
+ Requires-Dist: ioc-scope-mce-provider-deepeval>=2.0.0; extra == "all"
44
+ Requires-Dist: ioc-scope-mce-provider-opik>=2.0.0; extra == "all"
45
+ Requires-Dist: ioc-scope-mce-provider-ragas>=2.0.0; extra == "all"
46
+ Requires-Dist: fastapi>=0.111.0; extra == "all"
47
+ Requires-Dist: uvicorn>=0.30.0; extra == "all"
@@ -0,0 +1,74 @@
1
+ mce/__init__.py,sha256=wMRx_jlzot3wmSSQ9yR078H8XKg9UGEAwrJp5wSVQ9I,886
2
+ mce/core/catalog.py,sha256=9BPycJRunRj6LCeeCiKgCsF3XuzcKowcXYWKhgARGQQ,8282
3
+ mce/core/config.py,sha256=XNznNiy1WdQmXz-TZhyFRiE5sc1fQa-iJdefESUW814,1044
4
+ mce/core/helpers.py,sha256=d2k7upydKMGJjsyHH6KCTkjGbWNTyER5TjV4CzSnFgo,6430
5
+ mce/core/metadata.py,sha256=JVxS39FSGnVKul0CUOrSwKZHqCbI95dAwKkYPJY0wEE,5859
6
+ mce/core/metric.py,sha256=G1l1WUn_-4IP43Yrw66wcUjGTbqokjStG_sAbcuJEhY,5659
7
+ mce/core/provider.py,sha256=9rVSq1eXgvyFEB6QWsIPS63OxU8b7-h-wcHOtGbN_CA,1373
8
+ mce/core/types.py,sha256=otm3X1gIhO3vF975LdpKSBAPf-qlvqR5c44aMe5kfYQ,3086
9
+ mce/core/registry/__init__.py,sha256=o1S_LhJfgMhgxmWXaECJclMtgpVzaro28YQv9DgTaD0,2914
10
+ mce/core/registry/discovery.py,sha256=zo6t4Xndg8GhSepkAVkYzfV_GJw57s1MfDn1--Q80e8,27387
11
+ mce/core/registry/provider.py,sha256=5QIq93ghTYHRWGS6C9xcOc7wJJx7b1lq7EH9S6h_JRw,1261
12
+ mce/core/registry/service.py,sha256=SBQgORDsg1qZkPI9J810tvRtp3_lVpkLPoGZ6cC6beQ,2888
13
+ mce/core/specs/__init__.py,sha256=wxxV-Uv1ljibU1RrYFjZScqE2UVLXcRRe7NM1POWTqE,563
14
+ mce/core/specs/_registry.py,sha256=jI3qCQ2g4hn-qXBgjgZMNG658kGIi1uFWUOkAS-1Ao8,8076
15
+ mce/core/specs/agent_to_agent_interactions.py,sha256=JeQXwh9PQ_HyMapxspmss-Hye-ktVDTt0bpdGabH7-g,1638
16
+ mce/core/specs/agent_to_tool_interactions.py,sha256=yWsi7UXJ5V6f-4Fk8T8Qlv0WOjtNUI_M7kYB8DdQyds,521
17
+ mce/core/specs/answer_correctness.py,sha256=3dT-wmYvX9s_0pnAKkXpHifosVg9FxahH-FLy930vDE,712
18
+ mce/core/specs/answer_relevancy.py,sha256=bYsPBQZLoVHcOSpmdg3Q_Eqkeu4xU4YHyzWl9Fp9YUY,718
19
+ mce/core/specs/bias.py,sha256=zos8-G3L8o24SuztZ_n9dFUl7_ox3jjbMv6qLD4hNp0,706
20
+ mce/core/specs/call_count.py,sha256=IZO66wbUWMUi79v62dipgtZ781iHN7p5u95OcrJDR5g,540
21
+ mce/core/specs/coherence.py,sha256=WnAU5H5vR1bQEG-lGgg-KWJGcx4QAlA71lhF83e-qn8,698
22
+ mce/core/specs/completion_rate.py,sha256=9yJQWobhsCorXep-gMNVT9477Y1CRUBbImYuZyIFDQ0,1856
23
+ mce/core/specs/component_conflict_rate.py,sha256=GkBJINRW82fEaeUCVjEiwfa4yFJrO1ps46SBFUeV4QE,701
24
+ mce/core/specs/consistency.py,sha256=3E_Tk5YTB6G5hwiwc3YoI_otHILJcRlRuitwH9Iu9s4,693
25
+ mce/core/specs/context_preservation.py,sha256=HETT74CH7inEJuDwMDS_qDhELOMRh8ql7PdTeNvPmY0,701
26
+ mce/core/specs/conversation_completeness.py,sha256=DQBGA88MQkUvkk4oBc9XVi8eyi8ywlzAajax8BMt_zk,728
27
+ mce/core/specs/cost.py,sha256=rYeETaAUAszXW6nSyAUNSleqwoBrnzrrxBRpmEOMjXw,546
28
+ mce/core/specs/cycles_count.py,sha256=0g-RvcYZonT3F_cm6SjaQJ0bTuzjHXMvJ8UZmswcVGw,511
29
+ mce/core/specs/duration.py,sha256=8ck8rLwaLwc7s80ST0CucPcaW5AvnwVKuaprbUZHaW0,528
30
+ mce/core/specs/general_structure_and_style.py,sha256=CkqEUnynn5x4zrfc80PQyJJUO88qlBhfTQzUI-4DYUs,741
31
+ mce/core/specs/goal_success_rate.py,sha256=VPZ1XUCMq1M5WMg3h1SRdA3Vxi6D2Pblyy6FUoQkPjs,690
32
+ mce/core/specs/graph_determinism_score.py,sha256=w-y5pyPexR0wbh5It2OHkC1DtTyOoIjPqdks3XSa3j8,528
33
+ mce/core/specs/groundedness.py,sha256=5C9Ftmzl9Yev-cjNuX1ZMVpYhtbQ6zzFHBvEnscTjJM,689
34
+ mce/core/specs/hallucination.py,sha256=TqPJeIS0kveq_f34vfL6cWGFSM2cPepqCDBvBQ5Dp-E,678
35
+ mce/core/specs/information_retention.py,sha256=DDDNPI7YovEbMhtk8vblAHI5fIJIEDmxPQ1rT7DuiSU,708
36
+ mce/core/specs/intent_recognition_accuracy.py,sha256=ECApsUgj2kQwOe4TxQd8Q2GO0tofWuR89aowuW1Z_BU,704
37
+ mce/core/specs/llm_average_confidence.py,sha256=WQXJ1oOTun4dJg12Ind8TGsZdOdFRXzJRXtidJy91tc,527
38
+ mce/core/specs/llm_error_rate.py,sha256=Ykn8n0Ogn9XwKT2M3S6M8nd44I8PZVV4kiey3EAypIk,2483
39
+ mce/core/specs/llm_maximum_confidence.py,sha256=vtgP_fHeGVZC-QGEWdLMoBQMwDROmLAcp1-HMuUmN8Y,527
40
+ mce/core/specs/llm_minimum_confidence.py,sha256=KoLJEVD83PQmIpo2sDAKwUdcaBuLuPl3ZAIIaIokorw,527
41
+ mce/core/specs/response_completeness.py,sha256=3KxaYbJg8jwYIk7bVzo6A2HWXjqNyu44k_xPp_5kQYA,692
42
+ mce/core/specs/role_adherence.py,sha256=X7oHPDYELByqXoS9Ts15550lzRlPZWi5zC36qPBUpfo,711
43
+ mce/core/specs/sentiment.py,sha256=XnAg69SUTmRWtM-7MG-rZ-QrRB21jgwbT4BhIIIcveg,663
44
+ mce/core/specs/task_completion.py,sha256=-2RXHd3eShvOxA7_PHSwA36-YlIKDqEDirTksZHWCJw,735
45
+ mce/core/specs/task_delegation_accuracy.py,sha256=noHXyy7s5Ldcg0CjRF5kKOtMisp-OGS8NHqAxNJ7tSM,726
46
+ mce/core/specs/token_count.py,sha256=D80Y4GkoQFpAsoRSDvYNmme9XXbYgX_CE_CVxvxkdyU,545
47
+ mce/core/specs/tonality.py,sha256=_saGt5R6Vlc6dDX1Yve5B9Ln_2mW_3uhviEam-8X6P4,677
48
+ mce/core/specs/tool_error.py,sha256=TJ2TZ9bGmG3-4wsh9qL4wxEybVW_m0HTqLhnjYoU9ws,518
49
+ mce/core/specs/tool_error_rate.py,sha256=98-pNR_m4RZcB8DKxTzYV9EgSRJvw4J75HSqkl3EHcw,525
50
+ mce/core/specs/tool_utilization_accuracy.py,sha256=5TBW_EIzxxVOeiQcgXMEvD8UI8W1Wi8v2IiHqhf1Lrw,3035
51
+ mce/core/specs/topic_adherence.py,sha256=Xn9VnKPXif00AGGgRWCVHpglJ6QCCCm-vnteC4ye07w,700
52
+ mce/core/specs/toxicity.py,sha256=AWqCtevXMVT4hIxHuwJMF2ntc7ODewZ1wNVS_la_cFI,710
53
+ mce/core/specs/workflow_cohesion_index.py,sha256=y4LkBwO8kPHcZ37kBGVYF7YsvJREy0NTEjufrxzpARM,710
54
+ mce/core/specs/workflow_efficiency.py,sha256=qpKNKKX4bE-Fz6OmNSnenpqmBQQj0vwArEe1At8ykLk,3047
55
+ mce/engine/__init__.py,sha256=mfLW6UKKVo6maM5jD9r7r6eQSW8deEwiln4FuzZF1J0,807
56
+ mce/engine/caching.py,sha256=oD83Bwnbe2YYuNSBEcaPRpqfOVCPoqh0WseO_WzTamM,24097
57
+ mce/engine/context_builder.py,sha256=Weu03WEJGDM0ehABo4oBbuZe56D_5HC5Z4-dn3ubsMg,10868
58
+ mce/engine/engine.py,sha256=mY6L8rG6COHe5Lozkh0LUXA_NEYqzgExSWCbZopYSlw,34610
59
+ mce/engine/llm.py,sha256=Nksjh1lXNJXrEukgprL35phd1dzaZEG7QIQrHjOM27s,12350
60
+ mce/engine/pricing.py,sha256=brdkvEZukuDedEh9j2R8px3s4eTL12x9uzn7V1LWFs8,5292
61
+ mce/engine/scheduler.py,sha256=S7J2evwodA_CScSzGOzOPQ4hX59Bm2pR1Sy6KLrH9KA,2154
62
+ mce/engine/strategies.py,sha256=qzQy3QjVEVxO_O5pBojUWIZ7aKbQlYdkk7EztY-q7LY,4813
63
+ mce/engine/usage.py,sha256=WUVz50xClk4y7Kqjz0TJtcF4L7Vx3rKNDiG-0MIson0,3008
64
+ mce/engine/worker.py,sha256=sCdotByhpVQ_0nY1KhuuPQiMCA8anLZ-rASjnHQybcI,526
65
+ mce/engine/data/model_prices.json,sha256=NWKp4UqRt5gN-Ht468-v5c43asJurTKj1HYVh7v4m8w,322046
66
+ mce/providers/__init__.py,sha256=U0m0dDHDeU7LiiGdBKmqddgbzR9l3kO4I-S7olR0p5w,361
67
+ mce/providers/base_wrapper.py,sha256=uWhv3dG41qw4jmDPXJsj--U3-JYiPtK9oCODuFIhH6E,4852
68
+ mce/providers/catalog.py,sha256=8nDFvC7D-2qCbMmBRtOoI8tF4Z4HAy1iW3jgIWCt1O8,799
69
+ mce/providers/template/__init__.py,sha256=-7KPZOheq2Mevhzt7vt6VZgK8lBTBQ4HEXgJm-njqTE,177
70
+ mce/providers/virtual/aggregators.py,sha256=Yv8bHtSzv5s-W6TsozdZAjjrJI1vhIruv_2OEuv49BU,5311
71
+ ioc_scope_mce_core-2.0.0.dist-info/METADATA,sha256=GXuvWV0glctawmazwUY2IrZy4hr6FAx6kp1vkR2XzGE,1863
72
+ ioc_scope_mce_core-2.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
73
+ ioc_scope_mce_core-2.0.0.dist-info/top_level.txt,sha256=Fxy5kcrJSiMg-a89I_3tPx7wdnlIQ5vM85AN-Krho4U,4
74
+ ioc_scope_mce_core-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mce
mce/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from __future__ import annotations
4
+ from pkgutil import extend_path
5
+
6
+ import warnings
7
+
8
+ # Allow multiple workspace packages to contribute to top-level `mce`.
9
+ __path__ = extend_path(__path__, __name__)
10
+
11
+ # Suppress Pydantic v1 compatibility warning from opik in Python 3.14+
12
+ # Must be at the top level before any other imports
13
+ warnings.filterwarnings(
14
+ "ignore",
15
+ message="Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater",
16
+ category=UserWarning,
17
+ )
18
+
19
+ # Expose key public components
20
+ try:
21
+ from .core.types import MetricResult as MetricResult
22
+ from .core.metric import Metric as Metric
23
+ from .engine.engine import MetricEngine as MetricEngine
24
+
25
+ __all__ = ["MetricResult", "Metric", "MetricEngine"]
26
+ except ImportError:
27
+ __all__: list[str] = []
mce/core/catalog.py ADDED
@@ -0,0 +1,208 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import importlib
7
+ import inspect
8
+ import pkgutil
9
+ import threading
10
+ import statistics
11
+ from mce.core.metric import Metric
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Lazy imports for heavy dependencies
16
+ try:
17
+ from scope_ontology import OntologyService
18
+ except ImportError:
19
+ OntologyService = None
20
+
21
+
22
+ class MetricCatalogService:
23
+ """
24
+ Core Service: unified registry for Metric instances.
25
+ Supports lazy creation of templated aggregate (virtual) metrics.
26
+ """
27
+
28
+ _instance = None
29
+ _lock = threading.Lock() # Guards singleton creation
30
+
31
+ def __init__(self):
32
+ self._contracts: dict[str, Metric | type[Metric]] = {}
33
+ self._unified_metrics: dict[str, list[Metric]] = {}
34
+
35
+ @classmethod
36
+ def get_instance(cls):
37
+ """Thread-safe singleton accessor."""
38
+ if cls._instance is None:
39
+ with cls._lock:
40
+ if cls._instance is None: # double-checked locking
41
+ cls._instance = MetricCatalogService()
42
+ return cls._instance
43
+
44
+ def register_metric(self, metric: Metric | type[Metric]) -> None:
45
+ """Register a unified Metric plugin or abstract Base."""
46
+ if isinstance(metric, type):
47
+ # Abstract Class registration
48
+ if not hasattr(metric, "metadata"):
49
+ return # Skip if no metadata
50
+ mid = metric.metadata.name
51
+ self._contracts[mid] = metric
52
+ return
53
+
54
+ # Instance registration
55
+ mid = metric.metric_id
56
+ if mid not in self._contracts:
57
+ self._contracts[mid] = metric
58
+
59
+ if mid not in self._unified_metrics:
60
+ self._unified_metrics[mid] = []
61
+ self._unified_metrics[mid].append(metric)
62
+
63
+ def get_contract(self, metric_id: str) -> Metric | type[Metric] | None:
64
+ """Look up a registered contract (or unified metric) by ID."""
65
+ return self._contracts.get(metric_id)
66
+
67
+ def get_metric(self, metric_id: str) -> Metric | None:
68
+ """
69
+ Get a unified metric instance.
70
+
71
+ **Lazy Loading for Templated Aggregates** (Option C):
72
+ If metric_id matches pattern "operation_BaseMetric" (e.g., "avg_AnswerRelevancy"),
73
+ automatically create and cache an AggregateMetric instance.
74
+
75
+ Supported operations:
76
+ - avg_* → AverageMetric(base_metric)
77
+ - max_* → AggregateMetric(base_metric, "max", max)
78
+ - min_* → AggregateMetric(base_metric, "min", min)
79
+ - sum_* → AggregateMetric(base_metric, "sum", sum)
80
+ """
81
+ # Check if already registered
82
+ metrics = self._unified_metrics.get(metric_id, [])
83
+ if metrics:
84
+ return metrics[0]
85
+
86
+ # Lazy loading: detect templated aggregate pattern
87
+ operation_mapping = {
88
+ "avg": ("avg", "AverageMetric"),
89
+ "max": ("max", "max"),
90
+ "min": ("min", "min"),
91
+ "sum": ("sum", "sum"),
92
+ }
93
+
94
+ for prefix, (op_name, op_type) in operation_mapping.items():
95
+ if metric_id.startswith(f"{prefix}_"):
96
+ base_metric_id = metric_id[len(prefix) + 1 :] # Remove "avg_" prefix
97
+
98
+ # Validate base metric exists
99
+ if base_metric_id not in self._contracts:
100
+ return None # Base metric not found, cannot create aggregate
101
+
102
+ # Import and instantiate appropriate aggregate metric
103
+ if op_type == "AverageMetric":
104
+ from mce.providers.virtual.aggregators import AverageMetric
105
+
106
+ aggregate_metric = AverageMetric(target_metric_id=base_metric_id)
107
+ else:
108
+ # max / min / sum operations
109
+ from mce.providers.virtual.aggregators import AggregateMetric
110
+
111
+ # Fix #20: statistics imported at module level
112
+ op_fn_map = {
113
+ "max": max,
114
+ "min": min,
115
+ "sum": sum,
116
+ "avg": statistics.mean,
117
+ }
118
+ aggregate_metric = AggregateMetric(
119
+ target_metric_id=base_metric_id,
120
+ operation_name=op_name,
121
+ operation_fn=op_fn_map[op_name],
122
+ )
123
+
124
+ # Resolve target_types for the new metric
125
+ self._resolve_target_types(aggregate_metric)
126
+
127
+ # Register the lazily created metric
128
+ self.register_metric(aggregate_metric)
129
+ return aggregate_metric
130
+
131
+ return None
132
+
133
+ def _resolve_target_types(self, metric: "Metric") -> None:
134
+ """Resolve target_types for a lazily created aggregate metric.
135
+
136
+ Fix #16: delegates to the single source of truth in discovery.py
137
+ (ensure_target_types) instead of duplicating the mapping logic here.
138
+ The lazy import avoids the circular dependency
139
+ (catalog → discovery → catalog).
140
+ """
141
+ from mce.core.registry.discovery import (
142
+ ensure_target_types,
143
+ ) # lazy — avoids circular import
144
+
145
+ ensure_target_types(metric)
146
+
147
+ # If ensure_target_types could not resolve (no scope/attachment_point),
148
+ # fall back to ontology_class-based mapping for aggregate metrics which
149
+ # inherit the base metric's class name rather than a MAS prefix.
150
+ md = metric.metadata
151
+ if not getattr(md, "target_types", None):
152
+ ap = md.attachment_point
153
+ if ap and OntologyService is not None:
154
+ try:
155
+ onto = OntologyService.get_instance()
156
+ md.target_types = onto.get_subclasses_of(ap)
157
+ return
158
+ except Exception as e:
159
+ logger.warning(
160
+ "OntologyService.get_subclasses_of(%r) failed: %s — "
161
+ "falling back to ontology_class mapping for '%s'.",
162
+ ap,
163
+ e,
164
+ md.name,
165
+ )
166
+ # Last resort: map known ontology_class strings.
167
+ _class_map = {
168
+ "Session": {"mas:Session"},
169
+ "LLMCall": {"mas:LLMCall"},
170
+ "mas:LLMCall": {"mas:LLMCall"},
171
+ "ToolCall": {"mas:ToolCall"},
172
+ "mas:ToolCall": {"mas:ToolCall"},
173
+ }
174
+ md.target_types = _class_map.get(md.ontology_class, set())
175
+
176
+ def get_all_contracts(self) -> list[Metric | type[Metric]]:
177
+ return list(self._contracts.values())
178
+
179
+ def discover_metrics(self, package_path: str = "mce.metrics"):
180
+ """Auto-discover concrete Metric subclasses in the specified package."""
181
+ try:
182
+ module = importlib.import_module(package_path)
183
+ path = getattr(module, "__path__", [])
184
+ # NOTE: use module_name (not `name`) so the inner member loop cannot
185
+ # shadow the outer iteration variable — that was a silent bug.
186
+ # Fix #5: use walk_packages (recursive) instead of iter_modules (single-level)
187
+ # so metrics in sub-packages are not silently dropped.
188
+ for _, module_name, _ in pkgutil.walk_packages(
189
+ path, prefix=f"{package_path}."
190
+ ):
191
+ sub_module = importlib.import_module(module_name)
192
+ for member_name, obj in inspect.getmembers(sub_module):
193
+ if (
194
+ inspect.isclass(obj)
195
+ and issubclass(obj, Metric)
196
+ and obj is not Metric
197
+ ):
198
+ if inspect.isabstract(obj):
199
+ continue # Skip abstract bases
200
+ try:
201
+ instance = obj()
202
+ self.register_metric(instance)
203
+ except Exception as e:
204
+ logger.warning(
205
+ "Failed to instantiate metric %s: %s", member_name, e
206
+ )
207
+ except ImportError as e:
208
+ logger.warning("Could not import package %s: %s", package_path, e)
mce/core/config.py ADDED
@@ -0,0 +1,33 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from __future__ import annotations
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+ from pydantic import Field
6
+
7
+
8
+ class MceSettings(BaseSettings):
9
+ """
10
+ Configuration for the MCE library.
11
+ Settings can be overridden by environment variables with the prefix MCE_.
12
+
13
+ Provider availability is determined automatically: if a package is importable
14
+ (e.g. deepeval, ragas, opik) its metrics are discovered and registered.
15
+ Install the metapackage to get everything, or install individual provider
16
+ packages to pick only what you need.
17
+ """
18
+
19
+ strict_fail_fast: bool = Field(
20
+ default=False, description="Raise exceptions on provider initialization failure"
21
+ )
22
+
23
+ model_config = SettingsConfigDict(
24
+ env_prefix="MCE_",
25
+ env_file=".env",
26
+ env_file_encoding="utf-8",
27
+ case_sensitive=False,
28
+ extra="ignore",
29
+ )
30
+
31
+
32
+ # Global settings instance
33
+ settings = MceSettings()
mce/core/helpers.py ADDED
@@ -0,0 +1,184 @@
1
+ # Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from __future__ import annotations
4
+ from typing import Any
5
+ import json
6
+ import re
7
+ from mce.core.metric import Metric
8
+
9
+
10
+ def _safe_session(context: dict[str, Any]) -> Any:
11
+ """Return the session object from context if available."""
12
+ return context.get("session")
13
+
14
+
15
+ def _conversation_data(context: dict[str, Any]) -> dict[str, Any]:
16
+ """Return conversation data from context or session."""
17
+ data = context.get("conversation_data")
18
+ if isinstance(data, dict):
19
+ return data
20
+ session = _safe_session(context)
21
+ if session is not None:
22
+ session_data = getattr(session, "conversation_data", None)
23
+ if isinstance(session_data, dict):
24
+ return session_data
25
+ return {}
26
+
27
+
28
+ def _conversation_text(context: dict[str, Any]) -> str:
29
+ """Extract the conversation text from context or session."""
30
+ for key in ["conversation_text", "transcript", "conversation"]:
31
+ value = context.get(key)
32
+ if isinstance(value, str) and value:
33
+ return value
34
+ data = _conversation_data(context)
35
+ if "conversation" in data and isinstance(data["conversation"], str):
36
+ return data["conversation"]
37
+ if "elements" in data:
38
+ elements = data.get("elements")
39
+ if isinstance(elements, (list, dict)):
40
+ return json.dumps(elements, indent=2)
41
+ if isinstance(elements, str):
42
+ return elements
43
+ query, response = _query_response(context)
44
+ if query or response:
45
+ return f"User: {query}\nAssistant: {response}".strip()
46
+ return ""
47
+
48
+
49
+ def _query_response(context: dict[str, Any]) -> tuple[str, str]:
50
+ """Extract the input query and final response from context or session."""
51
+ query = context.get("input_query") or context.get("input_text")
52
+ response = context.get("final_response") or context.get("output_text")
53
+ data = _conversation_data(context)
54
+ if not query and isinstance(data.get("query"), str):
55
+ query = data.get("query")
56
+ if not response and isinstance(data.get("response"), str):
57
+ response = data.get("response")
58
+ session = _safe_session(context)
59
+ if session is not None:
60
+ if not query and isinstance(getattr(session, "input_query", None), str):
61
+ query = getattr(session, "input_query")
62
+ if not response and isinstance(getattr(session, "final_response", None), str):
63
+ response = getattr(session, "final_response")
64
+
65
+ llm_spans = context.get("llm_spans") or []
66
+ if not llm_spans and session is not None and hasattr(session, "llm_spans"):
67
+ llm_spans = session.llm_spans or []
68
+
69
+ if not query:
70
+ for span in llm_spans:
71
+ query = _span_value(span, "inputContent", "prompt", "input_text")
72
+ if query:
73
+ break
74
+
75
+ if not response:
76
+ for span in reversed(llm_spans):
77
+ response = _span_value(span, "outputContent", "completion", "output_text")
78
+ if response:
79
+ break
80
+
81
+ return query or "", response or ""
82
+
83
+
84
+ def _llm_binary_score(metric: Metric, prompt: str) -> tuple[float, str]:
85
+ """Get a binary score from an LLM response using the first numeric token."""
86
+ response = metric.call_llm(prompt).strip()
87
+ match = re.search(r"-?\d+(?:\.\d+)?", response)
88
+ score = float(match.group()) if match else 0.0
89
+ score = max(0.0, min(1.0, score))
90
+ return score, response
91
+
92
+
93
+ def _span_dict(span: Any) -> dict[str, Any]:
94
+ """Normalize span to a dictionary for attribute access."""
95
+ if isinstance(span, dict):
96
+ return span
97
+ return getattr(span, "__dict__", {})
98
+
99
+
100
+ def _span_attrs(span: Any) -> dict[str, Any]:
101
+ """Extract raw span attributes with multiple fallback keys."""
102
+ data = _span_dict(span)
103
+ if isinstance(data.get("attrs"), dict):
104
+ return data["attrs"]
105
+ raw_span = data.get("raw_span_data")
106
+ if isinstance(raw_span, dict):
107
+ for key in ["SpanAttributes", "attributes"]:
108
+ attrs = raw_span.get(key)
109
+ if isinstance(attrs, dict):
110
+ return attrs
111
+ return {}
112
+
113
+
114
+ def _span_value(span: Any, *keys: str) -> str | None:
115
+ """Return the first available string value from span data or attributes."""
116
+ data = _span_dict(span)
117
+ attrs = _span_attrs(span)
118
+ for key in keys:
119
+ value = data.get(key)
120
+ if isinstance(value, str) and value:
121
+ return value
122
+ attr_value = attrs.get(key)
123
+ if isinstance(attr_value, str) and attr_value:
124
+ return attr_value
125
+ return None
126
+
127
+
128
+ def _first_number(context: dict[str, Any], keys: list[str]) -> float:
129
+ for key in keys:
130
+ value = context.get(key)
131
+ if isinstance(value, (int, float)):
132
+ return float(value)
133
+ return 0.0
134
+
135
+
136
+ def _find_key_value(data: Any, target_key: str) -> Any:
137
+ if isinstance(data, dict):
138
+ if target_key in data:
139
+ return data[target_key]
140
+ for value in data.values():
141
+ result = _find_key_value(value, target_key)
142
+ if result is not None:
143
+ return result
144
+ elif isinstance(data, list):
145
+ for item in data:
146
+ result = _find_key_value(item, target_key)
147
+ if result is not None:
148
+ return result
149
+ return None
150
+
151
+
152
+ def _extract_logprobs(span: Any) -> list[float]:
153
+ span_dict = span if isinstance(span, dict) else getattr(span, "__dict__", {})
154
+ output_payload = span_dict.get("output_payload") or span_dict.get("output")
155
+ if output_payload is None:
156
+ return []
157
+
158
+ logprobs_block = _find_key_value(output_payload, "logprobs")
159
+ if not isinstance(logprobs_block, dict):
160
+ return []
161
+
162
+ content = logprobs_block.get("content")
163
+ if not isinstance(content, list):
164
+ return []
165
+
166
+ logprobs: list[float] = []
167
+ for token_dict in content:
168
+ if isinstance(token_dict, dict) and isinstance(
169
+ token_dict.get("logprob"), (int, float)
170
+ ):
171
+ logprobs.append(float(token_dict["logprob"]))
172
+ return logprobs
173
+
174
+
175
+ def _session_logprobs(context: dict[str, Any]) -> list[float]:
176
+ spans = context.get("llm_spans") or []
177
+ session = context.get("session")
178
+ if not spans and session is not None and hasattr(session, "llm_spans"):
179
+ spans = session.llm_spans or []
180
+
181
+ logprobs: list[float] = []
182
+ for span in spans:
183
+ logprobs.extend(_extract_logprobs(span))
184
+ return logprobs