open-data-sci 0.1.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.
- open_data_sci-0.1.0.dist-info/METADATA +629 -0
- open_data_sci-0.1.0.dist-info/RECORD +85 -0
- open_data_sci-0.1.0.dist-info/WHEEL +4 -0
- open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
- open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
- opendatasci/__init__.py +47 -0
- opendatasci/_tui/__init__.py +1 -0
- opendatasci/_tui/adapter.py +102 -0
- opendatasci/_tui/app.py +429 -0
- opendatasci/_tui/commands.py +95 -0
- opendatasci/_tui/completion.py +139 -0
- opendatasci/_tui/controller.py +644 -0
- opendatasci/_tui/file_refs.py +153 -0
- opendatasci/_tui/models.py +4 -0
- opendatasci/_tui/presenter.py +259 -0
- opendatasci/_tui/service.py +78 -0
- opendatasci/_tui/session.py +53 -0
- opendatasci/_tui/styles.tcss +248 -0
- opendatasci/_tui/styles_visible.tcss +245 -0
- opendatasci/_tui/theme.py +113 -0
- opendatasci/_tui/tools_display.py +86 -0
- opendatasci/_tui/widgets.py +1001 -0
- opendatasci/_utils/__init__.py +0 -0
- opendatasci/_utils/async_utils.py +11 -0
- opendatasci/_utils/data_formats.py +135 -0
- opendatasci/_utils/hash_utils.py +52 -0
- opendatasci/_utils/langchain_utils.py +155 -0
- opendatasci/_utils/streaming_utils.py +23 -0
- opendatasci/agents/__init__.py +12 -0
- opendatasci/agents/agents.py +515 -0
- opendatasci/agents/agents_factory.py +71 -0
- opendatasci/agents/chat_memory.py +397 -0
- opendatasci/agents/graphs.py +84 -0
- opendatasci/agents/nodes.py +74 -0
- opendatasci/agents/states.py +36 -0
- opendatasci/agents/turn_memory.py +124 -0
- opendatasci/configs.py +275 -0
- opendatasci/context/__init__.py +7 -0
- opendatasci/context/base.py +56 -0
- opendatasci/context/local.py +236 -0
- opendatasci/models/__init__.py +7 -0
- opendatasci/models/anthropic.py +40 -0
- opendatasci/models/aws.py +86 -0
- opendatasci/models/factory.py +179 -0
- opendatasci/models/google.py +79 -0
- opendatasci/models/local.py +79 -0
- opendatasci/models/microsoft.py +62 -0
- opendatasci/models/openai.py +49 -0
- opendatasci/models/providers.py +12 -0
- opendatasci/prompts/__init__.py +5 -0
- opendatasci/prompts/builders.py +85 -0
- opendatasci/prompts/caching.py +42 -0
- opendatasci/prompts/message_templates.py +7 -0
- opendatasci/prompts/prompt_templates.py +227 -0
- opendatasci/resources/skills/competitive_data_science.md +241 -0
- opendatasci/resources/skills/data_science.md +55 -0
- opendatasci/resources/skills/data_science_education.md +42 -0
- opendatasci/resources/skills/deep_learning.md +205 -0
- opendatasci/resources/skills/machine_learning.md +68 -0
- opendatasci/resources/skills/quantitative_analysis.md +45 -0
- opendatasci/sandbox/__init__.py +14 -0
- opendatasci/sandbox/_runner.py +114 -0
- opendatasci/sandbox/base.py +170 -0
- opendatasci/sandbox/srt.py +490 -0
- opendatasci/skills/__init__.py +9 -0
- opendatasci/skills/base.py +28 -0
- opendatasci/skills/local.py +131 -0
- opendatasci/streaming/__init__.py +37 -0
- opendatasci/streaming/events.py +159 -0
- opendatasci/streaming/processors.py +387 -0
- opendatasci/tools/__init__.py +58 -0
- opendatasci/tools/coding.py +261 -0
- opendatasci/tools/critic.py +136 -0
- opendatasci/tools/dataset_info.py +391 -0
- opendatasci/tools/factory.py +172 -0
- opendatasci/tools/mcp.py +179 -0
- opendatasci/tools/planning.py +88 -0
- opendatasci/tools/skills.py +90 -0
- opendatasci/tools/user_interaction.py +54 -0
- opendatasci/tools/web.py +236 -0
- opendatasci/tools/workers.py +237 -0
- opendatasci/tools/workspace.py +55 -0
- opendatasci/workspace/__init__.py +9 -0
- opendatasci/workspace/base.py +20 -0
- opendatasci/workspace/local.py +25 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""File-based context stores backed by the workspace's ``.opendatasci`` directory."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import datetime as _datetime
|
|
7
|
+
from datetime import timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncGenerator, Self
|
|
10
|
+
|
|
11
|
+
from opendatasci._utils.hash_utils import hash_path
|
|
12
|
+
from opendatasci.context.base import BaseContextStore
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
OPENDATASCI_DIRNAME = ".opendatasci"
|
|
17
|
+
|
|
18
|
+
_NOTES_DIR = "dataset_notes"
|
|
19
|
+
_PROFILES_DIR = "dataset_profiling"
|
|
20
|
+
_PLANS_DIR = "plans"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LocalContextStore(BaseContextStore):
|
|
24
|
+
"""File-based context store for a single local workspace.
|
|
25
|
+
|
|
26
|
+
Persists dataset notes and profile cards (keyed by dataset path) as well as
|
|
27
|
+
session plans (keyed by ``session_id``). All data lives under the
|
|
28
|
+
workspace's ``.opendatasci`` directory.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
workspace_path: Root directory of the active workspace. Relative
|
|
32
|
+
dataset paths are resolved against this directory.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, workspace_path: Path) -> None:
|
|
36
|
+
self._workspace_path = workspace_path
|
|
37
|
+
self._root = workspace_path / OPENDATASCI_DIRNAME
|
|
38
|
+
self._current_plan: str | None = None
|
|
39
|
+
|
|
40
|
+
# ── BaseContextStore: session ────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
@asynccontextmanager
|
|
43
|
+
async def session(self) -> AsyncGenerator[Self, None]:
|
|
44
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
yield self
|
|
46
|
+
|
|
47
|
+
# ── BaseContextStore: root ──────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def root(self) -> Path:
|
|
51
|
+
return self._root
|
|
52
|
+
|
|
53
|
+
# ── Internal storage paths ───────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def _notes_root(self) -> Path:
|
|
57
|
+
return self.root / _NOTES_DIR
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _profiles_root(self) -> Path:
|
|
61
|
+
return self.root / _PROFILES_DIR
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def _plans_root(self) -> Path:
|
|
65
|
+
return self.root / _PLANS_DIR
|
|
66
|
+
|
|
67
|
+
# ── Internal notes storage ────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def _find(self, hash_hex: str) -> Path | None:
|
|
70
|
+
"""Locate an existing notes file for *hash_hex* under any date prefix."""
|
|
71
|
+
if self._notes_root.exists():
|
|
72
|
+
for p in self._notes_root.rglob(f"{hash_hex}.md"):
|
|
73
|
+
return p
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def _resolve_notes_path(self, hash_hex: str) -> Path:
|
|
77
|
+
"""Reuse an existing file for *hash_hex*, or build a new date-keyed path."""
|
|
78
|
+
existing = self._find(hash_hex)
|
|
79
|
+
if existing is not None:
|
|
80
|
+
return existing
|
|
81
|
+
today = datetime.date.today()
|
|
82
|
+
return (
|
|
83
|
+
self._notes_root
|
|
84
|
+
/ str(today.year)
|
|
85
|
+
/ f"{today.month:02d}"
|
|
86
|
+
/ f"{today.day:02d}"
|
|
87
|
+
/ f"{hash_hex}.md"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _load_notes(self, hash_hex: str) -> str | None:
|
|
91
|
+
p = self._find(hash_hex)
|
|
92
|
+
return p.read_text(encoding="utf-8") if p is not None else None
|
|
93
|
+
|
|
94
|
+
def _save_notes(self, hash_hex: str, content: str) -> None:
|
|
95
|
+
path = self._resolve_notes_path(hash_hex)
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path.write_text(content, encoding="utf-8")
|
|
98
|
+
|
|
99
|
+
def _notes_file_path(self, hash_hex: str) -> str:
|
|
100
|
+
return str(self._resolve_notes_path(hash_hex))
|
|
101
|
+
|
|
102
|
+
# ── Internal profile storage ──────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def _load_profile(self, hash_hex: str) -> str | None:
|
|
105
|
+
p = self._profiles_root / f"{hash_hex}.md"
|
|
106
|
+
return p.read_text(encoding="utf-8") if p.exists() else None
|
|
107
|
+
|
|
108
|
+
# ── BaseContextStore interface ──────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async def read_dataset_info(self, dataset_path: str) -> str:
|
|
111
|
+
"""Return combined dataset info: profile card (if any) + session notes.
|
|
112
|
+
|
|
113
|
+
The returned Markdown string has clearly labelled sections:
|
|
114
|
+
|
|
115
|
+
``# DATASET PROFILING`` — auto-generated stats (shape, dtypes, etc.)
|
|
116
|
+
``# DATASET NOTES`` — persistent notes written by the agent
|
|
117
|
+
|
|
118
|
+
If no profile exists, only the notes section is included.
|
|
119
|
+
If no notes exist yet, a placeholder scaffold is returned.
|
|
120
|
+
"""
|
|
121
|
+
path = self._resolve_dataset_path(dataset_path)
|
|
122
|
+
hash_hex = await hash_path(path)
|
|
123
|
+
|
|
124
|
+
notes = self._load_notes(hash_hex)
|
|
125
|
+
if notes is None:
|
|
126
|
+
notes = f"# DATASET NOTES: {path.name}\n\n_No notes recorded yet._\n"
|
|
127
|
+
else:
|
|
128
|
+
notes = "# DATASET NOTES\n\n" + notes
|
|
129
|
+
|
|
130
|
+
profile = self._load_profile(hash_hex)
|
|
131
|
+
if profile is not None:
|
|
132
|
+
return "# DATASET PROFILING\n\n" + profile.rstrip() + "\n\n---\n\n" + notes
|
|
133
|
+
return notes
|
|
134
|
+
|
|
135
|
+
async def update_dataset_info(
|
|
136
|
+
self,
|
|
137
|
+
dataset_path: str,
|
|
138
|
+
update: str,
|
|
139
|
+
merge: bool = True,
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Persist dataset notes and return the path to the stored notes file.
|
|
142
|
+
|
|
143
|
+
Only notes are modified — the profile card (if any) is never touched.
|
|
144
|
+
"""
|
|
145
|
+
path = self._resolve_dataset_path(dataset_path)
|
|
146
|
+
hash_hex = await hash_path(path)
|
|
147
|
+
|
|
148
|
+
if merge:
|
|
149
|
+
existing = self._load_notes(hash_hex)
|
|
150
|
+
new_content = (
|
|
151
|
+
existing.rstrip() + "\n\n" + update.strip() + "\n"
|
|
152
|
+
if existing
|
|
153
|
+
else update.strip() + "\n"
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
new_content = update.strip() + "\n"
|
|
157
|
+
|
|
158
|
+
self._save_notes(hash_hex, new_content)
|
|
159
|
+
return self._notes_file_path(hash_hex)
|
|
160
|
+
|
|
161
|
+
async def get_profile_info(self, dataset_path: str) -> tuple[str, str, str | None]:
|
|
162
|
+
"""Return ``(resolved_path_str, hash_hex, existing_profile_or_None)``.
|
|
163
|
+
|
|
164
|
+
Used by the ``profile_dataset`` tool to check for a cached card before
|
|
165
|
+
running the profiling sandbox pass.
|
|
166
|
+
"""
|
|
167
|
+
path = self._resolve_dataset_path(dataset_path)
|
|
168
|
+
hash_hex = await hash_path(path)
|
|
169
|
+
return str(path), hash_hex, self._load_profile(hash_hex)
|
|
170
|
+
|
|
171
|
+
def save_dataset_profile(self, hash_hex: str, content: str) -> None:
|
|
172
|
+
"""Persist a completed profile card for *hash_hex*."""
|
|
173
|
+
self._profiles_root.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
(self._profiles_root / f"{hash_hex}.md").write_text(content, encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
# ── Private helpers ───────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _resolve_dataset_path(self, dataset_path: str) -> Path:
|
|
179
|
+
p = Path(dataset_path)
|
|
180
|
+
path = (self._workspace_path / p).resolve() if not p.is_absolute() else p.resolve()
|
|
181
|
+
if not path.exists():
|
|
182
|
+
raise FileNotFoundError(f"Dataset path does not exist: {path}")
|
|
183
|
+
return path
|
|
184
|
+
|
|
185
|
+
# ── BaseContextStore: plans ───────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def current_plan(self, session_id: str) -> str | None:
|
|
188
|
+
"""Return the most recent plan for this session.
|
|
189
|
+
|
|
190
|
+
Resolves the plan by reading the latest ``{session_id}_*.txt`` file from
|
|
191
|
+
disk so the plan survives process restarts. Falls back to the in-memory
|
|
192
|
+
cache when no file exists yet or a disk read fails.
|
|
193
|
+
"""
|
|
194
|
+
if self._plans_root.exists():
|
|
195
|
+
files = sorted(self._plans_root.glob(f"{session_id}_*.txt"))
|
|
196
|
+
if files:
|
|
197
|
+
try:
|
|
198
|
+
return files[-1].read_text(encoding="utf-8")
|
|
199
|
+
except OSError:
|
|
200
|
+
logger.warning("Could not read plan file: %s", files[-1], exc_info=True)
|
|
201
|
+
return self._current_plan
|
|
202
|
+
|
|
203
|
+
def save_plan(self, session_id: str, plan: str) -> None:
|
|
204
|
+
"""Persist *plan* to disk and prune stale files.
|
|
205
|
+
|
|
206
|
+
Also updates the in-memory cache used as a fallback when no plan file
|
|
207
|
+
can be read back.
|
|
208
|
+
"""
|
|
209
|
+
self._current_plan = plan
|
|
210
|
+
self._plans_root.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
stamp = _datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
212
|
+
path = self._plans_root / f"{session_id}_{stamp}.txt"
|
|
213
|
+
try:
|
|
214
|
+
path.write_text(plan, encoding="utf-8")
|
|
215
|
+
except OSError:
|
|
216
|
+
logger.warning("Could not write plan file: %s", path, exc_info=True)
|
|
217
|
+
return
|
|
218
|
+
self.prune()
|
|
219
|
+
|
|
220
|
+
def prune(self) -> None:
|
|
221
|
+
"""Keep only the most recent plan file per session_id."""
|
|
222
|
+
if not self._plans_root.exists():
|
|
223
|
+
return
|
|
224
|
+
by_session: dict[str, list[Path]] = {}
|
|
225
|
+
for p in self._plans_root.glob("*.txt"):
|
|
226
|
+
sid = p.stem.partition("_")[0]
|
|
227
|
+
if not sid:
|
|
228
|
+
continue
|
|
229
|
+
by_session.setdefault(sid, []).append(p)
|
|
230
|
+
for files in by_session.values():
|
|
231
|
+
files.sort(key=lambda f: f.name)
|
|
232
|
+
for stale in files[:-1]:
|
|
233
|
+
try:
|
|
234
|
+
stale.unlink()
|
|
235
|
+
except OSError:
|
|
236
|
+
logger.exception("Could not delete stale plan file: %s", stale)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from langchain_core.language_models import BaseChatModel
|
|
4
|
+
|
|
5
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_anthropic_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
9
|
+
"""Instantiate a ``ChatAnthropic`` model with extended thinking enabled."""
|
|
10
|
+
try:
|
|
11
|
+
from langchain_anthropic import ChatAnthropic
|
|
12
|
+
except ImportError as exc:
|
|
13
|
+
raise ValueError("langchain-anthropic is not installed.") from exc
|
|
14
|
+
return ChatAnthropic(
|
|
15
|
+
model=config.model,
|
|
16
|
+
api_key=config.anthropic_api_key,
|
|
17
|
+
# Temperature must be 1 when extended thinking is enabled.
|
|
18
|
+
temperature=1,
|
|
19
|
+
max_tokens=16000,
|
|
20
|
+
thinking={"type": "enabled", "budget_tokens": config.thinking_budget},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_anthropic_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
25
|
+
"""Instantiate a cheap ``ChatAnthropic`` model for auxiliary tasks (thinking disabled)."""
|
|
26
|
+
try:
|
|
27
|
+
from langchain_anthropic import ChatAnthropic
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise ValueError("langchain-anthropic is not installed.") from exc
|
|
30
|
+
return ChatAnthropic(
|
|
31
|
+
model=config.secondary_model,
|
|
32
|
+
api_key=config.anthropic_api_key,
|
|
33
|
+
temperature=0,
|
|
34
|
+
max_tokens=1000,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cached_system_prompt(prompt: str) -> list[dict[str, Any]]:
|
|
39
|
+
"""Wrap *prompt* with Anthropic's ephemeral cache breakpoint."""
|
|
40
|
+
return [{"type": "text", "text": prompt, "cache_control": {"type": "ephemeral"}}]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from langchain_core.language_models import BaseChatModel
|
|
4
|
+
from langchain_core.messages import AIMessageChunk
|
|
5
|
+
from langchain_core.outputs import ChatGenerationChunk
|
|
6
|
+
|
|
7
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from langchain_aws import ChatBedrockConverse as _BedrockBase
|
|
11
|
+
except ImportError:
|
|
12
|
+
_BedrockBase = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strip_list_usage_fields(chunk: ChatGenerationChunk) -> ChatGenerationChunk:
|
|
16
|
+
"""Remove list-valued fields from a chunk's usage_metadata.
|
|
17
|
+
|
|
18
|
+
Bedrock's TokenUsage includes a ``cacheDetails`` field (type: list) which
|
|
19
|
+
langchain_core's ``_dict_int_op`` cannot combine — it only accepts int/dict
|
|
20
|
+
values. Stripping the field here prevents the ValueError that would
|
|
21
|
+
otherwise surface as a spurious error event at the end of each streamed
|
|
22
|
+
response when prompt caching is active.
|
|
23
|
+
"""
|
|
24
|
+
msg = chunk.message
|
|
25
|
+
if not (isinstance(msg, AIMessageChunk) and msg.usage_metadata):
|
|
26
|
+
return chunk
|
|
27
|
+
if not any(isinstance(v, list) for v in msg.usage_metadata.values()):
|
|
28
|
+
return chunk
|
|
29
|
+
cleaned = {k: v for k, v in msg.usage_metadata.items() if not isinstance(v, list)}
|
|
30
|
+
return ChatGenerationChunk(
|
|
31
|
+
message=msg.model_copy(update={"usage_metadata": cleaned}),
|
|
32
|
+
generation_info=chunk.generation_info,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if _BedrockBase is not None:
|
|
37
|
+
|
|
38
|
+
class _CustomBedrockConverse(_BedrockBase): # type: ignore[misc]
|
|
39
|
+
def _stream(
|
|
40
|
+
self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any
|
|
41
|
+
) -> Any:
|
|
42
|
+
for chunk in super()._stream(messages, stop, run_manager, **kwargs):
|
|
43
|
+
yield _strip_list_usage_fields(chunk)
|
|
44
|
+
else:
|
|
45
|
+
_CustomBedrockConverse = None # type: ignore[assignment,misc]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_bedrock_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
49
|
+
"""Instantiate a Bedrock Converse model with adaptive thinking and streaming enabled."""
|
|
50
|
+
if _CustomBedrockConverse is None:
|
|
51
|
+
raise ValueError("langchain-aws is not installed.")
|
|
52
|
+
return _CustomBedrockConverse(
|
|
53
|
+
model=config.model,
|
|
54
|
+
region_name=config.aws_region,
|
|
55
|
+
# Temperature must be 1 when extended thinking is enabled.
|
|
56
|
+
temperature=1,
|
|
57
|
+
max_tokens=16000,
|
|
58
|
+
additional_model_request_fields={
|
|
59
|
+
"thinking": {"type": "adaptive"},
|
|
60
|
+
"output_config": {"effort": "medium"},
|
|
61
|
+
},
|
|
62
|
+
# langchain-aws's set_disable_streaming validator only recognises
|
|
63
|
+
# "claude-3" model IDs as streaming-capable. Claude 4 models
|
|
64
|
+
# (claude-sonnet-4-6, etc.) match none of the patterns and default to
|
|
65
|
+
# disable_streaming=True, causing every response to arrive as a single
|
|
66
|
+
# non-streamed chunk. Claude 4 supports Bedrock ConverseStream with
|
|
67
|
+
# tools, so we override the auto-detection explicitly.
|
|
68
|
+
disable_streaming=False,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_bedrock_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
73
|
+
"""Instantiate a cheap Bedrock model for auxiliary tasks (thinking disabled)."""
|
|
74
|
+
if _BedrockBase is None:
|
|
75
|
+
raise ValueError("langchain-aws is not installed.")
|
|
76
|
+
return _BedrockBase( # type: ignore[no-any-return]
|
|
77
|
+
model=config.secondary_model,
|
|
78
|
+
region_name=config.aws_region,
|
|
79
|
+
temperature=0,
|
|
80
|
+
max_tokens=1000,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cached_system_prompt(prompt: str) -> list[dict[str, Any]]:
|
|
85
|
+
"""Wrap *prompt* with a Bedrock Converse cache point breakpoint."""
|
|
86
|
+
return [{"type": "text", "text": prompt}, {"cachePoint": {"type": "default"}}]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM provider factory for the Agent.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from langchain_core.language_models import BaseChatModel
|
|
11
|
+
|
|
12
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
13
|
+
from opendatasci.models.providers import Provider
|
|
14
|
+
|
|
15
|
+
_LOG = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_TRANSIENT_KEYWORDS = frozenset(
|
|
18
|
+
[
|
|
19
|
+
"rate limit",
|
|
20
|
+
"ratelimit",
|
|
21
|
+
"rate_limit",
|
|
22
|
+
"429",
|
|
23
|
+
"too many requests",
|
|
24
|
+
"overloaded",
|
|
25
|
+
"overload",
|
|
26
|
+
"503",
|
|
27
|
+
"service unavailable",
|
|
28
|
+
"throttl",
|
|
29
|
+
"connection error",
|
|
30
|
+
"connection refused",
|
|
31
|
+
"connecterror",
|
|
32
|
+
"apiconnectionerror",
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_MAX_RETRY_WAIT = 60.0
|
|
37
|
+
_DEFAULT_MAX_ATTEMPTS = 5
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_transient(exc: Exception) -> bool:
|
|
41
|
+
text = (type(exc).__name__ + " " + str(exc)).lower()
|
|
42
|
+
return any(kw in text for kw in _TRANSIENT_KEYWORDS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _RetryRunnable:
|
|
46
|
+
"""Wraps a LangChain Runnable, retrying transient errors with exponential backoff."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, runnable: Any, max_attempts: int = _DEFAULT_MAX_ATTEMPTS) -> None:
|
|
49
|
+
self._runnable = runnable
|
|
50
|
+
self._max_attempts = max_attempts
|
|
51
|
+
|
|
52
|
+
async def ainvoke(self, *args: Any, **kwargs: Any) -> Any:
|
|
53
|
+
for attempt in range(self._max_attempts):
|
|
54
|
+
try:
|
|
55
|
+
return await self._runnable.ainvoke(*args, **kwargs)
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
if _is_transient(exc) and attempt < self._max_attempts - 1:
|
|
58
|
+
wait = min(_MAX_RETRY_WAIT, (2**attempt) + random.random())
|
|
59
|
+
_LOG.warning(
|
|
60
|
+
"LLM transient error (attempt %d/%d), retrying in %.1fs: %s",
|
|
61
|
+
attempt + 1,
|
|
62
|
+
self._max_attempts,
|
|
63
|
+
wait,
|
|
64
|
+
exc,
|
|
65
|
+
)
|
|
66
|
+
await asyncio.sleep(wait)
|
|
67
|
+
else:
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def with_retry(runnable: Any, max_attempts: int = _DEFAULT_MAX_ATTEMPTS) -> _RetryRunnable:
|
|
72
|
+
"""Wrap *runnable* so that transient LLM errors are retried with exponential backoff."""
|
|
73
|
+
return _RetryRunnable(runnable, max_attempts=max_attempts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _supported_providers() -> str:
|
|
77
|
+
return ", ".join(f"'{p}'" for p in Provider)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
81
|
+
"""Instantiate the primary LLM for the agent and workers.
|
|
82
|
+
|
|
83
|
+
Thinking is enabled by default for providers that support it (Anthropic, Bedrock).
|
|
84
|
+
Falls back to the provider's default primary model when none is specified.
|
|
85
|
+
"""
|
|
86
|
+
match config.provider:
|
|
87
|
+
case Provider.ANTHROPIC:
|
|
88
|
+
from opendatasci.models.anthropic import create_anthropic_model as _create
|
|
89
|
+
|
|
90
|
+
return _create(config)
|
|
91
|
+
case Provider.BEDROCK:
|
|
92
|
+
from opendatasci.models.aws import create_bedrock_model as _create
|
|
93
|
+
|
|
94
|
+
return _create(config)
|
|
95
|
+
case Provider.OPENAI:
|
|
96
|
+
from opendatasci.models.openai import create_openai_model as _create
|
|
97
|
+
|
|
98
|
+
return _create(config)
|
|
99
|
+
case Provider.GEMINI:
|
|
100
|
+
from opendatasci.models.google import create_gemini_model as _create
|
|
101
|
+
|
|
102
|
+
return _create(config)
|
|
103
|
+
case Provider.VERTEXAI:
|
|
104
|
+
from opendatasci.models.google import create_vertexai_model as _create
|
|
105
|
+
|
|
106
|
+
return _create(config)
|
|
107
|
+
case Provider.AZURE:
|
|
108
|
+
from opendatasci.models.microsoft import create_azure_model as _create
|
|
109
|
+
|
|
110
|
+
return _create(config)
|
|
111
|
+
case Provider.OLLAMA:
|
|
112
|
+
from opendatasci.models.local import create_ollama_model as _create
|
|
113
|
+
|
|
114
|
+
return _create(config)
|
|
115
|
+
case Provider.OPENAI_COMPATIBLE_SERVER:
|
|
116
|
+
from opendatasci.models.local import create_openai_compatible_model as _create
|
|
117
|
+
|
|
118
|
+
return _create(config)
|
|
119
|
+
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Unknown provider '{config.provider}'. Supported providers: {_supported_providers()}."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def create_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
126
|
+
"""Instantiate the secondary (auxiliary) LLM for lightweight tasks such as summarization.
|
|
127
|
+
|
|
128
|
+
Thinking is disabled. Falls back to the provider's default secondary model when none is
|
|
129
|
+
specified.
|
|
130
|
+
|
|
131
|
+
When ``config.secondary_provider`` differs from ``config.provider``, a
|
|
132
|
+
lightweight config copy is used so the secondary model's factory resolves its
|
|
133
|
+
API key from its own environment variable rather than the primary provider's.
|
|
134
|
+
"""
|
|
135
|
+
secondary_provider = config.secondary_provider
|
|
136
|
+
|
|
137
|
+
# When the secondary model uses a different provider, switch the provider
|
|
138
|
+
# field so the factory dispatches correctly. Each provider reads its own
|
|
139
|
+
# dedicated API-key field, so no credential clearing is needed.
|
|
140
|
+
if secondary_provider != config.provider:
|
|
141
|
+
config = config.model_copy(update={"provider": secondary_provider})
|
|
142
|
+
|
|
143
|
+
match secondary_provider:
|
|
144
|
+
case Provider.ANTHROPIC:
|
|
145
|
+
from opendatasci.models.anthropic import create_anthropic_secondary_model as _create
|
|
146
|
+
|
|
147
|
+
return _create(config)
|
|
148
|
+
case Provider.BEDROCK:
|
|
149
|
+
from opendatasci.models.aws import create_bedrock_secondary_model as _create
|
|
150
|
+
|
|
151
|
+
return _create(config)
|
|
152
|
+
case Provider.OPENAI:
|
|
153
|
+
from opendatasci.models.openai import create_openai_secondary_model as _create
|
|
154
|
+
|
|
155
|
+
return _create(config)
|
|
156
|
+
case Provider.GEMINI:
|
|
157
|
+
from opendatasci.models.google import create_gemini_secondary_model as _create
|
|
158
|
+
|
|
159
|
+
return _create(config)
|
|
160
|
+
case Provider.VERTEXAI:
|
|
161
|
+
from opendatasci.models.google import create_vertexai_secondary_model as _create
|
|
162
|
+
|
|
163
|
+
return _create(config)
|
|
164
|
+
case Provider.AZURE:
|
|
165
|
+
from opendatasci.models.microsoft import create_azure_secondary_model as _create
|
|
166
|
+
|
|
167
|
+
return _create(config)
|
|
168
|
+
case Provider.OLLAMA:
|
|
169
|
+
from opendatasci.models.local import create_ollama_secondary_model as _create
|
|
170
|
+
|
|
171
|
+
return _create(config)
|
|
172
|
+
case Provider.OPENAI_COMPATIBLE_SERVER:
|
|
173
|
+
from opendatasci.models.local import create_openai_compatible_secondary_model as _create
|
|
174
|
+
|
|
175
|
+
return _create(config)
|
|
176
|
+
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Unknown provider '{secondary_provider}'. Supported providers: {_supported_providers()}."
|
|
179
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from langchain_core.language_models import BaseChatModel
|
|
2
|
+
|
|
3
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_gemini_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
7
|
+
"""Instantiate a ``ChatGoogleGenerativeAI`` model via the Gemini API."""
|
|
8
|
+
try:
|
|
9
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
10
|
+
except ImportError as exc:
|
|
11
|
+
raise ValueError(
|
|
12
|
+
"langchain-google-genai is not installed. Run: pip install 'open-data-sci[gemini]'"
|
|
13
|
+
) from exc
|
|
14
|
+
return ChatGoogleGenerativeAI( # type: ignore[no-any-return]
|
|
15
|
+
model=config.model,
|
|
16
|
+
google_api_key=config.google_api_key,
|
|
17
|
+
temperature=config.temperature,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_gemini_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
22
|
+
"""Instantiate a cheap Gemini model for auxiliary tasks."""
|
|
23
|
+
try:
|
|
24
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
"langchain-google-genai is not installed. Run: pip install 'open-data-sci[gemini]'"
|
|
28
|
+
) from exc
|
|
29
|
+
return ChatGoogleGenerativeAI( # type: ignore[no-any-return]
|
|
30
|
+
model=config.secondary_model,
|
|
31
|
+
google_api_key=config.google_api_key,
|
|
32
|
+
temperature=0,
|
|
33
|
+
max_output_tokens=1000,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_vertexai_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
38
|
+
"""Instantiate a ``ChatVertexAI`` model via Google Cloud Vertex AI."""
|
|
39
|
+
try:
|
|
40
|
+
from langchain_google_vertexai import ChatVertexAI # type: ignore[import-not-found]
|
|
41
|
+
except ImportError as exc:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"langchain-google-vertexai is not installed. Run: pip install 'open-data-sci[gcp]'"
|
|
44
|
+
) from exc
|
|
45
|
+
return ChatVertexAI( # type: ignore[no-any-return]
|
|
46
|
+
model=config.model,
|
|
47
|
+
project=config.google_cloud_project,
|
|
48
|
+
location=config.google_cloud_location,
|
|
49
|
+
temperature=config.temperature,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_vertexai_secondary_model(config: OpenDataSciConfig) -> BaseChatModel:
|
|
54
|
+
"""Instantiate a cheap Vertex AI model for auxiliary tasks."""
|
|
55
|
+
try:
|
|
56
|
+
from langchain_google_vertexai import ChatVertexAI
|
|
57
|
+
except ImportError as exc:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"langchain-google-vertexai is not installed. Run: pip install 'open-data-sci[gcp]'"
|
|
60
|
+
) from exc
|
|
61
|
+
return ChatVertexAI( # type: ignore[no-any-return]
|
|
62
|
+
model=config.secondary_model,
|
|
63
|
+
project=config.google_cloud_project,
|
|
64
|
+
location=config.google_cloud_location,
|
|
65
|
+
temperature=0,
|
|
66
|
+
max_output_tokens=1000,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cached_system_prompt(prompt: str) -> str:
|
|
71
|
+
"""Return the system prompt unchanged.
|
|
72
|
+
|
|
73
|
+
Gemini 2.5+ models perform implicit context caching automatically for
|
|
74
|
+
prompts above the per-model minimum (>= 1024 tokens for Flash, >= 4096 for
|
|
75
|
+
Pro), keying off the request's leading prefix. No client-side cache
|
|
76
|
+
markers are required, and explicit `cached_content` setup is intentionally
|
|
77
|
+
not used here to keep the model factory side-effect free.
|
|
78
|
+
"""
|
|
79
|
+
return prompt
|