latris 0.3.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 (209) hide show
  1. latris/__init__.py +1 -0
  2. latris/__main__.py +4 -0
  3. latris/_mutation_signal.py +34 -0
  4. latris/ai/__init__.py +0 -0
  5. latris/ai/adapters/__init__.py +43 -0
  6. latris/ai/adapters/base.py +88 -0
  7. latris/ai/adapters/litellm_adapter.py +272 -0
  8. latris/ai/adapters/mock_adapter.py +105 -0
  9. latris/ai/call.py +442 -0
  10. latris/ai/chain_runner.py +155 -0
  11. latris/ai/chains.py +121 -0
  12. latris/ai/config.py +127 -0
  13. latris/ai/context.py +2695 -0
  14. latris/ai/credentials.py +145 -0
  15. latris/ai/interactions.py +187 -0
  16. latris/ai/prompts.py +1940 -0
  17. latris/ai/proposals.py +1857 -0
  18. latris/ai/render.py +412 -0
  19. latris/ai/validate_urls.py +89 -0
  20. latris/cli/__init__.py +0 -0
  21. latris/cli/_errors.py +60 -0
  22. latris/cli/ai_cmd.py +733 -0
  23. latris/cli/analyse_cmd.py +220 -0
  24. latris/cli/app.py +151 -0
  25. latris/cli/attr_cmd.py +26 -0
  26. latris/cli/bn_cmd.py +352 -0
  27. latris/cli/challenge_cmd.py +119 -0
  28. latris/cli/check_cmd.py +51 -0
  29. latris/cli/completeness_cmd.py +139 -0
  30. latris/cli/edge_cmd.py +65 -0
  31. latris/cli/elicit_cmd.py +144 -0
  32. latris/cli/evidence_cmd.py +39 -0
  33. latris/cli/export_cmd.py +156 -0
  34. latris/cli/fcm_cmd.py +112 -0
  35. latris/cli/findings_cmd.py +125 -0
  36. latris/cli/history_cmd.py +81 -0
  37. latris/cli/init_cmd.py +48 -0
  38. latris/cli/interpret_cmd.py +203 -0
  39. latris/cli/lineage_cmd.py +237 -0
  40. latris/cli/mapping_cmd.py +72 -0
  41. latris/cli/maturity_cmd.py +49 -0
  42. latris/cli/node_cmd.py +85 -0
  43. latris/cli/project_cmd.py +113 -0
  44. latris/cli/provenance_cmd.py +168 -0
  45. latris/cli/ref_cmd.py +137 -0
  46. latris/cli/review_cmd.py +36 -0
  47. latris/cli/story_cmd.py +144 -0
  48. latris/cli/sync_cmd.py +79 -0
  49. latris/cli/view_cmd.py +30 -0
  50. latris/diff/__init__.py +0 -0
  51. latris/diff/semantic.py +680 -0
  52. latris/exporters/__init__.py +0 -0
  53. latris/exporters/_format.py +10 -0
  54. latris/exporters/_scope.py +31 -0
  55. latris/exporters/diagram.py +106 -0
  56. latris/exporters/dot.py +92 -0
  57. latris/exporters/markdown.py +728 -0
  58. latris/exporters/narrative.py +302 -0
  59. latris/exporters/packs.py +70 -0
  60. latris/exporters/review_html.py +698 -0
  61. latris/exporters/story_diagram.py +377 -0
  62. latris/exporters/story_html.py +134 -0
  63. latris/exporters/templates/story/story.html.jinja2 +91 -0
  64. latris/exporters/templates/story/themes/default.css +63 -0
  65. latris/exporters/templates/story/vendor/scrollama.min.js +1 -0
  66. latris/exporters/word.py +538 -0
  67. latris/operations/__init__.py +0 -0
  68. latris/operations/_activations.py +93 -0
  69. latris/operations/_actor.py +22 -0
  70. latris/operations/_attribute_helpers.py +49 -0
  71. latris/operations/_bn_canonical.py +103 -0
  72. latris/operations/_bn_engine.py +125 -0
  73. latris/operations/_chunking.py +162 -0
  74. latris/operations/_data_engine.py +854 -0
  75. latris/operations/_graph_checks.py +41 -0
  76. latris/operations/_logs.py +22 -0
  77. latris/operations/_resolve.py +75 -0
  78. latris/operations/activation.py +63 -0
  79. latris/operations/ai_bibliography.py +152 -0
  80. latris/operations/analysis.py +134 -0
  81. latris/operations/analysis_runs.py +128 -0
  82. latris/operations/attributes.py +157 -0
  83. latris/operations/batch.py +98 -0
  84. latris/operations/bn.py +339 -0
  85. latris/operations/bn_inference.py +97 -0
  86. latris/operations/bn_learn.py +321 -0
  87. latris/operations/bn_sensitivity.py +165 -0
  88. latris/operations/challenges.py +157 -0
  89. latris/operations/coherence_checks.py +234 -0
  90. latris/operations/commit.py +89 -0
  91. latris/operations/completeness.py +340 -0
  92. latris/operations/compose.py +395 -0
  93. latris/operations/configure.py +119 -0
  94. latris/operations/datasets.py +658 -0
  95. latris/operations/edges.py +101 -0
  96. latris/operations/element_history.py +162 -0
  97. latris/operations/elicitation.py +216 -0
  98. latris/operations/evidence.py +277 -0
  99. latris/operations/fcm_inference.py +196 -0
  100. latris/operations/findings.py +683 -0
  101. latris/operations/graph_analysis.py +792 -0
  102. latris/operations/init.py +154 -0
  103. latris/operations/interpret.py +815 -0
  104. latris/operations/interpret_registry.py +176 -0
  105. latris/operations/lineage.py +779 -0
  106. latris/operations/mappings.py +130 -0
  107. latris/operations/maturity.py +332 -0
  108. latris/operations/nodes.py +121 -0
  109. latris/operations/project.py +189 -0
  110. latris/operations/provenance.py +261 -0
  111. latris/operations/readiness.py +918 -0
  112. latris/operations/references.py +659 -0
  113. latris/operations/report_config.py +66 -0
  114. latris/operations/review.py +213 -0
  115. latris/operations/story.py +313 -0
  116. latris/operations/sync.py +698 -0
  117. latris/operations/view_attributes.py +127 -0
  118. latris/operations/view_roles.py +74 -0
  119. latris/operations/views.py +137 -0
  120. latris/perf.py +98 -0
  121. latris/repo/__init__.py +0 -0
  122. latris/repo/git.py +547 -0
  123. latris/repo/layout.py +331 -0
  124. latris/repo/lineage_index.py +110 -0
  125. latris/repo/project.py +36 -0
  126. latris/repo/yaml_io.py +149 -0
  127. latris/schema/__init__.py +297 -0
  128. latris/schema/ai.py +91 -0
  129. latris/schema/analysis.py +46 -0
  130. latris/schema/attributes.py +182 -0
  131. latris/schema/bn.py +227 -0
  132. latris/schema/common.py +104 -0
  133. latris/schema/completeness.py +60 -0
  134. latris/schema/data_actions.py +91 -0
  135. latris/schema/data_versions.py +38 -0
  136. latris/schema/datasets.py +61 -0
  137. latris/schema/elicitation.py +102 -0
  138. latris/schema/findings.py +100 -0
  139. latris/schema/ids.py +98 -0
  140. latris/schema/interpretation.py +108 -0
  141. latris/schema/layout_ui.py +27 -0
  142. latris/schema/lineage.py +61 -0
  143. latris/schema/maturity.py +37 -0
  144. latris/schema/membership.py +33 -0
  145. latris/schema/model_types.py +312 -0
  146. latris/schema/project.py +30 -0
  147. latris/schema/provenance.py +154 -0
  148. latris/schema/references.py +55 -0
  149. latris/schema/report_config.py +55 -0
  150. latris/schema/review.py +88 -0
  151. latris/schema/roles.py +31 -0
  152. latris/schema/settings.py +67 -0
  153. latris/schema/story.py +129 -0
  154. latris/schema/substrate.py +171 -0
  155. latris/schema/views.py +50 -0
  156. latris/server/__init__.py +1 -0
  157. latris/server/__main__.py +39 -0
  158. latris/server/app.py +687 -0
  159. latris/server/config.py +16 -0
  160. latris/server/deps.py +26 -0
  161. latris/server/entry.py +120 -0
  162. latris/server/help.py +1236 -0
  163. latris/server/perf.py +80 -0
  164. latris/server/routes/__init__.py +1 -0
  165. latris/server/routes/activity.py +20 -0
  166. latris/server/routes/ai.py +484 -0
  167. latris/server/routes/analysis.py +114 -0
  168. latris/server/routes/annotations.py +94 -0
  169. latris/server/routes/attributes.py +30 -0
  170. latris/server/routes/bn.py +234 -0
  171. latris/server/routes/challenges.py +59 -0
  172. latris/server/routes/checks.py +35 -0
  173. latris/server/routes/completeness.py +94 -0
  174. latris/server/routes/compose.py +90 -0
  175. latris/server/routes/configure.py +50 -0
  176. latris/server/routes/datasets.py +213 -0
  177. latris/server/routes/edges.py +49 -0
  178. latris/server/routes/evidence.py +61 -0
  179. latris/server/routes/export.py +86 -0
  180. latris/server/routes/fcm.py +75 -0
  181. latris/server/routes/findings.py +133 -0
  182. latris/server/routes/help.py +28 -0
  183. latris/server/routes/history.py +39 -0
  184. latris/server/routes/interpret.py +190 -0
  185. latris/server/routes/layout.py +38 -0
  186. latris/server/routes/lineage.py +205 -0
  187. latris/server/routes/model_choices.py +57 -0
  188. latris/server/routes/model_types.py +40 -0
  189. latris/server/routes/nodes.py +67 -0
  190. latris/server/routes/provenance.py +111 -0
  191. latris/server/routes/readiness.py +121 -0
  192. latris/server/routes/references.py +152 -0
  193. latris/server/routes/report_config.py +40 -0
  194. latris/server/routes/review.py +31 -0
  195. latris/server/routes/story.py +126 -0
  196. latris/server/routes/sync.py +219 -0
  197. latris/server/routes/view_attributes.py +49 -0
  198. latris/server/routes/views.py +64 -0
  199. latris/server/state.py +160 -0
  200. latris/server/watch.py +133 -0
  201. latris/sync/__init__.py +5 -0
  202. latris/sync/askpass.py +54 -0
  203. latris/sync/client.py +156 -0
  204. latris/sync/credentials.py +112 -0
  205. latris-0.3.0.dist-info/METADATA +232 -0
  206. latris-0.3.0.dist-info/RECORD +209 -0
  207. latris-0.3.0.dist-info/WHEEL +4 -0
  208. latris-0.3.0.dist-info/entry_points.txt +2 -0
  209. latris-0.3.0.dist-info/licenses/LICENSE +201 -0
