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.
Files changed (65) hide show
  1. conjured-0.0.1/PKG-INFO +7 -0
  2. conjured-0.0.1/conjured/__init__.py +0 -0
  3. conjured-0.0.1/conjured/core/__init__.py +10 -0
  4. conjured-0.0.1/conjured/core/artifacts.py +98 -0
  5. conjured-0.0.1/conjured/core/config.py +53 -0
  6. conjured-0.0.1/conjured/core/logging.py +330 -0
  7. conjured-0.0.1/conjured/core/parsing.py +37 -0
  8. conjured-0.0.1/conjured/core/paths.py +35 -0
  9. conjured-0.0.1/conjured/core/utils.py +20 -0
  10. conjured-0.0.1/conjured/db/__init__.py +0 -0
  11. conjured-0.0.1/conjured/db/database.py +144 -0
  12. conjured-0.0.1/conjured/facade/__init__.py +0 -0
  13. conjured-0.0.1/conjured/facade/orchestrator.py +191 -0
  14. conjured-0.0.1/conjured/facade/patience.py +65 -0
  15. conjured-0.0.1/conjured/facade/session_store.py +104 -0
  16. conjured-0.0.1/conjured/game/__init__.py +0 -0
  17. conjured-0.0.1/conjured/handlers/__init__.py +7 -0
  18. conjured-0.0.1/conjured/handlers/analysis/__init__.py +0 -0
  19. conjured-0.0.1/conjured/handlers/analysis/length_hint.py +69 -0
  20. conjured-0.0.1/conjured/handlers/analysis/process_emotes.py +380 -0
  21. conjured-0.0.1/conjured/handlers/generate/__init__.py +0 -0
  22. conjured-0.0.1/conjured/handlers/generate/schemas.py +96 -0
  23. conjured-0.0.1/conjured/handlers/import/__init__.py +0 -0
  24. conjured-0.0.1/conjured/handlers/knowledge/__init__.py +0 -0
  25. conjured-0.0.1/conjured/handlers/prompt/__init__.py +0 -0
  26. conjured-0.0.1/conjured/handlers/result/__init__.py +0 -0
  27. conjured-0.0.1/conjured/handlers/state/__init__.py +0 -0
  28. conjured-0.0.1/conjured/handlers/utils/__init__.py +0 -0
  29. conjured-0.0.1/conjured/handlers/utils/conditions.py +134 -0
  30. conjured-0.0.1/conjured/handlers/utils/disfluency.py +95 -0
  31. conjured-0.0.1/conjured/handlers/utils/emote_probability.py +79 -0
  32. conjured-0.0.1/conjured/handlers/utils/entrainment.py +215 -0
  33. conjured-0.0.1/conjured/handlers/utils/mood.py +280 -0
  34. conjured-0.0.1/conjured/handlers/utils/parse_response.py +90 -0
  35. conjured-0.0.1/conjured/handlers/utils/stimulus.py +212 -0
  36. conjured-0.0.1/conjured/memory/__init__.py +0 -0
  37. conjured-0.0.1/conjured/memory/semantic_cache.py +179 -0
  38. conjured-0.0.1/conjured/ml/__init__.py +0 -0
  39. conjured-0.0.1/conjured/ml/classifiers.py +281 -0
  40. conjured-0.0.1/conjured/ml/segmenter.py +143 -0
  41. conjured-0.0.1/conjured/ml/spacy.py +34 -0
  42. conjured-0.0.1/conjured/npc/__init__.py +0 -0
  43. conjured-0.0.1/conjured/npc/agenda.py +174 -0
  44. conjured-0.0.1/conjured/npc/response_pools.py +231 -0
  45. conjured-0.0.1/conjured/pipeline/__init__.py +0 -0
  46. conjured-0.0.1/conjured/pipeline/context.py +131 -0
  47. conjured-0.0.1/conjured/pipeline/registry.py +215 -0
  48. conjured-0.0.1/conjured/routes/__init__.py +0 -0
  49. conjured-0.0.1/conjured/run_tests.py +92 -0
  50. conjured-0.0.1/conjured/testing/__init__.py +8 -0
  51. conjured-0.0.1/conjured/testing/config.py +35 -0
  52. conjured-0.0.1/conjured/testing/context.py +60 -0
  53. conjured-0.0.1/conjured/testing/fixtures.py +24 -0
  54. conjured-0.0.1/conjured/tools/__init__.py +0 -0
  55. conjured-0.0.1/conjured/tools/compile.py +218 -0
  56. conjured-0.0.1/conjured.egg-info/PKG-INFO +7 -0
  57. conjured-0.0.1/conjured.egg-info/SOURCES.txt +63 -0
  58. conjured-0.0.1/conjured.egg-info/dependency_links.txt +1 -0
  59. conjured-0.0.1/conjured.egg-info/top_level.txt +1 -0
  60. conjured-0.0.1/pyproject.toml +16 -0
  61. conjured-0.0.1/setup.cfg +4 -0
  62. conjured-0.0.1/tests/test_import_boundary.py +52 -0
  63. conjured-0.0.1/tests/test_no_legacy_files.py +108 -0
  64. conjured-0.0.1/tests/test_package_boundary.py +86 -0
  65. conjured-0.0.1/tests/test_template_completeness.py +51 -0
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: conjured
3
+ Version: 0.0.1
4
+ Summary: Generative pipeline creator for interactive fiction
5
+ Author-email: Bryan <myemfar@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
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