decision-cascade 1.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 (127) hide show
  1. api/sdk/__init__.py +0 -0
  2. api/sdk/agent.py +145 -0
  3. api/sdk/client.py +127 -0
  4. api/server/__init__.py +0 -0
  5. api/server/deprecation.py +114 -0
  6. api/server/main.py +927 -0
  7. api/server/metrics.py +126 -0
  8. api/server/observability.py +178 -0
  9. api/server/service.py +569 -0
  10. api/server/systemone.py +161 -0
  11. decision_cascade-1.0.0.dist-info/METADATA +1253 -0
  12. decision_cascade-1.0.0.dist-info/RECORD +127 -0
  13. decision_cascade-1.0.0.dist-info/WHEEL +5 -0
  14. decision_cascade-1.0.0.dist-info/licenses/LICENSE +202 -0
  15. decision_cascade-1.0.0.dist-info/licenses/NOTICE +2 -0
  16. decision_cascade-1.0.0.dist-info/top_level.txt +5 -0
  17. eval/__init__.py +0 -0
  18. eval/benchmark/__init__.py +1 -0
  19. eval/benchmark/core.py +252 -0
  20. eval/benchmark/run.py +108 -0
  21. eval/calibration/__init__.py +0 -0
  22. eval/calibration/metrics.py +113 -0
  23. eval/calibration/run.py +39 -0
  24. eval/calibration/selective.py +92 -0
  25. eval/competitors/__init__.py +1 -0
  26. eval/competitors/evaluate.py +123 -0
  27. eval/competitors/fit_remote_accuracy.py +147 -0
  28. eval/competitors/mocks.py +86 -0
  29. eval/competitors/release.py +264 -0
  30. eval/competitors/run.py +360 -0
  31. eval/competitors/serve.py +286 -0
  32. eval/datasets/__init__.py +1 -0
  33. eval/datasets/image_dataset.py +72 -0
  34. eval/datasets/public_dataset.py +104 -0
  35. eval/datasets/routerbench.py +266 -0
  36. eval/datasets/run.py +43 -0
  37. eval/datasets/text_dataset.py +218 -0
  38. eval/drift/__init__.py +1 -0
  39. eval/drift/run.py +427 -0
  40. eval/latency/__init__.py +0 -0
  41. eval/latency/benchmark.py +107 -0
  42. eval/latency/run.py +169 -0
  43. eval/nightly.py +177 -0
  44. eval/option_transfer/__init__.py +0 -0
  45. eval/option_transfer/core.py +267 -0
  46. eval/option_transfer/run.py +98 -0
  47. eval/report/__init__.py +1 -0
  48. eval/report/run.py +263 -0
  49. eval/risk/__init__.py +0 -0
  50. eval/risk/core.py +409 -0
  51. eval/risk/features.py +1341 -0
  52. eval/risk/run.py +264 -0
  53. eval/risk/simulate.py +548 -0
  54. examples/__init__.py +8 -0
  55. examples/_common.py +233 -0
  56. examples/batch/csv_triage.py +79 -0
  57. examples/batch/inprocess_batch.py +50 -0
  58. examples/batch/jsonl_batch.py +85 -0
  59. examples/cascade/client_fallback.py +68 -0
  60. examples/cascade/hash_to_llm.py +96 -0
  61. examples/cascade/risk_aware.py +174 -0
  62. examples/integrations/chat_agent.py +66 -0
  63. examples/integrations/discord_bot.py +81 -0
  64. examples/integrations/email_triage.py +65 -0
  65. examples/integrations/moderation.py +74 -0
  66. examples/integrations/openai_tools.py +82 -0
  67. examples/integrations/sentiment_router.py +61 -0
  68. examples/integrations/slack_app.py +71 -0
  69. examples/integrations/support_copilot.py +65 -0
  70. examples/integrations/telegram_bot.py +92 -0
  71. examples/integrations/webhook_fastapi.py +65 -0
  72. examples/ops/device_select.py +49 -0
  73. examples/ops/local_heads.py +45 -0
  74. examples/ops/option_transfer.py +112 -0
  75. examples/ops/weight_pack.py +58 -0
  76. examples/protocol/systemone_client.py +107 -0
  77. examples/quickstart/hello.py +42 -0
  78. examples/quickstart/http_client.py +91 -0
  79. examples/vision/image_folder.py +81 -0
  80. examples/vision/image_single.py +74 -0
  81. examples/vision/make_sample_images.py +37 -0
  82. examples/vision/video_frames.py +72 -0
  83. examples/vision/vision_offline.py +47 -0
  84. src/__init__.py +7 -0
  85. src/backends/__init__.py +23 -0
  86. src/backends/base.py +148 -0
  87. src/backends/benefit.py +259 -0
  88. src/backends/cascade.py +518 -0
  89. src/backends/http.py +223 -0
  90. src/backends/local.py +88 -0
  91. src/backends/protocol.py +100 -0
  92. src/decision_head/__init__.py +0 -0
  93. src/decision_head/heads.py +331 -0
  94. src/device.py +72 -0
  95. src/encoder/__init__.py +0 -0
  96. src/encoder/base.py +43 -0
  97. src/encoder/caching.py +30 -0
  98. src/encoder/factory.py +57 -0
  99. src/encoder/gemma.py +156 -0
  100. src/encoder/pooling.py +36 -0
  101. src/encoder/transformer.py +175 -0
  102. src/encoder/vision.py +237 -0
  103. src/errors.py +49 -0
  104. src/risk.py +373 -0
  105. src/sampler/__init__.py +0 -0
  106. src/sampler/parallel.py +14 -0
  107. src/sampler/questions.py +127 -0
  108. src/schemas.py +72 -0
  109. src/versioning.py +70 -0
  110. training/__init__.py +0 -0
  111. training/feedback/__init__.py +1 -0
  112. training/feedback/run.py +529 -0
  113. training/finetune/__init__.py +0 -0
  114. training/finetune/train.py +227 -0
  115. training/labeling/__init__.py +1 -0
  116. training/labeling/run.py +281 -0
  117. training/recipe/__init__.py +0 -0
  118. training/recipe/calibrate_rlcd.py +177 -0
  119. training/recipe/calibrate_temperature.py +281 -0
  120. training/recipe/finetune_encoder.py +214 -0
  121. training/recipe/risk.py +125 -0
  122. training/recipe/train_heads.py +259 -0
  123. training/rlcd/__init__.py +0 -0
  124. training/rlcd/reward.py +16 -0
  125. training/rlcd/train.py +118 -0
  126. training/sft/__init__.py +0 -0
  127. training/sft/train.py +108 -0
