devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.cross_project
|
|
3
|
+
====================================================
|
|
4
|
+
Cross-project learning suggestions.
|
|
5
|
+
|
|
6
|
+
Maintains a machine-level registry of governed projects at
|
|
7
|
+
``~/.devtorch/registry.json`` under the ``governed_projects`` key and lets a
|
|
8
|
+
project surface relevant learnings from sibling projects with matching tags or
|
|
9
|
+
names.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from devtorch_core.reasoning_plus.learning.models import Learning
|
|
22
|
+
from devtorch_core.reasoning_plus.learning.relevance import RelevanceEngine
|
|
23
|
+
from devtorch_core.reasoning_plus.learning.store import LearningStore
|
|
24
|
+
from devtorch_core.reasoning_plus.context import extract_keywords
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning.cross_project")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_REGISTRY_DIR = Path.home() / ".devtorch"
|
|
30
|
+
DEFAULT_REGISTRY_FILE = "registry.json"
|
|
31
|
+
REGISTRY_PATH_ENV = "DEVTORCH_REGISTRY_PATH"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class GovernedProject:
|
|
36
|
+
"""One governed project entry in the cross-project learning registry."""
|
|
37
|
+
|
|
38
|
+
project_root: str
|
|
39
|
+
name: str
|
|
40
|
+
tags: list[str]
|
|
41
|
+
gcc_dir: str
|
|
42
|
+
added_at: str
|
|
43
|
+
updated_at: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CrossProjectRegistry:
|
|
47
|
+
"""Machine-level registry of governed projects used for cross-project learning.
|
|
48
|
+
|
|
49
|
+
Stored under the ``governed_projects`` key in ``~/.devtorch/registry.json``
|
|
50
|
+
so it coexists with the Sprint 19 project registry (which uses the
|
|
51
|
+
``projects`` key).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, registry_path: Path | str | None = None) -> None:
|
|
55
|
+
if registry_path is None:
|
|
56
|
+
env_path = os.environ.get(REGISTRY_PATH_ENV, "").strip()
|
|
57
|
+
if env_path:
|
|
58
|
+
registry_path = Path(env_path)
|
|
59
|
+
else:
|
|
60
|
+
registry_path = DEFAULT_REGISTRY_DIR / DEFAULT_REGISTRY_FILE
|
|
61
|
+
self.registry_path = Path(registry_path)
|
|
62
|
+
self._ensure_dir()
|
|
63
|
+
|
|
64
|
+
def _ensure_dir(self) -> None:
|
|
65
|
+
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
def _read(self) -> dict[str, Any]:
|
|
68
|
+
if not self.registry_path.exists():
|
|
69
|
+
return {"version": "1", "projects": [], "governed_projects": []}
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
return {"version": "1", "projects": [], "governed_projects": []}
|
|
74
|
+
return data
|
|
75
|
+
except (json.JSONDecodeError, OSError):
|
|
76
|
+
return {"version": "1", "projects": [], "governed_projects": []}
|
|
77
|
+
|
|
78
|
+
def _write(self, data: dict[str, Any]) -> None:
|
|
79
|
+
self._ensure_dir()
|
|
80
|
+
self.registry_path.write_text(
|
|
81
|
+
json.dumps(data, indent=2, sort_keys=True), encoding="utf-8"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def list(self) -> list[GovernedProject]:
|
|
85
|
+
"""Return all registered governed projects."""
|
|
86
|
+
data = self._read()
|
|
87
|
+
entries: list[GovernedProject] = []
|
|
88
|
+
for raw in data.get("governed_projects", []):
|
|
89
|
+
try:
|
|
90
|
+
entries.append(GovernedProject(**raw))
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
logger.debug("devtorch: skipping malformed governed project entry: %s", raw)
|
|
93
|
+
continue
|
|
94
|
+
return entries
|
|
95
|
+
|
|
96
|
+
def add(self, gcc_dir: Path | str, name: str, tags: list[str] | None = None) -> GovernedProject:
|
|
97
|
+
"""Add or update a governed project entry keyed by gcc_dir."""
|
|
98
|
+
gcc_dir = Path(gcc_dir).resolve()
|
|
99
|
+
project_root = gcc_dir.parent
|
|
100
|
+
name = name or project_root.name
|
|
101
|
+
tags = sorted(set((tags or [])))
|
|
102
|
+
|
|
103
|
+
data = self._read()
|
|
104
|
+
projects = data.setdefault("governed_projects", [])
|
|
105
|
+
|
|
106
|
+
resolved_root = str(project_root)
|
|
107
|
+
for raw in projects:
|
|
108
|
+
if Path(raw.get("gcc_dir", "")).resolve() == gcc_dir:
|
|
109
|
+
raw["project_root"] = resolved_root
|
|
110
|
+
raw["name"] = name
|
|
111
|
+
raw["tags"] = tags
|
|
112
|
+
raw["updated_at"] = _now_iso()
|
|
113
|
+
self._write(data)
|
|
114
|
+
return GovernedProject(**raw)
|
|
115
|
+
|
|
116
|
+
entry = GovernedProject(
|
|
117
|
+
project_root=resolved_root,
|
|
118
|
+
name=name,
|
|
119
|
+
tags=tags,
|
|
120
|
+
gcc_dir=str(gcc_dir),
|
|
121
|
+
added_at=_now_iso(),
|
|
122
|
+
updated_at=_now_iso(),
|
|
123
|
+
)
|
|
124
|
+
projects.append(asdict(entry))
|
|
125
|
+
self._write(data)
|
|
126
|
+
return entry
|
|
127
|
+
|
|
128
|
+
def remove(self, gcc_dir: Path | str) -> bool:
|
|
129
|
+
"""Remove a governed project by gcc_dir. Returns True if removed."""
|
|
130
|
+
gcc_dir = Path(gcc_dir).resolve()
|
|
131
|
+
data = self._read()
|
|
132
|
+
projects = data.get("governed_projects", [])
|
|
133
|
+
original_len = len(projects)
|
|
134
|
+
projects = [
|
|
135
|
+
raw for raw in projects
|
|
136
|
+
if Path(raw.get("gcc_dir", "")).resolve() != gcc_dir
|
|
137
|
+
]
|
|
138
|
+
if len(projects) == original_len:
|
|
139
|
+
return False
|
|
140
|
+
data["governed_projects"] = projects
|
|
141
|
+
self._write(data)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _registry() -> CrossProjectRegistry:
|
|
146
|
+
return CrossProjectRegistry()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def register_project(gcc_dir: Path | str, name: str, tags: list[str] | None = None) -> GovernedProject:
|
|
150
|
+
"""Add or update the current project in the governed project registry."""
|
|
151
|
+
return _registry().add(gcc_dir, name, tags)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_projects() -> list[GovernedProject]:
|
|
155
|
+
"""Return all registered governed projects."""
|
|
156
|
+
return _registry().list()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _project_match_score(query: str, project: GovernedProject) -> float:
|
|
160
|
+
"""Keyword overlap score between the query and project name/tags."""
|
|
161
|
+
query_tokens = set(extract_keywords(query, max_keywords=20))
|
|
162
|
+
if not query_tokens:
|
|
163
|
+
return 0.0
|
|
164
|
+
|
|
165
|
+
project_tokens: set[str] = set()
|
|
166
|
+
project_tokens.update(extract_keywords(project.name, max_keywords=20))
|
|
167
|
+
for tag in project.tags:
|
|
168
|
+
project_tokens.update(extract_keywords(tag, max_keywords=20))
|
|
169
|
+
project_tokens.add(tag.lower())
|
|
170
|
+
|
|
171
|
+
if not project_tokens:
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
intersection = query_tokens & project_tokens
|
|
175
|
+
return len(intersection) / max(len(query_tokens), len(project_tokens))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_active_learnings(gcc_dir: Path | str) -> list[Learning]:
|
|
179
|
+
"""Load active learnings from a project's .GCC directory."""
|
|
180
|
+
try:
|
|
181
|
+
store = LearningStore(gcc_dir)
|
|
182
|
+
return store.list(validity="active")
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.warning("devtorch: failed to load learnings from %s — %s", gcc_dir, exc)
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cross_project_suggest(
|
|
189
|
+
query: str,
|
|
190
|
+
current_project_root: Path | str,
|
|
191
|
+
top_n: int = 3,
|
|
192
|
+
project_match_threshold: float = 0.1,
|
|
193
|
+
) -> list[Learning]:
|
|
194
|
+
"""Find relevant learnings from sibling governed projects.
|
|
195
|
+
|
|
196
|
+
Projects are matched by keyword overlap with the query using their name and
|
|
197
|
+
tags. Active learnings from matched projects are loaded via ``LearningStore``
|
|
198
|
+
and ranked with the keyword-based ``RelevanceEngine``. The current project is
|
|
199
|
+
excluded so suggestions come from other governed projects.
|
|
200
|
+
"""
|
|
201
|
+
current_project_root = Path(current_project_root).resolve()
|
|
202
|
+
registry = _registry()
|
|
203
|
+
projects = registry.list()
|
|
204
|
+
|
|
205
|
+
# Exclude current project to avoid suggesting its own learnings back to it.
|
|
206
|
+
sibling_projects = [
|
|
207
|
+
p for p in projects
|
|
208
|
+
if Path(p.project_root).resolve() != current_project_root
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
# Match projects by name/tags.
|
|
212
|
+
scored_projects = [
|
|
213
|
+
(score, p)
|
|
214
|
+
for p in sibling_projects
|
|
215
|
+
if (score := _project_match_score(query, p)) >= project_match_threshold
|
|
216
|
+
]
|
|
217
|
+
scored_projects.sort(key=lambda x: x[0], reverse=True)
|
|
218
|
+
|
|
219
|
+
# Collect active learnings from all matched projects.
|
|
220
|
+
all_learnings: list[Learning] = []
|
|
221
|
+
for _, project in scored_projects:
|
|
222
|
+
learnings = _load_active_learnings(project.gcc_dir)
|
|
223
|
+
all_learnings.extend(learnings)
|
|
224
|
+
|
|
225
|
+
if not all_learnings:
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
# Rank across projects using the same keyword backend used by the facade.
|
|
229
|
+
engine = RelevanceEngine()
|
|
230
|
+
return engine.rank(query, all_learnings, top_n=top_n)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _now_iso() -> str:
|
|
234
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.embeddings
|
|
3
|
+
================================================
|
|
4
|
+
Pluggable embedding backends for DRPL relevance.
|
|
5
|
+
|
|
6
|
+
The default backend is keyword-based (no extra dependencies). Users can opt
|
|
7
|
+
into dense semantic embeddings by installing the optional ``embeddings`` extra:
|
|
8
|
+
|
|
9
|
+
pip install devtorch-core[embeddings]
|
|
10
|
+
|
|
11
|
+
and then configuring the backend:
|
|
12
|
+
|
|
13
|
+
devtorch reasoning-plus config \
|
|
14
|
+
--learning-embedding-backend sentence_transformers \
|
|
15
|
+
--learning-embedding-model all-MiniLM-L6-v2
|
|
16
|
+
|
|
17
|
+
Supported backends:
|
|
18
|
+
- ``keyword`` — Jaccard similarity over extracted keywords (default)
|
|
19
|
+
- ``sentence_transformers`` — cosine similarity over dense embeddings (local)
|
|
20
|
+
- ``openai`` — cosine similarity over OpenAI embeddings (remote)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from devtorch_core.reasoning_plus.context import extract_keywords
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Base class
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
class EmbeddingBackend(ABC):
|
|
39
|
+
"""Abstract embedding backend for DRPL relevance scoring."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def encode(self, text: str) -> Any:
|
|
43
|
+
"""Encode *text* into a backend-specific representation."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def similarity(self, a: Any, b: Any) -> float:
|
|
48
|
+
"""Return a similarity score in [0, 1] for two encodings."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
"""Short identifier for this backend."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Keyword backend (default, no dependencies)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
class KeywordEmbeddingBackend(EmbeddingBackend):
|
|
63
|
+
"""Keyword-overlap backend that uses extracted concepts as a sparse vector."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, max_keywords: int = 15) -> None:
|
|
66
|
+
self.max_keywords = max_keywords
|
|
67
|
+
|
|
68
|
+
def encode(self, text: str) -> set[str]:
|
|
69
|
+
return set(extract_keywords(text, max_keywords=self.max_keywords))
|
|
70
|
+
|
|
71
|
+
def similarity(self, a: set[str], b: set[str]) -> float:
|
|
72
|
+
if not a and not b:
|
|
73
|
+
return 0.0
|
|
74
|
+
overlap = len(a & b)
|
|
75
|
+
total = len(a | b)
|
|
76
|
+
return overlap / total if total else 0.0
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def name(self) -> str:
|
|
80
|
+
return "keyword"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Dense-vector helpers
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
88
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
89
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
90
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
91
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
92
|
+
return 0.0
|
|
93
|
+
return max(0.0, min(1.0, dot / (norm_a * norm_b)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Sentence-transformers backend (optional)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
class SentenceTransformerBackend(EmbeddingBackend):
|
|
101
|
+
"""Local dense embedding backend via sentence-transformers."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
|
|
104
|
+
try:
|
|
105
|
+
from sentence_transformers import SentenceTransformer
|
|
106
|
+
except ImportError as exc:
|
|
107
|
+
raise ImportError(
|
|
108
|
+
"sentence-transformers is not installed. "
|
|
109
|
+
"Install the embeddings extra: pip install devtorch-core[embeddings]"
|
|
110
|
+
) from exc
|
|
111
|
+
self._model = SentenceTransformer(model_name)
|
|
112
|
+
self._model_name = model_name
|
|
113
|
+
|
|
114
|
+
def encode(self, text: str) -> list[float]:
|
|
115
|
+
import numpy as np
|
|
116
|
+
|
|
117
|
+
vector = self._model.encode(text)
|
|
118
|
+
return vector.tolist()
|
|
119
|
+
|
|
120
|
+
def similarity(self, a: list[float], b: list[float]) -> float:
|
|
121
|
+
return _cosine_similarity(a, b)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def name(self) -> str:
|
|
125
|
+
return "sentence_transformers"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# OpenAI backend (optional)
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
class OpenAIEmbeddingBackend(EmbeddingBackend):
|
|
133
|
+
"""Remote dense embedding backend via OpenAI embeddings API."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
model_name: str = "text-embedding-3-small",
|
|
138
|
+
api_key: str | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
try:
|
|
141
|
+
import openai
|
|
142
|
+
except ImportError as exc:
|
|
143
|
+
raise ImportError(
|
|
144
|
+
"openai is not installed. "
|
|
145
|
+
"Install the wrapper extra: pip install devtorch-core[wrapper]"
|
|
146
|
+
) from exc
|
|
147
|
+
self._client = openai.OpenAI(api_key=api_key)
|
|
148
|
+
self._model_name = model_name
|
|
149
|
+
|
|
150
|
+
def encode(self, text: str) -> list[float]:
|
|
151
|
+
response = self._client.embeddings.create(
|
|
152
|
+
input=text,
|
|
153
|
+
model=self._model_name,
|
|
154
|
+
)
|
|
155
|
+
return response.data[0].embedding
|
|
156
|
+
|
|
157
|
+
def similarity(self, a: list[float], b: list[float]) -> float:
|
|
158
|
+
return _cosine_similarity(a, b)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def name(self) -> str:
|
|
162
|
+
return "openai"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Fake backend (for tests)
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
class FakeEmbeddingBackend(EmbeddingBackend):
|
|
170
|
+
"""Deterministic fake backend for unit tests."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, embeddings: dict[str, list[float]] | None = None, dim: int = 8) -> None:
|
|
173
|
+
self._embeddings: dict[str, list[float]] = embeddings or {}
|
|
174
|
+
self._dim = dim
|
|
175
|
+
|
|
176
|
+
def encode(self, text: str) -> list[float]:
|
|
177
|
+
return self._embeddings.get(text, [0.0] * self._dim)
|
|
178
|
+
|
|
179
|
+
def similarity(self, a: list[float], b: list[float]) -> float:
|
|
180
|
+
return _cosine_similarity(a, b)
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def name(self) -> str:
|
|
184
|
+
return "fake"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Factory
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def get_embedding_backend(
|
|
192
|
+
name: str | None = None,
|
|
193
|
+
model_name: str | None = None,
|
|
194
|
+
max_keywords: int = 15,
|
|
195
|
+
) -> EmbeddingBackend:
|
|
196
|
+
"""
|
|
197
|
+
Return an embedding backend by name.
|
|
198
|
+
|
|
199
|
+
Supported names: ``keyword`` (default), ``sentence_transformers``,
|
|
200
|
+
``sentence-transformers``, ``st``, ``openai``.
|
|
201
|
+
"""
|
|
202
|
+
name = (name or "keyword").lower().strip().replace("-", "_")
|
|
203
|
+
if name in ("keyword", "keywords"):
|
|
204
|
+
return KeywordEmbeddingBackend(max_keywords=max_keywords)
|
|
205
|
+
if name in ("sentence_transformers", "sentence_transformer", "st"):
|
|
206
|
+
return SentenceTransformerBackend(model_name or "all-MiniLM-L6-v2")
|
|
207
|
+
if name == "openai":
|
|
208
|
+
return OpenAIEmbeddingBackend(model_name or "text-embedding-3-small")
|
|
209
|
+
raise ValueError(f"Unknown embedding backend: {name}")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.extractor
|
|
3
|
+
===============================================
|
|
4
|
+
Distill ReasoningCall records into reusable Learning objects.
|
|
5
|
+
|
|
6
|
+
Two strategies are supported:
|
|
7
|
+
|
|
8
|
+
1. **Rule-based** (default, no dependencies) — pair captured reasoning with the
|
|
9
|
+
observed outcome and synthesize a compact learning sentence.
|
|
10
|
+
2. **LLM-based** (optional) — pass the reasoning, input, output, and outcome to
|
|
11
|
+
a small LLM prompt that returns a concise learning summary. The caller
|
|
12
|
+
supplies the LLM client, so the core library remains dependency-free.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from devtorch_core.reasoning_plus.context import extract_keywords
|
|
20
|
+
from devtorch_core.reasoning_plus.learning.models import Learning, ReasoningCall
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
23
|
+
|
|
24
|
+
DEFAULT_EXTRACTION_PROMPT = """\
|
|
25
|
+
You are distilling a previous reasoning step into a short, reusable insight.
|
|
26
|
+
Given the reasoning, the action's input, its output, and the observed outcome,
|
|
27
|
+
write one concise sentence that another AI agent could reuse when facing a
|
|
28
|
+
similar situation.
|
|
29
|
+
|
|
30
|
+
Outcome: {outcome}
|
|
31
|
+
Name: {name}
|
|
32
|
+
Input: {input}
|
|
33
|
+
Output: {output}
|
|
34
|
+
Reasoning: {reasoning}
|
|
35
|
+
|
|
36
|
+
Insight:"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _type_for_outcome(outcome: str) -> str:
|
|
44
|
+
if outcome == "success":
|
|
45
|
+
return "confirm"
|
|
46
|
+
if outcome == "failure":
|
|
47
|
+
return "correction"
|
|
48
|
+
if outcome == "partial":
|
|
49
|
+
return "insight"
|
|
50
|
+
return "insight"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_concepts(call: ReasoningCall) -> list[str]:
|
|
54
|
+
text = f"{call.reasoning} {call.input} {call.output}"
|
|
55
|
+
return extract_keywords(text, max_keywords=15)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fallback_reasoning(call: ReasoningCall) -> str:
|
|
59
|
+
"""When the call has no captured reasoning, synthesize a short summary."""
|
|
60
|
+
if call.outcome not in ("success", "failure"):
|
|
61
|
+
return ""
|
|
62
|
+
name = call.name or call.call_type
|
|
63
|
+
if call.outcome == "failure":
|
|
64
|
+
return f"{name} failed. Input: {call.input}. Output: {call.output}."
|
|
65
|
+
return f"{name} succeeded. Input: {call.input}. Output: {call.output}."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compose_content_from_reasoning(call: ReasoningCall, reasoning: str) -> str:
|
|
69
|
+
reasoning = reasoning.strip().replace("\n", " ")
|
|
70
|
+
# Truncate very long reasoning
|
|
71
|
+
if len(reasoning) > 400:
|
|
72
|
+
reasoning = reasoning[:397].rstrip() + "..."
|
|
73
|
+
|
|
74
|
+
name = call.name or call.call_type
|
|
75
|
+
if call.outcome == "failure":
|
|
76
|
+
return (
|
|
77
|
+
f"Previous {name} attempt failed. The reasoning was: {reasoning}. "
|
|
78
|
+
f"Consider a different approach before trying the same path again."
|
|
79
|
+
)
|
|
80
|
+
if call.outcome == "success":
|
|
81
|
+
return (
|
|
82
|
+
f"Previous {name} attempt succeeded. The reasoning was: {reasoning}. "
|
|
83
|
+
f"This approach is worth reusing when the context is similar."
|
|
84
|
+
)
|
|
85
|
+
return (
|
|
86
|
+
f"Previous {name} attempt had outcome '{call.outcome}'. The reasoning was: {reasoning}."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_learning(call: ReasoningCall, reasoning: str) -> Learning:
|
|
91
|
+
"""Build a single Learning object from a reasoning summary."""
|
|
92
|
+
learning_type = _type_for_outcome(call.outcome)
|
|
93
|
+
content = _compose_content_from_reasoning(call, reasoning)
|
|
94
|
+
concepts = _extract_concepts(call)
|
|
95
|
+
|
|
96
|
+
# Start with moderate confidence so feedback can raise or lower it.
|
|
97
|
+
base_confidence = min(call.confidence, 0.8)
|
|
98
|
+
|
|
99
|
+
return Learning(
|
|
100
|
+
content=content,
|
|
101
|
+
type=learning_type,
|
|
102
|
+
trigger_concepts=concepts,
|
|
103
|
+
state_hash=call.state_hash,
|
|
104
|
+
confidence=base_confidence,
|
|
105
|
+
source_reasoning_ids=[call.id],
|
|
106
|
+
sensitivity=call.sensitivity,
|
|
107
|
+
meta={"call_type": call.call_type, "name": call.name, "outcome": call.outcome},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Rule-based extractor
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
class LearningExtractor:
|
|
116
|
+
"""Extract learnings from a single reasoning call using lightweight rules."""
|
|
117
|
+
|
|
118
|
+
def extract(self, call: ReasoningCall) -> list[Learning]:
|
|
119
|
+
"""
|
|
120
|
+
Return one or more Learning objects derived from the call.
|
|
121
|
+
|
|
122
|
+
For now we produce a single learning that summarizes the reasoning + outcome.
|
|
123
|
+
"""
|
|
124
|
+
reasoning = call.reasoning.strip()
|
|
125
|
+
if not reasoning:
|
|
126
|
+
reasoning = _fallback_reasoning(call)
|
|
127
|
+
|
|
128
|
+
if not reasoning:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
return [_build_learning(call, reasoning)]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Backwards-compatible alias
|
|
135
|
+
RuleBasedLearningExtractor = LearningExtractor
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# LLM-based extractor
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
class LLMBasedLearningExtractor(LearningExtractor):
|
|
143
|
+
"""
|
|
144
|
+
Extract learnings by asking an LLM to summarize a reasoning call.
|
|
145
|
+
|
|
146
|
+
The caller supplies ``client``, a callable that takes a prompt string and
|
|
147
|
+
returns a completion string. This keeps the core library free of any LLM
|
|
148
|
+
SDK dependency.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
client: Callable[[str], str],
|
|
154
|
+
prompt_template: str | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
self._client = client
|
|
157
|
+
self._prompt_template = prompt_template or DEFAULT_EXTRACTION_PROMPT
|
|
158
|
+
|
|
159
|
+
def extract(self, call: ReasoningCall) -> list[Learning]:
|
|
160
|
+
"""Use an LLM to summarize the reasoning; fall back to rule-based if the LLM fails."""
|
|
161
|
+
reasoning = call.reasoning.strip()
|
|
162
|
+
if not reasoning:
|
|
163
|
+
reasoning = _fallback_reasoning(call)
|
|
164
|
+
|
|
165
|
+
if not reasoning:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
prompt = self._prompt_template.format(
|
|
169
|
+
reasoning=reasoning,
|
|
170
|
+
input=call.input,
|
|
171
|
+
output=call.output,
|
|
172
|
+
outcome=call.outcome,
|
|
173
|
+
name=call.name or call.call_type,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
summary = self._client(prompt).strip()
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("devtorch: LLM-based extraction failed — %s", exc)
|
|
180
|
+
return [_build_learning(call, reasoning)]
|
|
181
|
+
|
|
182
|
+
if not summary:
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
return [_build_learning(call, summary)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Factory
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def make_extractor(
|
|
193
|
+
strategy: str = "rule",
|
|
194
|
+
llm_client: Callable[[str], str] | None = None,
|
|
195
|
+
) -> LearningExtractor:
|
|
196
|
+
"""
|
|
197
|
+
Build a learning extractor for the given strategy.
|
|
198
|
+
|
|
199
|
+
Strategies:
|
|
200
|
+
- ``rule`` (default): lightweight rule-based extraction.
|
|
201
|
+
- ``llm``: LLM-based extraction; requires a ``llm_client`` callable.
|
|
202
|
+
|
|
203
|
+
If ``llm`` is requested but no client is provided, falls back to rule-based.
|
|
204
|
+
"""
|
|
205
|
+
if strategy == "llm" and llm_client is not None:
|
|
206
|
+
return LLMBasedLearningExtractor(llm_client)
|
|
207
|
+
return LearningExtractor()
|