esimu-core 0.2.0b5__tar.gz
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.
- esimu_core-0.2.0b5/LICENSE +22 -0
- esimu_core-0.2.0b5/PKG-INFO +58 -0
- esimu_core-0.2.0b5/README.md +25 -0
- esimu_core-0.2.0b5/esimu_core/__init__.py +17 -0
- esimu_core-0.2.0b5/esimu_core/ai/__init__.py +31 -0
- esimu_core-0.2.0b5/esimu_core/ai/config.py +114 -0
- esimu_core-0.2.0b5/esimu_core/ai/parsing.py +45 -0
- esimu_core-0.2.0b5/esimu_core/ai/policy.py +80 -0
- esimu_core-0.2.0b5/esimu_core/ai/service.py +422 -0
- esimu_core-0.2.0b5/esimu_core/ai/transport.py +151 -0
- esimu_core-0.2.0b5/esimu_core/cli.py +106 -0
- esimu_core-0.2.0b5/esimu_core/content/__init__.py +424 -0
- esimu_core-0.2.0b5/esimu_core/domain/__init__.py +5 -0
- esimu_core-0.2.0b5/esimu_core/domain/actions.py +85 -0
- esimu_core-0.2.0b5/esimu_core/domain/effects.py +200 -0
- esimu_core-0.2.0b5/esimu_core/domain/semester.py +259 -0
- esimu_core-0.2.0b5/esimu_core/lifecycle/__init__.py +238 -0
- esimu_core-0.2.0b5/esimu_core/py.typed +1 -0
- esimu_core-0.2.0b5/esimu_core/runtime/__init__.py +2 -0
- esimu_core-0.2.0b5/esimu_core/runtime/actions.py +37 -0
- esimu_core-0.2.0b5/esimu_core/runtime/clock.py +51 -0
- esimu_core-0.2.0b5/esimu_core/runtime/cooldowns.py +43 -0
- esimu_core-0.2.0b5/esimu_core/runtime/snapshot.py +179 -0
- esimu_core-0.2.0b5/esimu_core/runtime/state.py +90 -0
- esimu_core-0.2.0b5/esimu_core/runtime/tasks.py +62 -0
- esimu_core-0.2.0b5/esimu_core/scaffold/esimu-starter.zip +0 -0
- esimu_core-0.2.0b5/esimu_core/scaffold.py +518 -0
- esimu_core-0.2.0b5/esimu_core/world/__init__.py +2 -0
- esimu_core-0.2.0b5/esimu_core/world/balance.py +233 -0
- esimu_core-0.2.0b5/esimu_core/world/catalog.py +206 -0
- esimu_core-0.2.0b5/esimu_core/world/items.py +350 -0
- esimu_core-0.2.0b5/esimu_core/world/prompts.py +74 -0
- esimu_core-0.2.0b5/esimu_core/world/stat_definitions.py +302 -0
- esimu_core-0.2.0b5/esimu_core/world/story.py +148 -0
- esimu_core-0.2.0b5/esimu_core/world/theme.py +161 -0
- esimu_core-0.2.0b5/esimu_core/world/theme_contract.py +571 -0
- esimu_core-0.2.0b5/esimu_core/world/theme_paths.py +125 -0
- esimu_core-0.2.0b5/esimu_core/world/validation.py +174 -0
- esimu_core-0.2.0b5/esimu_core.egg-info/PKG-INFO +58 -0
- esimu_core-0.2.0b5/esimu_core.egg-info/SOURCES.txt +64 -0
- esimu_core-0.2.0b5/esimu_core.egg-info/dependency_links.txt +1 -0
- esimu_core-0.2.0b5/esimu_core.egg-info/entry_points.txt +3 -0
- esimu_core-0.2.0b5/esimu_core.egg-info/requires.txt +12 -0
- esimu_core-0.2.0b5/esimu_core.egg-info/top_level.txt +1 -0
- esimu_core-0.2.0b5/pyproject.toml +66 -0
- esimu_core-0.2.0b5/setup.cfg +4 -0
- esimu_core-0.2.0b5/tests/test_ai_core.py +264 -0
- esimu_core-0.2.0b5/tests/test_cli.py +36 -0
- esimu_core-0.2.0b5/tests/test_content_contracts.py +121 -0
- esimu_core-0.2.0b5/tests/test_demo_theme_smoke.py +150 -0
- esimu_core-0.2.0b5/tests/test_domain_actions.py +44 -0
- esimu_core-0.2.0b5/tests/test_domain_effects.py +115 -0
- esimu_core-0.2.0b5/tests/test_domain_semester.py +108 -0
- esimu_core-0.2.0b5/tests/test_lifecycle_contracts.py +107 -0
- esimu_core-0.2.0b5/tests/test_package_metadata.py +25 -0
- esimu_core-0.2.0b5/tests/test_project_bootstrap.py +144 -0
- esimu_core-0.2.0b5/tests/test_runtime_actions.py +31 -0
- esimu_core-0.2.0b5/tests/test_runtime_clock.py +22 -0
- esimu_core-0.2.0b5/tests/test_runtime_cooldowns.py +25 -0
- esimu_core-0.2.0b5/tests/test_runtime_snapshot.py +90 -0
- esimu_core-0.2.0b5/tests/test_runtime_state.py +37 -0
- esimu_core-0.2.0b5/tests/test_runtime_tasks.py +52 -0
- esimu_core-0.2.0b5/tests/test_theme_contract.py +147 -0
- esimu_core-0.2.0b5/tests/test_theme_prompts.py +26 -0
- esimu_core-0.2.0b5/tests/test_world_catalog.py +21 -0
- esimu_core-0.2.0b5/tests/test_world_validation.py +16 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pirate-608
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: esimu-core
|
|
3
|
+
Version: 0.2.0b5
|
|
4
|
+
Summary: Theme-driven core and project tooling for the esimu simulator framework.
|
|
5
|
+
Author: pirate-608
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://esimu.67656.fun/
|
|
8
|
+
Project-URL: Documentation, https://esimu.67656.fun/
|
|
9
|
+
Project-URL: Repository, https://github.com/pirate-608/esimu
|
|
10
|
+
Project-URL: Issues, https://github.com/pirate-608/esimu/issues
|
|
11
|
+
Keywords: simulation,game,framework,theme-pack
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: pydantic>=2
|
|
23
|
+
Provides-Extra: ai
|
|
24
|
+
Requires-Dist: openai>=1.12; extra == "ai"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: aiosqlite>=0.20; extra == "dev"
|
|
27
|
+
Requires-Dist: fastapi>=0.115; extra == "dev"
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff==0.16.2; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# esimu-core
|
|
35
|
+
|
|
36
|
+
`esimu-core` is the typed, I/O-independent core and project CLI for esimu.
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
python -m pip install "esimu-core[ai]==0.2.0b5"
|
|
40
|
+
esimu version
|
|
41
|
+
esimu new D:\projects\my-simulator --theme-id my-simulator
|
|
42
|
+
esimu validate --root D:\projects\my-simulator --theme my-simulator
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The base dependency is Pydantic. The optional `ai` extra adds the OpenAI SDK
|
|
46
|
+
transport. FastAPI, SQLite, WebSocket, and Vue live in the generated Starter,
|
|
47
|
+
not in the core package.
|
|
48
|
+
|
|
49
|
+
Core owns:
|
|
50
|
+
|
|
51
|
+
- theme and world-data contracts;
|
|
52
|
+
- stat, item, effect, semester, and lifecycle rules;
|
|
53
|
+
- runtime timing, state, payload, cooldown, and task helpers;
|
|
54
|
+
- neutral event, forum, and messenger content contracts;
|
|
55
|
+
- optional AI configuration, parsing, generation, and fallback policy;
|
|
56
|
+
- the self-contained `esimu new` scaffold bundle.
|
|
57
|
+
|
|
58
|
+
See `https://esimu.67656.fun/` for the authoring and compatibility guides.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# esimu-core
|
|
2
|
+
|
|
3
|
+
`esimu-core` is the typed, I/O-independent core and project CLI for esimu.
|
|
4
|
+
|
|
5
|
+
```powershell
|
|
6
|
+
python -m pip install "esimu-core[ai]==0.2.0b5"
|
|
7
|
+
esimu version
|
|
8
|
+
esimu new D:\projects\my-simulator --theme-id my-simulator
|
|
9
|
+
esimu validate --root D:\projects\my-simulator --theme my-simulator
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The base dependency is Pydantic. The optional `ai` extra adds the OpenAI SDK
|
|
13
|
+
transport. FastAPI, SQLite, WebSocket, and Vue live in the generated Starter,
|
|
14
|
+
not in the core package.
|
|
15
|
+
|
|
16
|
+
Core owns:
|
|
17
|
+
|
|
18
|
+
- theme and world-data contracts;
|
|
19
|
+
- stat, item, effect, semester, and lifecycle rules;
|
|
20
|
+
- runtime timing, state, payload, cooldown, and task helpers;
|
|
21
|
+
- neutral event, forum, and messenger content contracts;
|
|
22
|
+
- optional AI configuration, parsing, generation, and fallback policy;
|
|
23
|
+
- the self-contained `esimu new` scaffold bundle.
|
|
24
|
+
|
|
25
|
+
See `https://esimu.67656.fun/` for the authoring and compatibility guides.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Simulator framework backend core package.
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pirate-608. Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.2.0b5"
|
|
7
|
+
|
|
8
|
+
THEME_SCHEMA_VERSION = 1
|
|
9
|
+
STATE_VERSION = 1
|
|
10
|
+
PROTOCOL_VERSION = 1
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"PROTOCOL_VERSION",
|
|
14
|
+
"STATE_VERSION",
|
|
15
|
+
"THEME_SCHEMA_VERSION",
|
|
16
|
+
"__version__",
|
|
17
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Optional AI integration for esimu simulator adapters.
|
|
2
|
+
|
|
3
|
+
The package is safe to import without the OpenAI SDK. Constructing
|
|
4
|
+
``OpenAICompatibleTransport`` requires the ``esimu-core[ai]`` extra.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from esimu_core.ai.config import (
|
|
8
|
+
AIModelConfig,
|
|
9
|
+
generic_model_config_from_env,
|
|
10
|
+
roleplay_model_config_from_env,
|
|
11
|
+
)
|
|
12
|
+
from esimu_core.ai.policy import ContentMode, ResolvedContent, resolve_content
|
|
13
|
+
from esimu_core.ai.service import AIContentService
|
|
14
|
+
from esimu_core.ai.transport import (
|
|
15
|
+
ChatTransport,
|
|
16
|
+
OpenAICompatibleTransport,
|
|
17
|
+
OpenAITransportRegistry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AIContentService",
|
|
22
|
+
"AIModelConfig",
|
|
23
|
+
"ChatTransport",
|
|
24
|
+
"ContentMode",
|
|
25
|
+
"OpenAICompatibleTransport",
|
|
26
|
+
"OpenAITransportRegistry",
|
|
27
|
+
"ResolvedContent",
|
|
28
|
+
"generic_model_config_from_env",
|
|
29
|
+
"resolve_content",
|
|
30
|
+
"roleplay_model_config_from_env",
|
|
31
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Model configuration for the optional esimu AI integration.
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pirate-608. Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Literal, Mapping
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
PROVIDER_BASE_URLS: dict[str, str] = {
|
|
14
|
+
"openai": "https://api.openai.com/v1",
|
|
15
|
+
"deepseek": "https://api.deepseek.com",
|
|
16
|
+
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
17
|
+
"glm": "https://open.bigmodel.cn/api/paas/v4",
|
|
18
|
+
"moonshot": "https://api.moonshot.cn/v1",
|
|
19
|
+
"minimax": "https://api.minimaxi.com/v1",
|
|
20
|
+
"ollama": "http://127.0.0.1:11434/v1",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
RoleProfile = Literal["generic", "minimax_m2her"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AIModelConfig(BaseModel):
|
|
27
|
+
"""One OpenAI-compatible model endpoint without application persistence."""
|
|
28
|
+
|
|
29
|
+
provider: str = "openai"
|
|
30
|
+
model: str = "gpt-4o-mini"
|
|
31
|
+
api_key: str | None = Field(default=None, repr=False)
|
|
32
|
+
base_url: str | None = None
|
|
33
|
+
timeout_seconds: float = Field(default=20.0, gt=0, le=300)
|
|
34
|
+
role_profile: RoleProfile = "generic"
|
|
35
|
+
|
|
36
|
+
@field_validator("provider", "model")
|
|
37
|
+
@classmethod
|
|
38
|
+
def strip_required_text(cls, value: str) -> str:
|
|
39
|
+
"""Normalize required model identifiers."""
|
|
40
|
+
normalized = value.strip()
|
|
41
|
+
if not normalized:
|
|
42
|
+
raise ValueError("model provider/name cannot be empty")
|
|
43
|
+
return normalized
|
|
44
|
+
|
|
45
|
+
@field_validator("base_url")
|
|
46
|
+
@classmethod
|
|
47
|
+
def normalize_base_url(cls, value: str | None) -> str | None:
|
|
48
|
+
"""Accept both SDK base URLs and older full completion endpoints."""
|
|
49
|
+
if value is None:
|
|
50
|
+
return None
|
|
51
|
+
normalized = value.strip().rstrip("/")
|
|
52
|
+
for suffix in ("/chat/completions", "/text/chatcompletion_v2"):
|
|
53
|
+
if normalized.endswith(suffix):
|
|
54
|
+
normalized = normalized[: -len(suffix)].rstrip("/")
|
|
55
|
+
return normalized or None
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="after")
|
|
58
|
+
def fill_provider_url(self) -> "AIModelConfig":
|
|
59
|
+
"""Use a known provider URL when the caller omitted an override."""
|
|
60
|
+
if self.base_url is None:
|
|
61
|
+
self.base_url = PROVIDER_BASE_URLS.get(self.provider.lower())
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_configured(self) -> bool:
|
|
66
|
+
"""Return whether the endpoint has enough information to be attempted."""
|
|
67
|
+
return bool(self.api_key or self.provider.lower() == "ollama")
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_env(
|
|
71
|
+
cls,
|
|
72
|
+
prefix: str = "ESIMU_LLM",
|
|
73
|
+
*,
|
|
74
|
+
environ: Mapping[str, str] | None = None,
|
|
75
|
+
default_provider: str = "openai",
|
|
76
|
+
default_model: str = "gpt-4o-mini",
|
|
77
|
+
role_profile: RoleProfile = "generic",
|
|
78
|
+
) -> "AIModelConfig":
|
|
79
|
+
"""Load a model endpoint from a namespaced environment mapping."""
|
|
80
|
+
values = os.environ if environ is None else environ
|
|
81
|
+
provider = values.get(f"{prefix}_PROVIDER", default_provider)
|
|
82
|
+
timeout_raw = values.get(f"{prefix}_TIMEOUT_SECONDS", "20")
|
|
83
|
+
try:
|
|
84
|
+
timeout = float(timeout_raw)
|
|
85
|
+
except ValueError:
|
|
86
|
+
timeout = 20.0
|
|
87
|
+
return cls(
|
|
88
|
+
provider=provider,
|
|
89
|
+
model=values.get(f"{prefix}_MODEL", default_model),
|
|
90
|
+
api_key=values.get(f"{prefix}_API_KEY") or None,
|
|
91
|
+
base_url=values.get(f"{prefix}_BASE_URL") or None,
|
|
92
|
+
timeout_seconds=timeout,
|
|
93
|
+
role_profile=role_profile,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def generic_model_config_from_env(
|
|
98
|
+
environ: Mapping[str, str] | None = None,
|
|
99
|
+
) -> AIModelConfig:
|
|
100
|
+
"""Load the framework's general content model configuration."""
|
|
101
|
+
return AIModelConfig.from_env("ESIMU_LLM", environ=environ)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def roleplay_model_config_from_env(
|
|
105
|
+
environ: Mapping[str, str] | None = None,
|
|
106
|
+
) -> AIModelConfig:
|
|
107
|
+
"""Load the optional MiniMax M2-her role-play model configuration."""
|
|
108
|
+
return AIModelConfig.from_env(
|
|
109
|
+
"ESIMU_RP",
|
|
110
|
+
environ=environ,
|
|
111
|
+
default_provider="minimax",
|
|
112
|
+
default_model="M2-her",
|
|
113
|
+
role_profile="minimax_m2her",
|
|
114
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Defensive parsing helpers for model-generated content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def json_object_from_text(content: object) -> dict[str, Any]:
|
|
10
|
+
"""Extract one JSON object from plain text or a fenced response."""
|
|
11
|
+
if not isinstance(content, str) or not content.strip():
|
|
12
|
+
return {}
|
|
13
|
+
text = content.strip()
|
|
14
|
+
if text.startswith("```"):
|
|
15
|
+
lines = text.splitlines()
|
|
16
|
+
if lines:
|
|
17
|
+
lines = lines[1:]
|
|
18
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
19
|
+
lines = lines[:-1]
|
|
20
|
+
text = "\n".join(lines).strip()
|
|
21
|
+
start = text.find("{")
|
|
22
|
+
end = text.rfind("}")
|
|
23
|
+
if start >= 0 and end >= start:
|
|
24
|
+
text = text[start : end + 1]
|
|
25
|
+
try:
|
|
26
|
+
value = json.loads(text)
|
|
27
|
+
except (json.JSONDecodeError, TypeError):
|
|
28
|
+
return {}
|
|
29
|
+
return value if isinstance(value, dict) else {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def string_list(value: object, *, limit: int | None = None) -> list[str]:
|
|
33
|
+
"""Return non-empty strings from an untrusted model value."""
|
|
34
|
+
if not isinstance(value, list):
|
|
35
|
+
return []
|
|
36
|
+
result = [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
|
37
|
+
return result[:limit] if limit is not None else result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dict_list(value: object, *, limit: int | None = None) -> list[dict[str, Any]]:
|
|
41
|
+
"""Return dictionary entries from an untrusted model value."""
|
|
42
|
+
if not isinstance(value, list):
|
|
43
|
+
return []
|
|
44
|
+
result = [item for item in value if isinstance(item, dict)]
|
|
45
|
+
return result[:limit] if limit is not None else result
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Content-mode fallback policy shared by simulator adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Awaitable, Callable, Generic, Literal, TypeVar
|
|
9
|
+
|
|
10
|
+
ContentMode = Literal["library", "hybrid", "ai"]
|
|
11
|
+
ContentSource = Literal["library", "ai", "fallback"]
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ResolvedContent(Generic[T]):
|
|
17
|
+
"""One resolved content value plus degradation metadata."""
|
|
18
|
+
|
|
19
|
+
value: T | None
|
|
20
|
+
source: ContentSource
|
|
21
|
+
degraded: bool = False
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _call(factory: Callable[[], T | Awaitable[T] | None]) -> T | None:
|
|
26
|
+
value = factory()
|
|
27
|
+
if inspect.isawaitable(value):
|
|
28
|
+
return await value
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def resolve_content(
|
|
33
|
+
mode: ContentMode,
|
|
34
|
+
*,
|
|
35
|
+
ai_factory: Callable[[], T | Awaitable[T] | None] | None,
|
|
36
|
+
library_factory: Callable[[], T | Awaitable[T] | None],
|
|
37
|
+
hybrid_ai_probability: float = 0.35,
|
|
38
|
+
random_value: Callable[[], float] = random.random,
|
|
39
|
+
) -> ResolvedContent[T]:
|
|
40
|
+
"""Resolve AI/library content with deterministic degradation semantics.
|
|
41
|
+
|
|
42
|
+
Library mode never calls a model. AI mode tries the model first and falls
|
|
43
|
+
back locally. Hybrid mode chooses AI with the configured probability and
|
|
44
|
+
otherwise prefers the library, still using the other source if needed.
|
|
45
|
+
"""
|
|
46
|
+
if mode not in {"library", "hybrid", "ai"}:
|
|
47
|
+
raise ValueError(f"unsupported content mode: {mode}")
|
|
48
|
+
probability = max(0.0, min(1.0, hybrid_ai_probability))
|
|
49
|
+
ai_first = mode == "ai" or (
|
|
50
|
+
mode == "hybrid" and ai_factory is not None and random_value() < probability
|
|
51
|
+
)
|
|
52
|
+
if mode == "library" or ai_factory is None:
|
|
53
|
+
return ResolvedContent(await _call(library_factory), "library")
|
|
54
|
+
|
|
55
|
+
first_name: ContentSource = "ai" if ai_first else "library"
|
|
56
|
+
second_name: ContentSource = "library" if ai_first else "ai"
|
|
57
|
+
first = ai_factory if ai_first else library_factory
|
|
58
|
+
second = library_factory if ai_first else ai_factory
|
|
59
|
+
first_error: str | None = None
|
|
60
|
+
try:
|
|
61
|
+
value = await _call(first)
|
|
62
|
+
if value is not None:
|
|
63
|
+
return ResolvedContent(value, first_name)
|
|
64
|
+
except Exception as exc: # adapters decide how to log/display degradation
|
|
65
|
+
first_error = str(exc)
|
|
66
|
+
try:
|
|
67
|
+
value = await _call(second)
|
|
68
|
+
return ResolvedContent(
|
|
69
|
+
value,
|
|
70
|
+
second_name if value is not None else "fallback",
|
|
71
|
+
degraded=first_name == "ai",
|
|
72
|
+
error=first_error,
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
return ResolvedContent(
|
|
76
|
+
None,
|
|
77
|
+
"fallback",
|
|
78
|
+
degraded=first_name == "ai",
|
|
79
|
+
error=first_error or str(exc),
|
|
80
|
+
)
|