conjured 0.0.1__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.
- conjured-0.0.1/PKG-INFO +7 -0
- conjured-0.0.1/conjured/__init__.py +0 -0
- conjured-0.0.1/conjured/core/__init__.py +10 -0
- conjured-0.0.1/conjured/core/artifacts.py +98 -0
- conjured-0.0.1/conjured/core/config.py +53 -0
- conjured-0.0.1/conjured/core/logging.py +330 -0
- conjured-0.0.1/conjured/core/parsing.py +37 -0
- conjured-0.0.1/conjured/core/paths.py +35 -0
- conjured-0.0.1/conjured/core/utils.py +20 -0
- conjured-0.0.1/conjured/db/__init__.py +0 -0
- conjured-0.0.1/conjured/db/database.py +144 -0
- conjured-0.0.1/conjured/facade/__init__.py +0 -0
- conjured-0.0.1/conjured/facade/orchestrator.py +191 -0
- conjured-0.0.1/conjured/facade/patience.py +65 -0
- conjured-0.0.1/conjured/facade/session_store.py +104 -0
- conjured-0.0.1/conjured/game/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/__init__.py +7 -0
- conjured-0.0.1/conjured/handlers/analysis/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/analysis/length_hint.py +69 -0
- conjured-0.0.1/conjured/handlers/analysis/process_emotes.py +380 -0
- conjured-0.0.1/conjured/handlers/generate/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/generate/schemas.py +96 -0
- conjured-0.0.1/conjured/handlers/import/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/knowledge/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/prompt/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/result/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/state/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/utils/__init__.py +0 -0
- conjured-0.0.1/conjured/handlers/utils/conditions.py +134 -0
- conjured-0.0.1/conjured/handlers/utils/disfluency.py +95 -0
- conjured-0.0.1/conjured/handlers/utils/emote_probability.py +79 -0
- conjured-0.0.1/conjured/handlers/utils/entrainment.py +215 -0
- conjured-0.0.1/conjured/handlers/utils/mood.py +280 -0
- conjured-0.0.1/conjured/handlers/utils/parse_response.py +90 -0
- conjured-0.0.1/conjured/handlers/utils/stimulus.py +212 -0
- conjured-0.0.1/conjured/memory/__init__.py +0 -0
- conjured-0.0.1/conjured/memory/semantic_cache.py +179 -0
- conjured-0.0.1/conjured/ml/__init__.py +0 -0
- conjured-0.0.1/conjured/ml/classifiers.py +281 -0
- conjured-0.0.1/conjured/ml/segmenter.py +143 -0
- conjured-0.0.1/conjured/ml/spacy.py +34 -0
- conjured-0.0.1/conjured/npc/__init__.py +0 -0
- conjured-0.0.1/conjured/npc/agenda.py +174 -0
- conjured-0.0.1/conjured/npc/response_pools.py +231 -0
- conjured-0.0.1/conjured/pipeline/__init__.py +0 -0
- conjured-0.0.1/conjured/pipeline/context.py +131 -0
- conjured-0.0.1/conjured/pipeline/registry.py +215 -0
- conjured-0.0.1/conjured/routes/__init__.py +0 -0
- conjured-0.0.1/conjured/run_tests.py +92 -0
- conjured-0.0.1/conjured/testing/__init__.py +8 -0
- conjured-0.0.1/conjured/testing/config.py +35 -0
- conjured-0.0.1/conjured/testing/context.py +60 -0
- conjured-0.0.1/conjured/testing/fixtures.py +24 -0
- conjured-0.0.1/conjured/tools/__init__.py +0 -0
- conjured-0.0.1/conjured/tools/compile.py +218 -0
- conjured-0.0.1/conjured.egg-info/PKG-INFO +7 -0
- conjured-0.0.1/conjured.egg-info/SOURCES.txt +63 -0
- conjured-0.0.1/conjured.egg-info/dependency_links.txt +1 -0
- conjured-0.0.1/conjured.egg-info/top_level.txt +1 -0
- conjured-0.0.1/pyproject.toml +16 -0
- conjured-0.0.1/setup.cfg +4 -0
- conjured-0.0.1/tests/test_import_boundary.py +52 -0
- conjured-0.0.1/tests/test_no_legacy_files.py +108 -0
- conjured-0.0.1/tests/test_package_boundary.py +86 -0
- conjured-0.0.1/tests/test_template_completeness.py +51 -0
conjured-0.0.1/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared utilities — no project-internal imports. Any layer can use these.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from conjured.core import get_logger, load_toml, PROJECT_ROOT
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from conjured.core.config import load_toml, get_nested, invalidate
|
|
8
|
+
from conjured.core.logging import get_logger, configure_logging, compute_profile, check_budget, format_report
|
|
9
|
+
from conjured.core.paths import PROJECT_ROOT, PACKAGE_ROOT, CONFIG_DIR, MODELS_DIR
|
|
10
|
+
from conjured.core.utils import deep_merge
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Artifact registry and runtime loader.
|
|
2
|
+
|
|
3
|
+
Registry for compiled artifacts (pre-processed game config → optimized
|
|
4
|
+
runtime data). The build tool (tools/compile.py) compiles artifacts.
|
|
5
|
+
Handlers load them at runtime via load_artifact().
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from conjured.core.artifacts import register_artifact, load_artifact
|
|
9
|
+
|
|
10
|
+
@register_artifact("entity_dict")
|
|
11
|
+
class EntityDict:
|
|
12
|
+
name = "entity_dict"
|
|
13
|
+
def source_hash(self) -> str: ...
|
|
14
|
+
def compile(self, output_dir: Path) -> None: ...
|
|
15
|
+
def load(self, compiled_dir: Path) -> Any: ...
|
|
16
|
+
|
|
17
|
+
# At runtime (in a handler):
|
|
18
|
+
data = load_artifact("entity_dict")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from conjured.core.logging import get_logger
|
|
29
|
+
from conjured.core.paths import PROJECT_ROOT
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
COMPILED_DIR = PROJECT_ROOT / "compiled"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ArtifactMeta:
|
|
38
|
+
"""Registry entry for a compiled artifact."""
|
|
39
|
+
name: str
|
|
40
|
+
compiler: object # has source_hash(), compile(), load() methods
|
|
41
|
+
_instance: Any = field(default=None, repr=False)
|
|
42
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# The registry — populated by @register_artifact
|
|
46
|
+
ARTIFACTS: dict[str, ArtifactMeta] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register_artifact(name: str):
|
|
50
|
+
"""Decorator that registers an artifact compiler class.
|
|
51
|
+
|
|
52
|
+
The class must implement:
|
|
53
|
+
name: str
|
|
54
|
+
source_hash() -> str
|
|
55
|
+
compile(output_dir: Path) -> None
|
|
56
|
+
load(compiled_dir: Path) -> Any
|
|
57
|
+
"""
|
|
58
|
+
def decorator(cls):
|
|
59
|
+
ARTIFACTS[name] = ArtifactMeta(name=name, compiler=cls())
|
|
60
|
+
return cls
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_artifact(name: str) -> Any:
|
|
65
|
+
"""Load a compiled artifact, using a cached singleton.
|
|
66
|
+
|
|
67
|
+
Thread-safe via per-artifact Lock with double-checked locking.
|
|
68
|
+
Raises FileNotFoundError if artifact is not compiled.
|
|
69
|
+
"""
|
|
70
|
+
if name not in ARTIFACTS:
|
|
71
|
+
raise ValueError(f"Unknown artifact: {name}. Registered: {list(ARTIFACTS.keys())}")
|
|
72
|
+
|
|
73
|
+
meta = ARTIFACTS[name]
|
|
74
|
+
|
|
75
|
+
if meta._instance is not None:
|
|
76
|
+
return meta._instance
|
|
77
|
+
|
|
78
|
+
with meta._lock:
|
|
79
|
+
if meta._instance is not None:
|
|
80
|
+
return meta._instance
|
|
81
|
+
|
|
82
|
+
artifact_dir = COMPILED_DIR / name
|
|
83
|
+
if not artifact_dir.exists():
|
|
84
|
+
raise FileNotFoundError(
|
|
85
|
+
f"Artifact '{name}' not compiled. Run: python -m conjured.tools.compile"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
meta._instance = meta.compiler.load(COMPILED_DIR)
|
|
89
|
+
return meta._instance
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def invalidate(name: str | None = None) -> None:
|
|
93
|
+
"""Clear cached artifact instance(s), forcing reload on next access."""
|
|
94
|
+
targets = [name] if name else list(ARTIFACTS.keys())
|
|
95
|
+
for n in targets:
|
|
96
|
+
if n in ARTIFACTS:
|
|
97
|
+
with ARTIFACTS[n]._lock:
|
|
98
|
+
ARTIFACTS[n]._instance = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Generic TOML config loading with caching.
|
|
2
|
+
|
|
3
|
+
No engine, API, or game-specific logic. Any module can import this.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from conjured.core.config import load_toml, get_nested, invalidate
|
|
7
|
+
|
|
8
|
+
cfg = load_toml(Path("config/something.toml"))
|
|
9
|
+
val = get_nested(cfg, "section.key", default="fallback")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_cache: dict[Path, dict] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_toml(path: Path) -> dict:
|
|
19
|
+
"""Load and cache a TOML file. Returns the parsed dict.
|
|
20
|
+
|
|
21
|
+
Cache is keyed by resolved path, so symlinks and relative paths
|
|
22
|
+
resolve to the same entry. Call invalidate() to clear.
|
|
23
|
+
"""
|
|
24
|
+
key = path.resolve()
|
|
25
|
+
if key not in _cache:
|
|
26
|
+
with open(key, "rb") as f:
|
|
27
|
+
_cache[key] = tomllib.load(f)
|
|
28
|
+
return _cache[key]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_nested(config: dict, dotted_key: str, default=None):
|
|
32
|
+
"""Traverse a nested dict using dot-separated keys.
|
|
33
|
+
|
|
34
|
+
get_nested(cfg, "generation.temperature", default=0.7)
|
|
35
|
+
is equivalent to cfg.get("generation", {}).get("temperature", 0.7)
|
|
36
|
+
"""
|
|
37
|
+
parts = dotted_key.split(".")
|
|
38
|
+
current = config
|
|
39
|
+
for part in parts[:-1]:
|
|
40
|
+
if not isinstance(current, dict):
|
|
41
|
+
return default
|
|
42
|
+
current = current.get(part, {})
|
|
43
|
+
if not isinstance(current, dict):
|
|
44
|
+
return default
|
|
45
|
+
return current.get(parts[-1], default)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def invalidate(path: Path | None = None):
|
|
49
|
+
"""Clear cached config. If path given, clear only that file. Otherwise clear all."""
|
|
50
|
+
if path is not None:
|
|
51
|
+
_cache.pop(path.resolve(), None)
|
|
52
|
+
else:
|
|
53
|
+
_cache.clear()
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Structured logging, latency profiling, and timing capture.
|
|
2
|
+
|
|
3
|
+
No engine, API, or game-specific logic. Any module can import this.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from conjured.core.logging import get_logger
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
logger.info("event_name", key=value)
|
|
9
|
+
|
|
10
|
+
Configure output format via RPCHAT_LOG_FORMAT env var:
|
|
11
|
+
"console" (default) — human-readable colored output
|
|
12
|
+
"json" — structured JSON lines (for production/parsing)
|
|
13
|
+
|
|
14
|
+
Log files:
|
|
15
|
+
QA/{RPCHAT_QA_ENV}/logs/rpchat.log — JSON lines, rotates at 5 MB, keeps 50 files.
|
|
16
|
+
QA/{RPCHAT_QA_ENV}/turn-timings.jsonl — per-turn timing records.
|
|
17
|
+
RPCHAT_QA_ENV defaults to "default" if unset.
|
|
18
|
+
|
|
19
|
+
Latency profiling:
|
|
20
|
+
from conjured.core.logging import compute_profile, check_budget, format_report
|
|
21
|
+
profile = compute_profile(list_of_timing_dicts)
|
|
22
|
+
report = format_report(profile, "archetype_companion", budget_ms=6000)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import logging
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from logging.handlers import RotatingFileHandler
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from dotenv import load_dotenv
|
|
35
|
+
import structlog
|
|
36
|
+
|
|
37
|
+
load_dotenv()
|
|
38
|
+
|
|
39
|
+
from conjured.core.paths import PROJECT_ROOT
|
|
40
|
+
|
|
41
|
+
QA_ENV = os.environ.get("RPCHAT_QA_ENV", "default")
|
|
42
|
+
_QA_DIR = PROJECT_ROOT / "QA"
|
|
43
|
+
_LOG_DIR = _QA_DIR / QA_ENV / "logs"
|
|
44
|
+
_TIMING_DIR = _QA_DIR / QA_ENV
|
|
45
|
+
_TIMING_PATH = _TIMING_DIR / "turn-timings.jsonl"
|
|
46
|
+
|
|
47
|
+
# Noisy third-party loggers — suppress to WARNING
|
|
48
|
+
_QUIET_LOGGERS = [
|
|
49
|
+
"uvicorn.access",
|
|
50
|
+
"uvicorn.error",
|
|
51
|
+
"httpx",
|
|
52
|
+
"httpcore",
|
|
53
|
+
"sentence_transformers",
|
|
54
|
+
"transformers",
|
|
55
|
+
"onnxruntime",
|
|
56
|
+
"multipart",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def configure_logging():
|
|
61
|
+
"""Set up structlog with console + rotating file output.
|
|
62
|
+
|
|
63
|
+
Console: human-readable or JSON (via RPCHAT_LOG_FORMAT).
|
|
64
|
+
File: always JSON lines at QA/{env}/logs/rpchat.log, 5 MB per file, 50 kept (~250 MB max).
|
|
65
|
+
"""
|
|
66
|
+
log_format = os.environ.get("RPCHAT_LOG_FORMAT", "console")
|
|
67
|
+
|
|
68
|
+
shared_processors = [
|
|
69
|
+
structlog.contextvars.merge_contextvars,
|
|
70
|
+
structlog.stdlib.add_log_level,
|
|
71
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
if log_format == "json":
|
|
75
|
+
console_renderer = structlog.processors.JSONRenderer()
|
|
76
|
+
else:
|
|
77
|
+
console_renderer = structlog.dev.ConsoleRenderer()
|
|
78
|
+
|
|
79
|
+
structlog.configure(
|
|
80
|
+
processors=[
|
|
81
|
+
*shared_processors,
|
|
82
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
83
|
+
],
|
|
84
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
85
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
86
|
+
cache_logger_on_first_use=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
console_formatter = structlog.stdlib.ProcessorFormatter(
|
|
90
|
+
processors=[
|
|
91
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
92
|
+
console_renderer,
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
console_handler = logging.StreamHandler()
|
|
96
|
+
console_handler.setFormatter(console_formatter)
|
|
97
|
+
|
|
98
|
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
log_path = _LOG_DIR / "rpchat.log"
|
|
100
|
+
file_formatter = structlog.stdlib.ProcessorFormatter(
|
|
101
|
+
processors=[
|
|
102
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
103
|
+
structlog.processors.JSONRenderer(),
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
file_handler = RotatingFileHandler(
|
|
107
|
+
str(log_path), maxBytes=5_000_000, backupCount=50, encoding="utf-8",
|
|
108
|
+
)
|
|
109
|
+
file_handler.setFormatter(file_formatter)
|
|
110
|
+
|
|
111
|
+
root = logging.getLogger()
|
|
112
|
+
root.handlers.clear()
|
|
113
|
+
root.addHandler(console_handler)
|
|
114
|
+
root.addHandler(file_handler)
|
|
115
|
+
root.setLevel(logging.INFO)
|
|
116
|
+
|
|
117
|
+
for name in _QUIET_LOGGERS:
|
|
118
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
122
|
+
"""Return a named structlog logger."""
|
|
123
|
+
return structlog.get_logger(name)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Turn timing capture
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def record_turn_timing(
|
|
131
|
+
pipeline_name: str,
|
|
132
|
+
handler_timings: dict,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Append a single timing record to the QA turn-timings JSONL file.
|
|
135
|
+
|
|
136
|
+
Called by the pipeline runner after each turn completes. Failure is
|
|
137
|
+
silent (warning log) — timing capture must never break gameplay.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
pipeline_name: Name of the pipeline that ran (from config).
|
|
141
|
+
handler_timings: {handler_name: elapsed_ms} dict accumulated by the runner.
|
|
142
|
+
"""
|
|
143
|
+
_logger = get_logger(__name__)
|
|
144
|
+
try:
|
|
145
|
+
total_ms = sum(v for v in handler_timings.values() if isinstance(v, (int, float)))
|
|
146
|
+
record = {
|
|
147
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
148
|
+
"pipeline": pipeline_name,
|
|
149
|
+
"total_ms": total_ms,
|
|
150
|
+
**handler_timings,
|
|
151
|
+
}
|
|
152
|
+
os.makedirs(_TIMING_DIR, exist_ok=True)
|
|
153
|
+
with open(_TIMING_PATH, "a", encoding="utf-8") as f:
|
|
154
|
+
f.write(json.dumps(record) + "\n")
|
|
155
|
+
except Exception:
|
|
156
|
+
_logger.warning("qa_timing_write_failed", path=str(_TIMING_PATH), exc_info=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Latency profiling
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class LatencyStats:
|
|
165
|
+
"""Aggregated latency statistics for a single handler or total pipeline."""
|
|
166
|
+
mean: float = 0.0
|
|
167
|
+
p50: float = 0.0
|
|
168
|
+
p95: float = 0.0
|
|
169
|
+
p99: float = 0.0
|
|
170
|
+
min: float = 0.0
|
|
171
|
+
max: float = 0.0
|
|
172
|
+
count: int = 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compute_handler_stats(values: list[float]) -> LatencyStats:
|
|
176
|
+
"""Compute percentile statistics from a list of ms values."""
|
|
177
|
+
if not values:
|
|
178
|
+
return LatencyStats()
|
|
179
|
+
|
|
180
|
+
sorted_vals = sorted(values)
|
|
181
|
+
n = len(sorted_vals)
|
|
182
|
+
|
|
183
|
+
return LatencyStats(
|
|
184
|
+
mean=sum(sorted_vals) / n,
|
|
185
|
+
p50=_percentile(sorted_vals, 50),
|
|
186
|
+
p95=_percentile(sorted_vals, 95),
|
|
187
|
+
p99=_percentile(sorted_vals, 99),
|
|
188
|
+
min=sorted_vals[0],
|
|
189
|
+
max=sorted_vals[-1],
|
|
190
|
+
count=n,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def compute_profile(timing_dicts: list[dict]) -> dict[str, LatencyStats]:
|
|
195
|
+
"""Compute per-handler stats from a list of timing dicts.
|
|
196
|
+
|
|
197
|
+
Each dict has keys like "gate_ms", "identity_ms", "generation_ms", etc.
|
|
198
|
+
Keys ending in "_ms" are treated as handler timings. Also computes a
|
|
199
|
+
"total" entry from the sum of all handlers per dict.
|
|
200
|
+
|
|
201
|
+
Returns: {handler_name: LatencyStats, ..., "total": LatencyStats}
|
|
202
|
+
"""
|
|
203
|
+
if not timing_dicts:
|
|
204
|
+
return {}
|
|
205
|
+
|
|
206
|
+
handler_values: dict[str, list[float]] = {}
|
|
207
|
+
totals: list[float] = []
|
|
208
|
+
|
|
209
|
+
for td in timing_dicts:
|
|
210
|
+
turn_total = 0.0
|
|
211
|
+
for key, val in td.items():
|
|
212
|
+
if not isinstance(val, (int, float)):
|
|
213
|
+
continue
|
|
214
|
+
name = key[:-3] if key.endswith("_ms") else key
|
|
215
|
+
handler_values.setdefault(name, []).append(float(val))
|
|
216
|
+
turn_total += float(val)
|
|
217
|
+
totals.append(turn_total)
|
|
218
|
+
|
|
219
|
+
profile = {name: compute_handler_stats(vals) for name, vals in handler_values.items()}
|
|
220
|
+
profile["total"] = compute_handler_stats(totals)
|
|
221
|
+
|
|
222
|
+
return profile
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_budget(total_ms: float, budget_ms: float) -> bool:
|
|
226
|
+
"""Returns True if total_ms exceeds the budget."""
|
|
227
|
+
return total_ms > budget_ms
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_budget_for_pipeline(pipeline_name: str, budgets: dict) -> float | None:
|
|
231
|
+
"""Look up the latency budget for a pipeline name.
|
|
232
|
+
|
|
233
|
+
Tries exact match first, then longest prefix match, then "default".
|
|
234
|
+
Config is a plain dict: {"archetype_companion": 6000, "archetype": 5000, "default": 8000}
|
|
235
|
+
|
|
236
|
+
Returns budget in ms, or None if no budget configured.
|
|
237
|
+
"""
|
|
238
|
+
if not budgets:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
if pipeline_name in budgets:
|
|
242
|
+
return float(budgets[pipeline_name])
|
|
243
|
+
|
|
244
|
+
# Longest prefix match
|
|
245
|
+
best_match = None
|
|
246
|
+
best_len = 0
|
|
247
|
+
for key, val in budgets.items():
|
|
248
|
+
if key == "default":
|
|
249
|
+
continue
|
|
250
|
+
if pipeline_name.startswith(key) and len(key) > best_len:
|
|
251
|
+
best_match = val
|
|
252
|
+
best_len = len(key)
|
|
253
|
+
|
|
254
|
+
if best_match is not None:
|
|
255
|
+
return float(best_match)
|
|
256
|
+
|
|
257
|
+
if "default" in budgets:
|
|
258
|
+
return float(budgets["default"])
|
|
259
|
+
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def format_report(
|
|
264
|
+
profile: dict[str, LatencyStats],
|
|
265
|
+
label: str,
|
|
266
|
+
budget_ms: float | None = None,
|
|
267
|
+
) -> str:
|
|
268
|
+
"""Format a latency profile as a text table."""
|
|
269
|
+
if not profile:
|
|
270
|
+
return f" {label}: no data\n"
|
|
271
|
+
|
|
272
|
+
lines = []
|
|
273
|
+
budget_str = f" (budget: {budget_ms:.0f}ms)" if budget_ms else ""
|
|
274
|
+
total = profile.get("total")
|
|
275
|
+
total_str = f" — total p50: {total.p50:.0f}ms" if total else ""
|
|
276
|
+
lines.append(f" {label}{budget_str}{total_str}")
|
|
277
|
+
lines.append(f" {'Handler':<20} {'Mean':>7} {'P50':>7} {'P95':>7} {'P99':>7} {'Min':>7} {'Max':>7} {'N':>5}")
|
|
278
|
+
lines.append(f" {'─' * 20} {'─' * 7} {'─' * 7} {'─' * 7} {'─' * 7} {'─' * 7} {'─' * 7} {'─' * 5}")
|
|
279
|
+
|
|
280
|
+
handler_order = sorted(k for k in profile if k != "total")
|
|
281
|
+
handler_order.append("total")
|
|
282
|
+
|
|
283
|
+
for handler in handler_order:
|
|
284
|
+
s = profile[handler]
|
|
285
|
+
marker = " *" if handler == "total" and budget_ms and s.p50 > budget_ms else ""
|
|
286
|
+
lines.append(
|
|
287
|
+
f" {handler:<20} {s.mean:>6.0f}ms {s.p50:>6.0f}ms {s.p95:>6.0f}ms "
|
|
288
|
+
f"{s.p99:>6.0f}ms {s.min:>6.0f}ms {s.max:>6.0f}ms {s.count:>5}{marker}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
lines.append("")
|
|
292
|
+
return "\n".join(lines)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def find_bottlenecks(
|
|
296
|
+
profiles: dict[str, dict[str, LatencyStats]],
|
|
297
|
+
top_n: int = 3,
|
|
298
|
+
) -> list[tuple[str, str, float]]:
|
|
299
|
+
"""Identify the top N slowest handlers across all pipelines.
|
|
300
|
+
|
|
301
|
+
Returns list of (pipeline, handler, p50_ms) tuples, sorted by p50 descending.
|
|
302
|
+
Excludes the "total" pseudo-entry.
|
|
303
|
+
"""
|
|
304
|
+
entries = []
|
|
305
|
+
for pipeline, profile in profiles.items():
|
|
306
|
+
for handler, stats in profile.items():
|
|
307
|
+
if handler == "total" or stats.count == 0:
|
|
308
|
+
continue
|
|
309
|
+
entries.append((pipeline, handler, stats.p50))
|
|
310
|
+
|
|
311
|
+
entries.sort(key=lambda x: x[2], reverse=True)
|
|
312
|
+
return entries[:top_n]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Helpers
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def _percentile(sorted_vals: list[float], pct: float) -> float:
|
|
320
|
+
"""Compute percentile from a pre-sorted list using linear interpolation."""
|
|
321
|
+
if not sorted_vals:
|
|
322
|
+
return 0.0
|
|
323
|
+
if len(sorted_vals) == 1:
|
|
324
|
+
return sorted_vals[0]
|
|
325
|
+
|
|
326
|
+
rank = (pct / 100.0) * (len(sorted_vals) - 1)
|
|
327
|
+
lower = int(math.floor(rank))
|
|
328
|
+
upper = min(lower + 1, len(sorted_vals) - 1)
|
|
329
|
+
frac = rank - lower
|
|
330
|
+
return sorted_vals[lower] + frac * (sorted_vals[upper] - sorted_vals[lower])
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Generic text and JSON parsing utilities.
|
|
2
|
+
|
|
3
|
+
No domain knowledge. Any project could use these.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from conjured.core.parsing import parse_json, strip_html_tags
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from conjured.core.logging import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
_HTML_TAG_RE = re.compile(r"</?[a-zA-Z][^>]*>")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def strip_html_tags(text: str) -> str:
|
|
20
|
+
"""Remove HTML tags that LLMs sometimes inject into dialogue/narration."""
|
|
21
|
+
return _HTML_TAG_RE.sub("", text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_json(raw: str) -> dict | None:
|
|
25
|
+
"""Strip markdown fences, thinking blocks, and parse JSON. Returns dict or None."""
|
|
26
|
+
try:
|
|
27
|
+
clean = raw.strip()
|
|
28
|
+
if "<think>" in clean:
|
|
29
|
+
clean = re.sub(r'<think>.*?</think>\s*', '', clean, flags=re.DOTALL).strip()
|
|
30
|
+
if clean.startswith("```"):
|
|
31
|
+
clean = clean.split("```")[1]
|
|
32
|
+
if clean.startswith("json"):
|
|
33
|
+
clean = clean[4:]
|
|
34
|
+
return json.loads(clean.strip())
|
|
35
|
+
except Exception:
|
|
36
|
+
logger.exception("json_parse_failed")
|
|
37
|
+
return None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Path resolution — single source of truth for the engine package.
|
|
2
|
+
|
|
3
|
+
Resolution order for CONFIG_DIR:
|
|
4
|
+
1. CONJURE_CONFIG_DIR env var (or .env)
|
|
5
|
+
2. conjured/config/ (ships with package — reference templates)
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from conjured.core.paths import CONFIG_DIR, MODELS_DIR, PROJECT_ROOT
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
# src/conjured/core/ → parent = src/conjured/
|
|
19
|
+
PACKAGE_ROOT = Path(__file__).parent.parent
|
|
20
|
+
|
|
21
|
+
# src/conjured/core/ → up to repo root
|
|
22
|
+
PROJECT_ROOT = PACKAGE_ROOT.parent.parent
|
|
23
|
+
|
|
24
|
+
# Config — inside the package (ships as reference templates)
|
|
25
|
+
# Games/consumers override via CONJURE_CONFIG_DIR env var or .env
|
|
26
|
+
CONFIG_DIR = Path(os.environ.get(
|
|
27
|
+
"CONJURE_CONFIG_DIR",
|
|
28
|
+
str(PACKAGE_ROOT / "config"),
|
|
29
|
+
))
|
|
30
|
+
|
|
31
|
+
# External paths — at repo root (not inside package)
|
|
32
|
+
MODELS_DIR = Path(os.environ.get(
|
|
33
|
+
"CONJURE_MODELS_DIR",
|
|
34
|
+
str(PROJECT_ROOT / "models"),
|
|
35
|
+
))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared utility functions used across layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def deep_merge(base: dict, overlay: dict) -> dict:
|
|
7
|
+
"""Recursively merge overlay into base. Overlay values win on conflict.
|
|
8
|
+
|
|
9
|
+
Lists are replaced (not appended). None values in overlay delete the key.
|
|
10
|
+
Returns a new dict — base is not mutated.
|
|
11
|
+
"""
|
|
12
|
+
result = dict(base)
|
|
13
|
+
for key, value in overlay.items():
|
|
14
|
+
if value is None:
|
|
15
|
+
result.pop(key, None)
|
|
16
|
+
elif isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
17
|
+
result[key] = deep_merge(result[key], value)
|
|
18
|
+
else:
|
|
19
|
+
result[key] = value
|
|
20
|
+
return result
|
|
File without changes
|