api/sdk/__init__.py ADDED
File without changes
api/sdk/agent.py ADDED
@@ -0,0 +1,145 @@
1
+ """Маршрутизация обращения по решениям decision-cascade.
2
+
3
+ Показывает типичную интеграцию «мозга решений» в чат-агента: по типизированным
4
+ решениям (noul/choice/score) выбирается команда, приоритет, эскалация и тон
5
+ ответа. Логика чистая (без сети) и легко тестируется отдельно от SDK.
6
+
7
+ Каскад: ``route_ticket`` решает, нужна ли эскалация; вызывающий код (см.
8
+ ``examples/integrations/chat_agent.py``) может на этом основании переспросить внешний сервис.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ PRIORITIES = ("low", "normal", "high", "urgent")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RoutingPolicy:
20
+ """Пороги, по которым решения превращаются в действие агента."""
21
+
22
+ escalate_threshold: float = 0.5
23
+ urgent_threshold: float = 0.6
24
+ high_threshold: float = 0.3
25
+ churn_threshold: float = 3.0
26
+ heat_urgent: float = 3.5
27
+ heat_high: float = 2.0
28
+ default_team: str = "общая поддержка"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AgentAction:
33
+ team: str
34
+ priority: str
35
+ escalate: bool
36
+ tone: str
37
+ reply: str
38
+ rationale: tuple[str, ...] = ()
39
+
40
+
41
+ def _noul(decisions: dict[str, Any], key: str) -> float | None:
42
+ d = decisions.get(key)
43
+ if isinstance(d, dict) and d.get("type") == "noul":
44
+ return float(d["noul"])
45
+ return None
46
+
47
+
48
+ def _choice(decisions: dict[str, Any], key: str) -> str | None:
49
+ d = decisions.get(key)
50
+ if isinstance(d, dict) and d.get("type") == "choice" and "choice" in d:
51
+ return str(d["choice"])
52
+ return None
53
+
54
+
55
+ def _score(decisions: dict[str, Any], key: str) -> float | None:
56
+ d = decisions.get(key)
57
+ if isinstance(d, dict) and d.get("type") == "score":
58
+ return float(d["score"])
59
+ return None
60
+
61
+
62
+ def _priority(
63
+ urgency: float,
64
+ heat: float,
65
+ churn: float,
66
+ policy: RoutingPolicy,
67
+ rationale: list[str],
68
+ ) -> str:
69
+ if urgency >= policy.urgent_threshold or heat >= policy.heat_urgent:
70
+ rationale.append(
71
+ f"срочность={urgency:.2f}, накал={heat:.2f} -> urgent"
72
+ )
73
+ return "urgent"
74
+ if urgency >= policy.high_threshold or heat >= policy.heat_high or churn >= policy.churn_threshold:
75
+ rationale.append(
76
+ f"срочность={urgency:.2f}, накал={heat:.2f}, отток={churn:.2f} -> high"
77
+ )
78
+ return "high"
79
+ if urgency < 0.2 and heat < 1.0:
80
+ rationale.append("низкая срочность и низкий накал -> low")
81
+ return "low"
82
+ return "normal"
83
+
84
+
85
+ def _tone(heat: float, churn: float) -> str:
86
+ if heat >= 3.0:
87
+ return "извиняющийся"
88
+ if heat >= 1.5 or churn >= 3.0:
89
+ return "эмпатичный"
90
+ return "нейтральный"
91
+
92
+
93
+ def _reply(team: str, priority: str, tone: str, escalate: bool) -> str:
94
+ if tone == "извиняющийся":
95
+ opening = "Приносим извинения за доставленные неудобства."
96
+ elif tone == "эмпатичный":
97
+ opening = "Понимаю, как это неприятно."
98
+ else:
99
+ opening = "Спасибо за обращение."
100
+ if escalate:
101
+ return (
102
+ f"{opening} Передаю обращение старшему инженеру "
103
+ f"(команда «{team}», приоритет: {priority})."
104
+ )
105
+ return f"{opening} Обращение принято в команду «{team}» (приоритет: {priority})."
106
+
107
+
108
+ def route_ticket(
109
+ decisions: dict[str, Any],
110
+ policy: RoutingPolicy | None = None,
111
+ ) -> AgentAction:
112
+ """Превращает решения по обращению в действие чат-агента.
113
+
114
+ Ожидаемые вопросы (любой можно опустить): ``срочность``/``эскалация`` (noul),
115
+ ``команда`` (choice), ``накал``/``удержание`` (score).
116
+ """
117
+ policy = policy or RoutingPolicy()
118
+ rationale: list[str] = []
119
+
120
+ team = _choice(decisions, "команда") or policy.default_team
121
+ if team != policy.default_team:
122
+ rationale.append(f"команда из решения: {team!r}")
123
+
124
+ urgency = _noul(decisions, "срочность") or 0.0
125
+ escalation = _noul(decisions, "эскалация") or 0.0
126
+ heat = _score(decisions, "накал") or 0.0
127
+ churn = _score(decisions, "удержание") or 0.0
128
+
129
+ priority = _priority(urgency, heat, churn, policy, rationale)
130
+ escalate = escalation >= policy.escalate_threshold or priority == "urgent"
131
+ if escalate:
132
+ rationale.append(
133
+ f"эскалация={escalation:.2f} (порог {policy.escalate_threshold:.2f})"
134
+ )
135
+ tone = _tone(heat, churn)
136
+ rationale.append(f"тон ответа: {tone}")
137
+
138
+ return AgentAction(
139
+ team=team,
140
+ priority=priority,
141
+ escalate=escalate,
142
+ tone=tone,
143
+ reply=_reply(team, priority, tone, escalate),
144
+ rationale=tuple(rationale),
145
+ )
api/sdk/client.py ADDED
@@ -0,0 +1,127 @@
1
+ """Python SDK для decision-cascade API: типизированный клиент с валидацией."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Annotated, Any, Literal, Self
5
+
6
+ import httpx
7
+ from pydantic import BaseModel, Field
8
+
9
+ from src.schemas import SCHEMA_VERSION
10
+ from src.versioning import is_compatible
11
+
12
+ QuestionPayload = dict[str, Any]
13
+
14
+
15
+ class _NoulOut(BaseModel):
16
+ type: Literal["noul"] = "noul"
17
+ noul: float = Field(..., ge=0.0, le=1.0)
18
+ confidence: float = Field(..., ge=0.0, le=1.0)
19
+
20
+
21
+ class _ChoiceOut(BaseModel):
22
+ type: Literal["choice"] = "choice"
23
+ choice: str
24
+ probabilities: dict[str, float]
25
+ confidence: float = Field(..., ge=0.0, le=1.0)
26
+
27
+
28
+ class _ScoreOut(BaseModel):
29
+ type: Literal["score"] = "score"
30
+ score: float
31
+ probabilities: dict[str, float]
32
+ confidence: float = Field(..., ge=0.0, le=1.0)
33
+ legend: list[str]
34
+
35
+
36
+ _Out = Annotated[
37
+ _NoulOut | _ChoiceOut | _ScoreOut, Field(discriminator="type")
38
+ ]
39
+
40
+
41
+ class _Response(BaseModel):
42
+ schema_version: str
43
+ decisions: dict[str, _Out]
44
+
45
+
46
+ class _FeedbackQuestion(BaseModel):
47
+ question: str
48
+ accepted: int = Field(..., ge=0)
49
+ examples: int = Field(..., ge=0)
50
+ reference: int = Field(..., ge=0)
51
+ retrained: bool
52
+
53
+
54
+ class _FeedbackResponse(BaseModel):
55
+ accepted: int = Field(..., ge=0)
56
+ questions: list[_FeedbackQuestion]
57
+
58
+
59
+ def validate_response(payload: dict[str, Any]) -> list[str] | None:
60
+ """Проверяет ответ API на соответствие типизированному контракту.
61
+
62
+ Версия схемы должна быть совместима по major (аддитивные minor-изменения не
63
+ ломают клиент), см. ``src/versioning.py``.
64
+ """
65
+ schema_version = payload.get("schema_version")
66
+ if not isinstance(schema_version, str) or not is_compatible(
67
+ SCHEMA_VERSION, schema_version
68
+ ):
69
+ raise ValueError(
70
+ f"schema_version mismatch: expected major of {SCHEMA_VERSION}, "
71
+ f"got {schema_version!r}"
72
+ )
73
+ _Response.model_validate(payload)
74
+ for name, res in payload["decisions"].items():
75
+ probs = res.get("probabilities")
76
+ if probs is not None:
77
+ if abs(sum(probs.values()) - 1.0) > 1e-6:
78
+ raise ValueError(f"decision {name!r}: probabilities must sum to 1.0")
79
+ if any(p < 0.0 for p in probs.values()):
80
+ raise ValueError(f"decision {name!r}: probabilities must be non-negative")
81
+ return None
82
+
83
+
84
+ class DecisionClient:
85
+ def __init__(
86
+ self,
87
+ base_url: str = "http://localhost:8000",
88
+ timeout: float = 60.0,
89
+ transport: httpx.BaseTransport | None = None,
90
+ ):
91
+ self._client = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
92
+
93
+ def decisions(
94
+ self, state: str, questions: dict[str, QuestionPayload]
95
+ ) -> dict[str, Any]:
96
+ payload = {"state": state, "questions": questions}
97
+ resp = self._client.post("/decisions", json=payload)
98
+ resp.raise_for_status()
99
+ body = resp.json()
100
+ validate_response(body)
101
+ return body
102
+
103
+ def feedback(self, examples: list[dict[str, Any]]) -> dict[str, Any]:
104
+ """Прислать размеченные примеры решений (few-shot риск-модели).
105
+
106
+ ``examples`` — список вида
107
+ ``{"question": ..., "decision": <решение из decisions()>, "correct": bool}``:
108
+ повторяется именно решение, которое сервер уже отдавал, состояние кодировать
109
+ заново не нужно. В ответе — сколько примеров накоплено по вопросу и не
110
+ достигнут ли порог `DECISION_RISK_FEW_SHOT_N` (тогда риск-модель вопроса
111
+ переобучена). Если бэкенд обратную связь не принимает, сервер отвечает
112
+ ``409 FEEDBACK_UNSUPPORTED`` — это ``httpx.HTTPStatusError``.
113
+ """
114
+ resp = self._client.post("/v1/feedback", json={"examples": examples})
115
+ resp.raise_for_status()
116
+ body = resp.json()
117
+ _FeedbackResponse.model_validate(body)
118
+ return body
119
+
120
+ def close(self) -> None:
121
+ self._client.close()
122
+
123
+ def __enter__(self) -> Self:
124
+ return self
125
+
126
+ def __exit__(self, *exc: object) -> None:
127
+ self.close()
api/server/__init__.py ADDED
File without changes
@@ -0,0 +1,114 @@
1
+ """Политика депрекации: реестр устаревших поверхностей и заголовки ответа.
2
+
3
+ Правило (``docs/stability.md`` §5): поверхность объявляется устаревшей минимум
4
+ за две минорные версии до удаления. Всё это время она работает, отдаёт
5
+ заголовки ``Deprecation``/``Sunset``/``Link`` и один раз пишет предупреждение в
6
+ лог. Удаление — только в major-релизе.
7
+
8
+ Реестр намеренно пуст: пока в проекте нет устаревших поверхностей. Механизм
9
+ здесь, чтобы первая же депрекация была объявлена одинаково и проверялась
10
+ тестом, а не «на словах».
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass
16
+
17
+ logger = logging.getLogger("decision.deprecation")
18
+
19
+ #: Сколько минорных релизов поверхность обязана прожить после объявления.
20
+ MIN_MINOR_RELEASES = 2
21
+
22
+
23
+ def parse_minor(version: str) -> tuple[int, int]:
24
+ """Разбирает ``X.Y[.Z]`` в ``(major, minor)``; иначе ``ValueError``."""
25
+ parts = version.strip().split(".")
26
+ if len(parts) < 2:
27
+ raise ValueError(f"expected MAJOR.MINOR[.PATCH], got {version!r}")
28
+ try:
29
+ return int(parts[0]), int(parts[1])
30
+ except ValueError as exc:
31
+ raise ValueError(f"expected numeric MAJOR.MINOR, got {version!r}") from exc
32
+
33
+
34
+ def minor_distance(since: str, remove_in: str) -> int:
35
+ """Сколько минорных версий между объявлением и удалением."""
36
+ since_major, since_minor = parse_minor(since)
37
+ remove_major, remove_minor = parse_minor(remove_in)
38
+ return (remove_major - since_major) * 1000 + (remove_minor - since_minor)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Deprecation:
43
+ """Одна устаревшая поверхность: путь, поле ответа или переменная окружения."""
44
+
45
+ #: что именно устарело — путь (``/decisions``) или имя (``DECISION_FOO``)
46
+ item: str
47
+ #: версия, в которой объявлена депрекация
48
+ since: str
49
+ #: версия, в которой поверхность исчезнет (не раньше since + 2 минорные)
50
+ remove_in: str
51
+ #: чем заменить; попадает в ``Link`` и в предупреждение
52
+ alternative: str | None = None
53
+ #: дата отключения в формате RFC 8594 (``Sunset``), необязательна
54
+ sunset: str | None = None
55
+
56
+ def headers(self) -> dict[str, str]:
57
+ """Заголовки ответа по RFC 8594 и RFC 9745."""
58
+ headers = {"Deprecation": "true"}
59
+ if self.sunset:
60
+ headers["Sunset"] = self.sunset
61
+ if self.alternative:
62
+ headers["Link"] = f'<{self.alternative}>; rel="deprecation"'
63
+ return headers
64
+
65
+ def __post_init__(self) -> None:
66
+ distance = minor_distance(self.since, self.remove_in)
67
+ if distance < MIN_MINOR_RELEASES:
68
+ raise ValueError(
69
+ f"deprecation {self.item!r}: remove_in={self.remove_in} is only "
70
+ f"{distance} minor release(s) after since={self.since}; "
71
+ f"minimum is {MIN_MINOR_RELEASES}"
72
+ )
73
+
74
+
75
+ #: Реестр устаревших поверхностей: ключ — ``Deprecation.item``.
76
+ DEPRECATIONS: dict[str, Deprecation] = {}
77
+
78
+ #: Уже предупреждённые элементы: в лог пишем один раз за жизнь процесса.
79
+ _warned: set[str] = set()
80
+
81
+
82
+ def deprecation_for(item: str) -> Deprecation | None:
83
+ """Возвращает депрекацию для поверхности или ``None``."""
84
+ return DEPRECATIONS.get(item)
85
+
86
+
87
+ def warn_once(item: str) -> Deprecation | None:
88
+ """Пишет предупреждение о депрекации один раз и возвращает её запись."""
89
+ deprecation = DEPRECATIONS.get(item)
90
+ if deprecation is None or item in _warned:
91
+ return deprecation
92
+ _warned.add(item)
93
+ logger.warning(
94
+ "deprecated %s since %s, removal in %s: %s",
95
+ deprecation.item,
96
+ deprecation.since,
97
+ deprecation.remove_in,
98
+ deprecation.alternative or "no replacement announced",
99
+ )
100
+ return deprecation
101
+
102
+
103
+ def reset_warnings() -> None:
104
+ """Сбрасывает память предупреждений (для тестов и перезапуска)."""
105
+ _warned.clear()
106
+
107
+
108
+ def validate_registry() -> None:
109
+ """Проверяет, что реестр не нарушает правило двух минорных версий."""
110
+ for item, deprecation in DEPRECATIONS.items():
111
+ if item != deprecation.item:
112
+ raise ValueError(
113
+ f"registry key {item!r} does not match item {deprecation.item!r}"
114
+ )