latris/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
latris/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from latris.cli.app import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,34 @@
1
+ """Cross-layer signal that a server-initiated mutation just happened.
2
+
3
+ Lives at the package root (alongside :mod:`latris.perf`) so
4
+ ``operations/commit.py`` can fire it from ``auto_commit`` without
5
+ importing from the ``server/`` layer. The file watcher reads this
6
+ signal to suppress redundant ``state_changed`` broadcasts after the
7
+ GUI's own mutation response has already rehydrated its store.
8
+
9
+ The mechanism is a 2-second wall-clock window: any file change
10
+ detected within :data:`SUPPRESS_SECONDS` of the last
11
+ :func:`mark_own_mutation` call is treated as our own write and
12
+ silently dropped. The window is intentionally short — external
13
+ mutations (CLI runs, another editor) still get a broadcast as soon
14
+ as the window expires.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+
21
+ SUPPRESS_SECONDS = 2.0
22
+
23
+ _last_own_mutation: float = 0.0
24
+
25
+
26
+ def mark_own_mutation() -> None:
27
+ """Stamp the suppression window. Called by ``auto_commit`` on a real commit."""
28
+ global _last_own_mutation # noqa: PLW0603
29
+ _last_own_mutation = time.monotonic()
30
+
31
+
32
+ def is_suppressed() -> bool:
33
+ """True if a server mutation happened within the suppression window."""
34
+ return (time.monotonic() - _last_own_mutation) < SUPPRESS_SECONDS
latris/ai/__init__.py ADDED
File without changes
@@ -0,0 +1,43 @@
1
+ """Chat adapters — how we get a typed proposal out of an LLM.
2
+
3
+ The rest of the codebase only ever imports ``get_adapter()`` from here.
4
+ Selection is environment-driven so tests and CI can run without network.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from latris.ai.adapters.base import ChatAdapter, ChatResult
12
+ from latris.ai.adapters.mock_adapter import MockAdapter
13
+
14
+ MOCK_ENV_VAR = "LATRIS_AI_MOCK"
15
+ # One-release back-compat: environments exported before the rename (and the
16
+ # private app repo's CI during the transition) may still set the old name.
17
+ _LEGACY_MOCK_ENV_VAR = "GRAPH_TOOLING_AI_MOCK"
18
+
19
+
20
+ def mock_enabled() -> bool:
21
+ """True when AI mocking is requested via env: ``LATRIS_AI_MOCK``, falling
22
+ back to the pre-rename ``GRAPH_TOOLING_AI_MOCK`` for one release."""
23
+ value = os.environ.get(MOCK_ENV_VAR) or os.environ.get(_LEGACY_MOCK_ENV_VAR) or ""
24
+ return value.lower() in ("1", "true", "yes")
25
+
26
+
27
+ def get_adapter() -> ChatAdapter:
28
+ """Return the active chat adapter.
29
+
30
+ If ``LATRIS_AI_MOCK`` is truthy, return a ``MockAdapter`` — zero
31
+ network, deterministic responses from whatever's been registered via
32
+ ``MockAdapter.register(...)``. Otherwise return the ``LiteLLMAdapter``.
33
+ """
34
+ if mock_enabled():
35
+ return MockAdapter.shared()
36
+ # Lazy import: litellm is heavy and pulls in several transitive deps on
37
+ # first import. No reason to pay that cost in mock mode.
38
+ from latris.ai.adapters.litellm_adapter import LiteLLMAdapter
39
+
40
+ return LiteLLMAdapter()
41
+
42
+
43
+ __all__ = ["ChatAdapter", "ChatResult", "MockAdapter", "get_adapter", "mock_enabled"]
@@ -0,0 +1,88 @@
1
+ """The chat adapter interface — one chokepoint for every LLM call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Protocol, TypeVar
8
+
9
+ from pydantic import BaseModel
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+
14
+ class LLMCallError(RuntimeError):
15
+ """Provider-error wrapper with API keys scrubbed from the message.
16
+
17
+ Raised by adapters instead of propagating provider-specific exceptions
18
+ that may contain keys in URLs or headers. The CLI error handler catches
19
+ this and prints a clean, safe message.
20
+
21
+ ``raw_response`` carries the model's last raw output when the adapter
22
+ can extract it (e.g. instructor's ``InstructorRetryException`` exposes
23
+ the failed completion under ``last_completion``). It exists so the
24
+ interaction logger can persist the malformed payload for diagnosis
25
+ rather than throwing it away with the exception.
26
+ """
27
+
28
+ def __init__(self, message: str, *, raw_response: str | None = None) -> None:
29
+ super().__init__(message)
30
+ self.raw_response = raw_response
31
+
32
+
33
+ # Known API-key shapes. Order matters — the generic ``sk-`` must come last.
34
+ _KEY_PATTERNS = [
35
+ re.compile(r"AIza[0-9A-Za-z_-]{20,}"), # Google AI Studio / Gemini
36
+ re.compile(r"sk-ant-(?:api\d+-)?[0-9A-Za-z_-]{20,}"), # Anthropic
37
+ re.compile(r"sk-proj-[0-9A-Za-z_-]{20,}"), # OpenAI project keys
38
+ re.compile(r"sk-svcacct-[0-9A-Za-z_-]{20,}"), # OpenAI service accounts
39
+ re.compile(r"gsk_[0-9A-Za-z_-]{20,}"), # Groq
40
+ re.compile(r"xai-[0-9A-Za-z_-]{20,}"), # xAI
41
+ re.compile(r"[?&]key=[0-9A-Za-z_-]{20,}"), # any ?key=... in URLs
42
+ re.compile(r"sk-[0-9A-Za-z_-]{20,}"), # OpenAI legacy (keep last)
43
+ ]
44
+
45
+
46
+ def redact_secrets(text: str) -> str:
47
+ """Scrub anything that looks like an API key. Safe to call on tracebacks."""
48
+ if not text:
49
+ return text
50
+ for pat in _KEY_PATTERNS:
51
+ text = pat.sub("<REDACTED>", text)
52
+ return text
53
+
54
+
55
+ @dataclass
56
+ class ChatResult:
57
+ """What every adapter returns.
58
+
59
+ - ``parsed`` is the pydantic-validated structured output (what the caller
60
+ actually wanted — e.g. a ``ChallengeProposal``).
61
+ - ``raw`` is the model's full text response, stashed for the interaction
62
+ log so a future retrieval layer has verbatim memory.
63
+ - ``token_usage`` is provider-reported token counts; shape is free-form
64
+ but we standardise on ``input_tokens`` / ``output_tokens`` /
65
+ ``cache_read_tokens`` / ``cache_write_tokens`` where applicable.
66
+ - ``cost_usd`` is the estimated dollar cost for this call, if available.
67
+ """
68
+
69
+ parsed: BaseModel
70
+ raw: str
71
+ model_id: str
72
+ token_usage: dict[str, Any] = field(default_factory=dict)
73
+ cost_usd: float | None = None
74
+
75
+
76
+ class ChatAdapter(Protocol):
77
+ """Minimum contract: ask a model for a typed response."""
78
+
79
+ def chat(
80
+ self,
81
+ *,
82
+ model: str,
83
+ system: str,
84
+ user: str,
85
+ response_model: type[T],
86
+ temperature: float = 0.3,
87
+ max_tokens: int | None = None,
88
+ ) -> ChatResult: ...
@@ -0,0 +1,272 @@
1
+ """Real LLM calls via litellm + instructor.
2
+
3
+ litellm normalises the chat-completion API across Anthropic / OpenAI /
4
+ Gemini / Groq / Ollama / etc. Instructor wraps it with pydantic-native
5
+ structured output (JSON schema + validation + retry on bad output).
6
+
7
+ We never store keys here — litellm reads them from env vars, which the
8
+ call orchestrator populates from keyring just before each call and unsets
9
+ after.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, TypeVar
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from latris.ai import config as ai_config
19
+ from latris.ai.adapters.base import ChatResult, LLMCallError, redact_secrets
20
+ from latris.perf import span
21
+
22
+ T = TypeVar("T", bound=BaseModel)
23
+
24
+
25
+ def _is_json_parse_error(exc: BaseException) -> bool:
26
+ """Heuristic: did this exception come from a malformed-JSON response?
27
+
28
+ Pydantic's ValidationError for JSON-mode structured output surfaces
29
+ the message ``"Invalid JSON: ..."`` (see
30
+ https://errors.pydantic.dev/2.12/v/json_invalid). Instructor wraps
31
+ the validation error and propagates it; the substring match here is
32
+ deliberately broad — false positives just spend one extra call,
33
+ false negatives leak through to fail-loud where Layer 1 records
34
+ them. Both error message and class name are searched because
35
+ instructor's wrapper sometimes obscures the inner message.
36
+ """
37
+ text = f"{type(exc).__name__}: {exc}".lower()
38
+ return "invalid json" in text or "jsondecodeerror" in text
39
+
40
+
41
+ def _build_repair_messages(
42
+ *,
43
+ system: str,
44
+ user: str,
45
+ raw_response: str,
46
+ parser_error: str,
47
+ response_model: type[BaseModel],
48
+ ) -> list[dict[str, str]]:
49
+ """Construct the message list for a self-correction retry.
50
+
51
+ The model gets to see what it produced and the parser's complaint,
52
+ so it can fix the structural issue (typically an unescaped quote
53
+ or newline inside a string value) rather than re-generating
54
+ blindly. We cap the embedded raw response and parser error to
55
+ keep the repair prompt from approaching the context window in
56
+ pathological cases.
57
+ """
58
+ raw_excerpt = raw_response[:4000]
59
+ err_excerpt = parser_error[:1000]
60
+ repair_user = (
61
+ f"{user}\n\n"
62
+ "Your previous response could not be parsed as a valid JSON "
63
+ f"object matching the {response_model.__name__} schema:\n\n"
64
+ f"<failed_response>\n{raw_excerpt}\n</failed_response>\n\n"
65
+ "Parser error:\n"
66
+ f"<parser_error>\n{err_excerpt}\n</parser_error>\n\n"
67
+ "Re-emit the same intent as a valid JSON object. Common "
68
+ "issues to check: unescaped double quotes inside string "
69
+ 'values (use \\"), literal newlines inside string values '
70
+ "(use \\n), stray backslashes (use \\\\), and trailing "
71
+ "commas. Output only the JSON object, no prose around it."
72
+ )
73
+ return [
74
+ {"role": "system", "content": system},
75
+ {"role": "user", "content": repair_user},
76
+ ]
77
+
78
+
79
+ class LiteLLMAdapter:
80
+ """Lazy-imported litellm+instructor. Constructed once per process."""
81
+
82
+ def __init__(self) -> None:
83
+ # Import lazily so mock-mode runs don't pay the litellm import cost.
84
+ import instructor # type: ignore[import-untyped]
85
+ import litellm # type: ignore[import-untyped]
86
+
87
+ self._instructor = instructor
88
+ self._instructor_client_tools = instructor.from_litellm(litellm.completion)
89
+ self._instructor_client_json = instructor.from_litellm(
90
+ litellm.completion,
91
+ mode=instructor.Mode.JSON,
92
+ )
93
+ self._litellm = litellm
94
+
95
+ def chat(
96
+ self,
97
+ *,
98
+ model: str,
99
+ system: str,
100
+ user: str,
101
+ response_model: type[T],
102
+ temperature: float = 0.3,
103
+ max_tokens: int | None = None,
104
+ ) -> ChatResult:
105
+ messages = [
106
+ {"role": "system", "content": system},
107
+ {"role": "user", "content": user},
108
+ ]
109
+ # `create_with_completion` returns (parsed_model, raw_completion) so we
110
+ # can keep the verbatim response for the interaction log. Any provider
111
+ # error is re-wrapped as an ``LLMCallError`` with API keys scrubbed.
112
+ #
113
+ # Gemini models fail with Mode.TOOLS on complex schemas (optional/union
114
+ # fields return empty completions); Moonshot's thinking models reject
115
+ # the explicit tool_choice TOOLS mode sends ("tool_choice 'specified'
116
+ # is incompatible with thinking enabled"). Both use Mode.JSON.
117
+ lowered = model.lower()
118
+ uses_json_mode = lowered.startswith("gemini") or lowered.startswith("moonshot")
119
+ client = self._instructor_client_json if uses_json_mode else self._instructor_client_tools
120
+
121
+ try:
122
+ with span("adapter.chat", model=model):
123
+ parsed, raw_completion = self._call(
124
+ client=client,
125
+ model=model,
126
+ messages=messages,
127
+ response_model=response_model,
128
+ temperature=temperature,
129
+ max_tokens=max_tokens,
130
+ )
131
+ except Exception as exc:
132
+ raw_response = self._extract_last_raw_response(exc)
133
+ # On parse failure, do one self-correction pass: feed the malformed
134
+ # output back to the model with the parser error and ask for valid
135
+ # JSON. Instructor's own retry doesn't show the model what it
136
+ # produced — that's the meaningful step-up.
137
+ if raw_response and _is_json_parse_error(exc):
138
+ try:
139
+ with span("adapter.chat.repair", model=model):
140
+ parsed, raw_completion = self._call(
141
+ client=client,
142
+ model=model,
143
+ messages=_build_repair_messages(
144
+ system=system,
145
+ user=user,
146
+ raw_response=raw_response,
147
+ parser_error=str(exc),
148
+ response_model=response_model,
149
+ ),
150
+ response_model=response_model,
151
+ temperature=temperature,
152
+ max_tokens=max_tokens,
153
+ )
154
+ except Exception as repair_exc:
155
+ # Repair pass also failed — raise wrapping the *repair*
156
+ # error since that's the most recent (still safe — both
157
+ # error messages and raw response are scrubbed).
158
+ safe_message = redact_secrets(str(repair_exc))
159
+ repair_raw = self._extract_last_raw_response(repair_exc) or raw_response
160
+ raise LLMCallError(
161
+ f"{type(repair_exc).__name__} from {model} (repair pass): {safe_message}",
162
+ raw_response=redact_secrets(repair_raw) or None,
163
+ ) from None
164
+ else:
165
+ safe_message = redact_secrets(str(exc))
166
+ raise LLMCallError(
167
+ f"{type(exc).__name__} from {model}: {safe_message}",
168
+ raw_response=redact_secrets(raw_response or "") or None,
169
+ ) from None
170
+
171
+ token_usage = self._extract_token_usage(raw_completion)
172
+ cost_usd = self._try_completion_cost(raw_completion)
173
+ raw_text = self._first_text_content(raw_completion)
174
+
175
+ return ChatResult(
176
+ parsed=parsed,
177
+ raw=raw_text,
178
+ model_id=model,
179
+ token_usage=token_usage,
180
+ cost_usd=cost_usd,
181
+ )
182
+
183
+ def _call(
184
+ self,
185
+ *,
186
+ client: Any,
187
+ model: str,
188
+ messages: list[dict[str, str]],
189
+ response_model: type[T],
190
+ temperature: float,
191
+ max_tokens: int | None,
192
+ ) -> tuple[T, Any]:
193
+ """Single instructor invocation. Extracted so the repair pass can
194
+ reuse the same code path with different messages."""
195
+ kwargs: dict[str, Any] = {
196
+ "model": model,
197
+ "messages": messages,
198
+ "response_model": response_model,
199
+ "temperature": temperature,
200
+ # Bumped from 1 → 3 alongside Layer 2's repair pass. Instructor
201
+ # retries on validation error (missing field, wrong type) — a
202
+ # higher budget helps with transient hiccups before we burn the
203
+ # one repair attempt below on actual JSON-parse failures.
204
+ "max_retries": 3,
205
+ }
206
+ if max_tokens is not None:
207
+ kwargs["max_tokens"] = max_tokens
208
+ extra_body = ai_config.resolve_extra_body(model)
209
+ if extra_body:
210
+ kwargs["extra_body"] = extra_body
211
+ return client.chat.completions.create_with_completion(**kwargs)
212
+
213
+ # ---- helpers ----
214
+
215
+ @staticmethod
216
+ def _extract_token_usage(completion: Any) -> dict[str, Any]:
217
+ usage = getattr(completion, "usage", None)
218
+ if usage is None:
219
+ return {}
220
+ # Usage fields vary per provider; collect the standard ones.
221
+ out: dict[str, Any] = {}
222
+ for src_key, dst_key in (
223
+ ("prompt_tokens", "input_tokens"),
224
+ ("completion_tokens", "output_tokens"),
225
+ ("total_tokens", "total_tokens"),
226
+ ("cache_read_input_tokens", "cache_read_tokens"),
227
+ ("cache_creation_input_tokens", "cache_write_tokens"),
228
+ ):
229
+ value = getattr(usage, src_key, None)
230
+ if value is not None:
231
+ out[dst_key] = value
232
+ return out
233
+
234
+ def _try_completion_cost(self, completion: Any) -> float | None:
235
+ try:
236
+ return float(self._litellm.completion_cost(completion_response=completion))
237
+ except Exception:
238
+ return None
239
+
240
+ @staticmethod
241
+ def _first_text_content(completion: Any) -> str:
242
+ try:
243
+ choices = getattr(completion, "choices", None) or []
244
+ if not choices:
245
+ return ""
246
+ msg = getattr(choices[0], "message", None)
247
+ return getattr(msg, "content", "") or ""
248
+ except Exception:
249
+ return ""
250
+
251
+ @classmethod
252
+ def _extract_last_raw_response(cls, exc: BaseException) -> str | None:
253
+ """Best-effort pull of the failed model output from instructor's exception.
254
+
255
+ Instructor's ``InstructorRetryException`` exposes the last completion
256
+ under ``last_completion`` (a chat-completion-shaped object). When
257
+ retries are exhausted on a parse failure, this is the malformed
258
+ payload we need for diagnosis. Falls back to ``messages`` /
259
+ ``raw_response`` attribute names other instructor versions use.
260
+ """
261
+ for attr in ("last_completion", "raw_response", "completion"):
262
+ completion = getattr(exc, attr, None)
263
+ if completion is None:
264
+ continue
265
+ text = cls._first_text_content(completion)
266
+ if text:
267
+ return text
268
+ # InstructorRetryException stores the rendered prompt under `messages`
269
+ # and the parser failure under `n_attempts` / `total_usage`; the raw
270
+ # text we want isn't always reachable. Last resort: stringify the
271
+ # exception's traceback tail.
272
+ return None
@@ -0,0 +1,105 @@
1
+ """Deterministic mock adapter — hermetic for tests and demos.
2
+
3
+ Tests or dev sessions pre-register canned ``ChatResult``s keyed by prompt
4
+ signature; ``chat()`` looks the key up and returns the canned result.
5
+ Falls through to a generic stub if nothing is registered for the key —
6
+ useful for smoke runs where we just want no-network behaviour.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from typing import TypeVar
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from latris.ai.adapters.base import ChatResult
17
+
18
+ T = TypeVar("T", bound=BaseModel)
19
+
20
+ _SHARED: MockAdapter | None = None
21
+
22
+
23
+ def _signature(model: str, system: str, user: str, response_cls: type[BaseModel]) -> str:
24
+ hasher = hashlib.sha256()
25
+ hasher.update(model.encode("utf-8"))
26
+ hasher.update(b"\x00")
27
+ hasher.update(system.encode("utf-8"))
28
+ hasher.update(b"\x00")
29
+ hasher.update(user.encode("utf-8"))
30
+ hasher.update(b"\x00")
31
+ hasher.update(response_cls.__name__.encode("utf-8"))
32
+ return hasher.hexdigest()
33
+
34
+
35
+ class MockAdapter:
36
+ """In-memory fake. Not thread-safe; tests serialise their own state."""
37
+
38
+ def __init__(self) -> None:
39
+ self._registry: dict[str, ChatResult] = {}
40
+ self._default_by_response_cls: dict[str, ChatResult] = {}
41
+
42
+ # ---- registration ----
43
+
44
+ def register(
45
+ self,
46
+ *,
47
+ model: str,
48
+ system: str,
49
+ user: str,
50
+ response_cls: type[BaseModel],
51
+ result: ChatResult,
52
+ ) -> None:
53
+ """Bind an exact ``(model, system, user, response_cls)`` to a canned result."""
54
+ self._registry[_signature(model, system, user, response_cls)] = result
55
+
56
+ def register_default(
57
+ self,
58
+ *,
59
+ response_cls: type[BaseModel],
60
+ result: ChatResult,
61
+ ) -> None:
62
+ """Fallback for any call whose response_cls matches, regardless of prompt."""
63
+ self._default_by_response_cls[response_cls.__name__] = result
64
+
65
+ def clear(self) -> None:
66
+ self._registry.clear()
67
+ self._default_by_response_cls.clear()
68
+
69
+ # ---- adapter protocol ----
70
+
71
+ def chat(
72
+ self,
73
+ *,
74
+ model: str,
75
+ system: str,
76
+ user: str,
77
+ response_model: type[T],
78
+ temperature: float = 0.3,
79
+ max_tokens: int | None = None,
80
+ ) -> ChatResult:
81
+ key = _signature(model, system, user, response_model)
82
+ if key in self._registry:
83
+ return self._registry[key]
84
+ fallback = self._default_by_response_cls.get(response_model.__name__)
85
+ if fallback is not None:
86
+ return fallback
87
+ raise LookupError(
88
+ f"MockAdapter: no canned response registered for response_model="
89
+ f"{response_model.__name__} (model={model!r}). Register one via "
90
+ "MockAdapter.shared().register(...) / register_default(...)."
91
+ )
92
+
93
+ # ---- process-wide singleton ----
94
+
95
+ @classmethod
96
+ def shared(cls) -> MockAdapter:
97
+ global _SHARED
98
+ if _SHARED is None:
99
+ _SHARED = cls()
100
+ return _SHARED
101
+
102
+ @classmethod
103
+ def reset_shared(cls) -> None:
104
+ global _SHARED
105
+ _SHARED = None