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,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.api
|
|
3
|
+
=========================================
|
|
4
|
+
High-level facade for Reasoning Plus Learning (DRPL).
|
|
5
|
+
|
|
6
|
+
This is the main entry point for callers. It orchestrates:
|
|
7
|
+
- recording calls
|
|
8
|
+
- extracting learnings
|
|
9
|
+
- retrieving relevant learnings
|
|
10
|
+
- composing prompt blocks
|
|
11
|
+
- applying outcome feedback
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
from devtorch_core.reasoning_plus.config import ReasoningPlusConfig
|
|
21
|
+
from devtorch_core.reasoning_plus.learning.composer import PromptComposer
|
|
22
|
+
from devtorch_core.reasoning_plus.learning.embeddings import (
|
|
23
|
+
EmbeddingBackend,
|
|
24
|
+
KeywordEmbeddingBackend,
|
|
25
|
+
get_embedding_backend,
|
|
26
|
+
)
|
|
27
|
+
from devtorch_core.reasoning_plus.learning.extractor import (
|
|
28
|
+
LearningExtractor,
|
|
29
|
+
make_extractor,
|
|
30
|
+
)
|
|
31
|
+
from devtorch_core.reasoning_plus.learning.models import Learning, ReasoningCall, _make_id
|
|
32
|
+
from devtorch_core.reasoning_plus.learning.provenance import ProvenanceRecord, ProvenanceStore, _hash_prompt
|
|
33
|
+
from devtorch_core.reasoning_plus.learning.recorder import CallRecorder
|
|
34
|
+
from devtorch_core.reasoning_plus.learning.relevance import RelevanceEngine
|
|
35
|
+
from devtorch_core.reasoning_plus.learning.state import workspace_state_hash
|
|
36
|
+
from devtorch_core.reasoning_plus.learning.store import LearningStore
|
|
37
|
+
from devtorch_core.reasoning_plus.learning import cross_project
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
LLMClient = Callable[[str], str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReasoningPlusLearning:
|
|
46
|
+
"""
|
|
47
|
+
Facade for learning from reasoning and feeding it back into future calls.
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
drpl = ReasoningPlusLearning(gcc_dir, repo_path)
|
|
51
|
+
call_id = drpl.record_call(
|
|
52
|
+
call_type="tool",
|
|
53
|
+
name="read_file",
|
|
54
|
+
reasoning="Reading utils.py because the error mentions parse_date.",
|
|
55
|
+
outcome="failure",
|
|
56
|
+
input="utils.py",
|
|
57
|
+
output="...",
|
|
58
|
+
state_hash=drpl.workspace_hash(),
|
|
59
|
+
session_id="session-1",
|
|
60
|
+
)
|
|
61
|
+
drpl.extract_learnings(call_id)
|
|
62
|
+
learnings = drpl.get_relevant_learnings("Fix the parse_date bug")
|
|
63
|
+
prompt = drpl.compose_user_prompt("Fix the parse_date bug", learnings)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
gcc_dir: Path | str,
|
|
69
|
+
repo_path: Path | str | None = None,
|
|
70
|
+
config: ReasoningPlusConfig | None = None,
|
|
71
|
+
llm_client: Callable[[str], str] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._gcc_dir = Path(gcc_dir)
|
|
74
|
+
self._repo_path = Path(repo_path) if repo_path else self._gcc_dir.parent
|
|
75
|
+
self._config = config or ReasoningPlusConfig()
|
|
76
|
+
self._llm_client = llm_client
|
|
77
|
+
|
|
78
|
+
self._recorder = CallRecorder(self._gcc_dir)
|
|
79
|
+
self._store = LearningStore(self._gcc_dir)
|
|
80
|
+
self._provenance = ProvenanceStore(self._gcc_dir)
|
|
81
|
+
self._extractor = self._make_extractor()
|
|
82
|
+
self._relevance = RelevanceEngine(
|
|
83
|
+
backend=self._make_backend(),
|
|
84
|
+
cache_ttl=self._config.learning_relevance_cache_ttl,
|
|
85
|
+
)
|
|
86
|
+
self._composer = PromptComposer(max_lines=self._config.learning_max_lines)
|
|
87
|
+
|
|
88
|
+
def _make_extractor(self) -> LearningExtractor:
|
|
89
|
+
"""Build the configured learning extractor, falling back to rule-based on errors."""
|
|
90
|
+
try:
|
|
91
|
+
return make_extractor(
|
|
92
|
+
strategy=self._config.learning_extraction_strategy,
|
|
93
|
+
llm_client=self._llm_client,
|
|
94
|
+
)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.warning(
|
|
97
|
+
"devtorch: failed to load learning extractor strategy '%s' — %s; falling back to rule-based",
|
|
98
|
+
self._config.learning_extraction_strategy,
|
|
99
|
+
exc,
|
|
100
|
+
)
|
|
101
|
+
return LearningExtractor()
|
|
102
|
+
|
|
103
|
+
def _make_backend(self) -> EmbeddingBackend:
|
|
104
|
+
"""Build the configured embedding backend, falling back to keyword on errors."""
|
|
105
|
+
try:
|
|
106
|
+
return get_embedding_backend(
|
|
107
|
+
self._config.learning_embedding_backend,
|
|
108
|
+
self._config.learning_embedding_model,
|
|
109
|
+
max_keywords=self._config.max_keywords,
|
|
110
|
+
)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"devtorch: failed to load embedding backend '%s' — %s; falling back to keyword",
|
|
114
|
+
self._config.learning_embedding_backend,
|
|
115
|
+
exc,
|
|
116
|
+
)
|
|
117
|
+
return KeywordEmbeddingBackend(max_keywords=self._config.max_keywords)
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Recording
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def record_call(self, *, call_type: str, reasoning: str, session_id: str = "", name: str = "", input: str = "", output: str = "", outcome: str = "unknown", state_hash: str = "", concepts: list[str] | None = None, confidence: float = 1.0, sensitivity: str = "PUBLIC", meta: dict[str, Any] | None = None, call_id: str | None = None) -> str:
|
|
124
|
+
"""Record a single call and return its ID."""
|
|
125
|
+
call = ReasoningCall(
|
|
126
|
+
id=call_id or _make_id("call"),
|
|
127
|
+
call_type=call_type,
|
|
128
|
+
name=name,
|
|
129
|
+
reasoning=reasoning,
|
|
130
|
+
input=input,
|
|
131
|
+
output=output,
|
|
132
|
+
outcome=outcome,
|
|
133
|
+
state_hash=state_hash or self.workspace_hash(),
|
|
134
|
+
concepts=concepts or [],
|
|
135
|
+
confidence=confidence,
|
|
136
|
+
sensitivity=sensitivity,
|
|
137
|
+
session_id=session_id,
|
|
138
|
+
meta=meta or {},
|
|
139
|
+
)
|
|
140
|
+
self._recorder.record(call)
|
|
141
|
+
return call.id
|
|
142
|
+
|
|
143
|
+
def record_and_extract(self, *, call_type: str, reasoning: str, session_id: str = "", outcome: str = "unknown", **kwargs: Any) -> tuple[str, list[str]]:
|
|
144
|
+
"""Record a call and immediately extract learnings from it."""
|
|
145
|
+
call_id = self.record_call(
|
|
146
|
+
call_type=call_type,
|
|
147
|
+
reasoning=reasoning,
|
|
148
|
+
session_id=session_id,
|
|
149
|
+
outcome=outcome,
|
|
150
|
+
**kwargs,
|
|
151
|
+
)
|
|
152
|
+
learning_ids = self.extract_learnings(call_id)
|
|
153
|
+
return call_id, learning_ids
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Extraction
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def extract_learnings(self, call_id: str) -> list[str]:
|
|
160
|
+
"""Extract and store learnings from a recorded call. Returns learning IDs."""
|
|
161
|
+
call = self._recorder.get(call_id)
|
|
162
|
+
if not call:
|
|
163
|
+
return []
|
|
164
|
+
learnings = self._extractor.extract(call)
|
|
165
|
+
ids: list[str] = []
|
|
166
|
+
for learning in learnings:
|
|
167
|
+
self._store.save(learning)
|
|
168
|
+
ids.append(learning.id)
|
|
169
|
+
return ids
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Retrieval
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def get_relevant_learnings(
|
|
176
|
+
self,
|
|
177
|
+
context: str,
|
|
178
|
+
top_n: int | None = None,
|
|
179
|
+
state_hash: str | None = None,
|
|
180
|
+
) -> list[Learning]:
|
|
181
|
+
"""Return the most relevant active learnings for the given context."""
|
|
182
|
+
if not self._config.learning_enabled:
|
|
183
|
+
return []
|
|
184
|
+
top_n = top_n or self._config.learning_top_n
|
|
185
|
+
all_active = self._store.list(validity="active")
|
|
186
|
+
return self._relevance.rank(
|
|
187
|
+
context,
|
|
188
|
+
all_active,
|
|
189
|
+
current_state_hash=state_hash or self.workspace_hash(),
|
|
190
|
+
top_n=top_n,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def cross_project_suggest(self, query: str, top_n: int = 3) -> list[Learning]:
|
|
194
|
+
"""Return relevant learnings from sibling governed projects.
|
|
195
|
+
|
|
196
|
+
Looks up the machine-level registry of governed projects, finds projects
|
|
197
|
+
whose name or tags overlap with the query, loads their active learnings,
|
|
198
|
+
and returns the top-N most relevant ones.
|
|
199
|
+
"""
|
|
200
|
+
if not self._config.learning_enabled:
|
|
201
|
+
return []
|
|
202
|
+
return cross_project.cross_project_suggest(
|
|
203
|
+
query=query,
|
|
204
|
+
current_project_root=self._repo_path,
|
|
205
|
+
top_n=top_n,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Composition
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def compose_user_prompt(self, user_prompt: str, learnings: list[Learning]) -> str:
|
|
213
|
+
"""Append a compact <learnings> block to the user prompt."""
|
|
214
|
+
block = self._composer.compose(learnings)
|
|
215
|
+
if not block:
|
|
216
|
+
return user_prompt
|
|
217
|
+
return f"{user_prompt}\n\n{block}"
|
|
218
|
+
|
|
219
|
+
def compose_system_prompt(self, system_prompt: str, learnings: list[Learning]) -> str:
|
|
220
|
+
"""Append a learning appendix to the system prompt (fallback)."""
|
|
221
|
+
block = self._composer.compose_system_prompt(learnings)
|
|
222
|
+
if not block:
|
|
223
|
+
return system_prompt
|
|
224
|
+
return f"{system_prompt}\n\n{block}"
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
# Feedback
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def apply_feedback(self, learning_id: str, outcome: str) -> Learning | None:
|
|
231
|
+
"""Adjust confidence or validity based on observed outcome."""
|
|
232
|
+
learning = self._store.get(learning_id)
|
|
233
|
+
if not learning:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
if outcome == "success":
|
|
237
|
+
learning.confidence = min(1.0, learning.confidence + 0.1)
|
|
238
|
+
elif outcome == "failure":
|
|
239
|
+
learning.confidence = max(0.0, learning.confidence - 0.2)
|
|
240
|
+
if learning.confidence < 0.3:
|
|
241
|
+
learning.validity = "deprecated"
|
|
242
|
+
elif outcome == "stale":
|
|
243
|
+
learning.validity = "stale"
|
|
244
|
+
|
|
245
|
+
learning.updated_at = _now_iso()
|
|
246
|
+
self._store.save(learning)
|
|
247
|
+
return learning
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Provenance
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def record_injection(
|
|
254
|
+
self,
|
|
255
|
+
learning_ids: list[str],
|
|
256
|
+
*,
|
|
257
|
+
call_id: str,
|
|
258
|
+
session_id: str = "",
|
|
259
|
+
prompt_text: str = "",
|
|
260
|
+
model_name: str = "",
|
|
261
|
+
) -> ProvenanceRecord:
|
|
262
|
+
"""Record that a set of learnings was injected into a call."""
|
|
263
|
+
record = ProvenanceRecord(
|
|
264
|
+
call_id=call_id,
|
|
265
|
+
learning_ids=learning_ids,
|
|
266
|
+
session_id=session_id,
|
|
267
|
+
prompt_hash=_hash_prompt(prompt_text),
|
|
268
|
+
prompt_excerpt=prompt_text[:200],
|
|
269
|
+
model_name=model_name,
|
|
270
|
+
)
|
|
271
|
+
self._provenance.save(record)
|
|
272
|
+
return record
|
|
273
|
+
|
|
274
|
+
def record_injection_outcome(self, call_id: str, outcome: str) -> None:
|
|
275
|
+
"""Update provenance for a call and apply feedback to injected learnings."""
|
|
276
|
+
record = self._provenance.update_outcome(call_id, outcome)
|
|
277
|
+
if not record:
|
|
278
|
+
return
|
|
279
|
+
for learning_id in record.learning_ids:
|
|
280
|
+
try:
|
|
281
|
+
self.apply_feedback(learning_id, outcome)
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
logger.warning("devtorch: feedback failed for learning %s — %s", learning_id, exc)
|
|
284
|
+
|
|
285
|
+
def get_provenance(self, call_id: str) -> ProvenanceRecord | None:
|
|
286
|
+
"""Return the provenance record for a call, if any."""
|
|
287
|
+
return self._provenance.get_by_call(call_id)
|
|
288
|
+
|
|
289
|
+
def list_provenance(self, learning_id: str | None = None) -> list[ProvenanceRecord]:
|
|
290
|
+
"""Return provenance records, optionally filtered by learning."""
|
|
291
|
+
return self._provenance.list(learning_id=learning_id)
|
|
292
|
+
|
|
293
|
+
def invalidate_stale(self, changed_paths: list[str]) -> list[str]:
|
|
294
|
+
"""Mark learnings affected by the given changed paths as stale."""
|
|
295
|
+
from devtorch_core.reasoning_plus.learning.state import affected_by_state_change
|
|
296
|
+
invalidated: list[str] = []
|
|
297
|
+
for learning in self._store.list(validity="active"):
|
|
298
|
+
if affected_by_state_change(self._repo_path, learning.trigger_concepts, changed_paths):
|
|
299
|
+
self._store.invalidate(learning.id, reason="stale")
|
|
300
|
+
invalidated.append(learning.id)
|
|
301
|
+
return invalidated
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
# State
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def workspace_hash(self, file_paths: list[str] | None = None) -> str:
|
|
308
|
+
return workspace_state_hash(self._repo_path, file_paths)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _now_iso() -> str:
|
|
312
|
+
from datetime import datetime, timezone
|
|
313
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.chain
|
|
3
|
+
============================================
|
|
4
|
+
Reasoning chain: trace the full developer-agent handoff path.
|
|
5
|
+
|
|
6
|
+
Traverses: human commit -> agent session -> agent calls -> learnings -> outcomes.
|
|
7
|
+
|
|
8
|
+
The chain is built by linking:
|
|
9
|
+
1. Commit records (with concepts or session references)
|
|
10
|
+
2. Session records (from .GCC/sessions/)
|
|
11
|
+
3. ReasoningCall records (from .GCC/reasoning_learnings/calls/)
|
|
12
|
+
4. Provenance records (from .GCC/reasoning_learnings/provenance/)
|
|
13
|
+
5. Learning records (from .GCC/reasoning_learnings/learnings/)
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_chain(
|
|
26
|
+
gcc_dir: Path | str,
|
|
27
|
+
concept: str | None = None,
|
|
28
|
+
max_chains: int = 5,
|
|
29
|
+
) -> list[dict[str, Any]]:
|
|
30
|
+
"""Build reasoning chains by linking commits to sessions to calls to learnings.
|
|
31
|
+
|
|
32
|
+
Each chain is a list of nodes:
|
|
33
|
+
commit -> session -> [call -> learning -> outcome]*
|
|
34
|
+
|
|
35
|
+
Chains are filtered to those whose commits or sessions mention *concept*,
|
|
36
|
+
or all chains if *concept* is None.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
gcc_dir: Path to .GCC/ directory.
|
|
40
|
+
concept: Optional concept filter.
|
|
41
|
+
max_chains: Maximum number of chains to return.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of chain dicts, each with keys:
|
|
45
|
+
- commit_id: the commit ID
|
|
46
|
+
- commit_message: the commit message
|
|
47
|
+
- commit_concepts: list of concepts
|
|
48
|
+
- timestamp: commit timestamp
|
|
49
|
+
- session: session dict or None
|
|
50
|
+
- calls: list of call dicts (with learning + outcome)
|
|
51
|
+
"""
|
|
52
|
+
chains: list[dict] = []
|
|
53
|
+
commits = _load_commits(gcc_dir)
|
|
54
|
+
|
|
55
|
+
for commit in commits:
|
|
56
|
+
if concept and not _matches_concept(commit, concept):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
chain = {
|
|
60
|
+
"commit_id": commit.get("id", "?"),
|
|
61
|
+
"commit_message": commit.get("message", ""),
|
|
62
|
+
"commit_concepts": commit.get("concepts", []),
|
|
63
|
+
"timestamp": commit.get("timestamp", ""),
|
|
64
|
+
"session": None,
|
|
65
|
+
"calls": [],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
session_id = _extract_session_id(commit)
|
|
69
|
+
if session_id:
|
|
70
|
+
session = _load_session(gcc_dir, session_id)
|
|
71
|
+
if session:
|
|
72
|
+
chain["session"] = {
|
|
73
|
+
"session_id": session.get("session_id"),
|
|
74
|
+
"name": session.get("name"),
|
|
75
|
+
"problem": session.get("problem"),
|
|
76
|
+
"status": session.get("status"),
|
|
77
|
+
"subtasks": session.get("subtasks", []),
|
|
78
|
+
}
|
|
79
|
+
calls = _load_calls_for_session(gcc_dir, session_id)
|
|
80
|
+
for call in calls:
|
|
81
|
+
call_entry = _build_call_entry(gcc_dir, call)
|
|
82
|
+
if call_entry:
|
|
83
|
+
chain["calls"].append(call_entry)
|
|
84
|
+
|
|
85
|
+
chains.append(chain)
|
|
86
|
+
if len(chains) >= max_chains:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
return chains
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load_commits(gcc_dir: Path) -> list[dict]:
|
|
93
|
+
"""Load all commits from .GCC/commits/, sorted newest-first."""
|
|
94
|
+
commits_dir = Path(gcc_dir) / "commits"
|
|
95
|
+
if not commits_dir.exists():
|
|
96
|
+
return []
|
|
97
|
+
commits: list[dict] = []
|
|
98
|
+
for path in sorted(commits_dir.glob("*.json"), reverse=True):
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
commits.append(data)
|
|
102
|
+
except (json.JSONDecodeError, OSError):
|
|
103
|
+
continue
|
|
104
|
+
return commits
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _matches_concept(commit: dict, concept: str) -> bool:
|
|
108
|
+
"""Check if a commit matches a concept (via concepts field or message)."""
|
|
109
|
+
cname = concept.lower()
|
|
110
|
+
for c in commit.get("concepts", []):
|
|
111
|
+
if c.lower() == cname:
|
|
112
|
+
return True
|
|
113
|
+
if cname in commit.get("message", "").lower():
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_session_id(commit: dict) -> str | None:
|
|
119
|
+
"""Extract session_id from commit message or a stored session_id field."""
|
|
120
|
+
msg = commit.get("message", "")
|
|
121
|
+
# Check for [session=xxx] pattern in message
|
|
122
|
+
import re
|
|
123
|
+
m = re.search(r"\[session=([^\]]+)\]", msg)
|
|
124
|
+
if m:
|
|
125
|
+
return m.group(1)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_session(gcc_dir: Path, session_id: str) -> dict | None:
|
|
130
|
+
"""Load a session record from .GCC/sessions/."""
|
|
131
|
+
session_path = Path(gcc_dir) / "sessions" / f"{session_id}.json"
|
|
132
|
+
if not session_path.exists():
|
|
133
|
+
# Try matching by prefix or name
|
|
134
|
+
sessions_dir = Path(gcc_dir) / "sessions"
|
|
135
|
+
if sessions_dir.exists():
|
|
136
|
+
for sp in sessions_dir.glob("*.json"):
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(sp.read_text(encoding="utf-8"))
|
|
139
|
+
if data.get("session_id") == session_id or data.get("name") == session_id:
|
|
140
|
+
return data
|
|
141
|
+
except (json.JSONDecodeError, OSError):
|
|
142
|
+
continue
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
return json.loads(session_path.read_text(encoding="utf-8"))
|
|
146
|
+
except (json.JSONDecodeError, OSError):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_calls_for_session(gcc_dir: Path, session_id: str) -> list[dict]:
|
|
151
|
+
"""Load all ReasoningCall records for a given session."""
|
|
152
|
+
calls_dir = Path(gcc_dir) / "reasoning_learnings" / "calls"
|
|
153
|
+
if not calls_dir.exists():
|
|
154
|
+
return []
|
|
155
|
+
calls: list[dict] = []
|
|
156
|
+
for path in sorted(calls_dir.glob("*.json")):
|
|
157
|
+
try:
|
|
158
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
159
|
+
if data.get("session_id") == session_id:
|
|
160
|
+
calls.append(data)
|
|
161
|
+
except (json.JSONDecodeError, OSError):
|
|
162
|
+
continue
|
|
163
|
+
return calls
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _build_call_entry(gcc_dir: Path, call: dict) -> dict | None:
|
|
167
|
+
"""Build a chain entry for a call, linking to its learnings and outcomes."""
|
|
168
|
+
call_id = call.get("id", "")
|
|
169
|
+
if not call_id:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
entry: dict = {
|
|
173
|
+
"call_id": call_id,
|
|
174
|
+
"call_type": call.get("call_type", ""),
|
|
175
|
+
"name": call.get("name", ""),
|
|
176
|
+
"reasoning": call.get("reasoning", ""),
|
|
177
|
+
"outcome": call.get("outcome", "unknown"),
|
|
178
|
+
"concepts": call.get("concepts", []),
|
|
179
|
+
"learnings": [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Find provenance records for this call
|
|
183
|
+
provenance = _load_provenance_for_call(gcc_dir, call_id)
|
|
184
|
+
if provenance:
|
|
185
|
+
entry["provenance_outcome"] = provenance.get("outcome")
|
|
186
|
+
for lid in provenance.get("learning_ids", []):
|
|
187
|
+
learning = _load_learning(gcc_dir, lid)
|
|
188
|
+
if learning:
|
|
189
|
+
entry["learnings"].append({
|
|
190
|
+
"learning_id": lid,
|
|
191
|
+
"content": learning.get("content", "")[:120],
|
|
192
|
+
"type": learning.get("type", ""),
|
|
193
|
+
"confidence": learning.get("confidence", 0),
|
|
194
|
+
"validity": learning.get("validity", ""),
|
|
195
|
+
"trigger_concepts": learning.get("trigger_concepts", []),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return entry
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _load_provenance_for_call(gcc_dir: Path, call_id: str) -> dict | None:
|
|
202
|
+
"""Find the provenance record for a specific call."""
|
|
203
|
+
prov_dir = Path(gcc_dir) / "reasoning_learnings" / "provenance"
|
|
204
|
+
if not prov_dir.exists():
|
|
205
|
+
return None
|
|
206
|
+
for path in prov_dir.glob("*.json"):
|
|
207
|
+
try:
|
|
208
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
209
|
+
if data.get("call_id") == call_id:
|
|
210
|
+
return data
|
|
211
|
+
except (json.JSONDecodeError, OSError):
|
|
212
|
+
continue
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_learning(gcc_dir: Path, learning_id: str) -> dict | None:
|
|
217
|
+
"""Load a single learning record by ID."""
|
|
218
|
+
path = Path(gcc_dir) / "reasoning_learnings" / "learnings" / f"{learning_id}.json"
|
|
219
|
+
if not path.exists():
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
223
|
+
except (json.JSONDecodeError, OSError):
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def format_chain(chain: dict, indent: str = "") -> str:
|
|
228
|
+
"""Format a single chain as readable text."""
|
|
229
|
+
lines = []
|
|
230
|
+
|
|
231
|
+
# Commit node
|
|
232
|
+
cid = chain.get("commit_id", "?")
|
|
233
|
+
msg = chain.get("commit_message", "")
|
|
234
|
+
concepts = chain.get("commit_concepts", [])
|
|
235
|
+
ts = chain.get("timestamp", "")
|
|
236
|
+
lines.append(f"{indent}Commit {cid} [{ts}]")
|
|
237
|
+
lines.append(f"{indent} Message: {msg[:80]}")
|
|
238
|
+
if concepts:
|
|
239
|
+
lines.append(f"{indent} Concepts: {', '.join(concepts)}")
|
|
240
|
+
|
|
241
|
+
# Session node
|
|
242
|
+
session = chain.get("session")
|
|
243
|
+
if session:
|
|
244
|
+
lines.append(f"{indent} Session: {session.get('name', '?')} ({session.get('status', '?')})")
|
|
245
|
+
lines.append(f"{indent} Problem: {session.get('problem', '')[:80]}")
|
|
246
|
+
subtasks = session.get("subtasks", [])
|
|
247
|
+
if subtasks:
|
|
248
|
+
completed = sum(1 for s in subtasks if s.get("status") == "completed")
|
|
249
|
+
failed = sum(1 for s in subtasks if s.get("status") == "failed")
|
|
250
|
+
total = len(subtasks)
|
|
251
|
+
lines.append(f"{indent} Subtasks: {completed}/{total} completed, {failed} failed")
|
|
252
|
+
|
|
253
|
+
# Call + learning nodes
|
|
254
|
+
calls = chain.get("calls", [])
|
|
255
|
+
if calls:
|
|
256
|
+
lines.append(f"{indent} Agent calls ({len(calls)}):")
|
|
257
|
+
success = sum(1 for c in calls if c.get("outcome") == "success")
|
|
258
|
+
failure = sum(1 for c in calls if c.get("outcome") == "failure")
|
|
259
|
+
lines.append(f"{indent} Outcomes: {success} success, {failure} failure")
|
|
260
|
+
for call in calls[:3]: # Show first 3 calls
|
|
261
|
+
lines.append(f"{indent} [{call.get('call_type','')}] {call.get('name','')}: {call.get('reasoning','')[:60]}")
|
|
262
|
+
lines.append(f"{indent} Outcome: {call.get('outcome', '?')}")
|
|
263
|
+
for lr in call.get("learnings", [])[:2]: # Show first 2 learnings per call
|
|
264
|
+
lines.append(f"{indent} Learning: {lr.get('content','')[:60]}")
|
|
265
|
+
lines.append(f"{indent} [{lr.get('type','')}] conf={lr.get('confidence',0)} concepts={lr.get('trigger_concepts',[])}")
|
|
266
|
+
if len(calls) > 3:
|
|
267
|
+
lines.append(f"{indent} ... and {len(calls) - 3} more calls")
|
|
268
|
+
else:
|
|
269
|
+
lines.append(f"{indent} No agent calls linked to this commit.")
|
|
270
|
+
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def format_chains(chains: list[dict]) -> str:
|
|
275
|
+
"""Format multiple chains for CLI output."""
|
|
276
|
+
if not chains:
|
|
277
|
+
return "No reasoning chains found."
|
|
278
|
+
|
|
279
|
+
lines = ["═══ Reasoning chains ═══", ""]
|
|
280
|
+
for i, chain in enumerate(chains, 1):
|
|
281
|
+
lines.append(f"Chain #{i}")
|
|
282
|
+
lines.append(format_chain(chain, indent=" "))
|
|
283
|
+
lines.append("")
|
|
284
|
+
lines.append("═══ End ═══")
|
|
285
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.composer
|
|
3
|
+
==============================================
|
|
4
|
+
Format selected learnings into a compact prompt block.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from devtorch_core.reasoning_plus.learning.models import Learning
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PromptComposer:
|
|
12
|
+
"""Compose a <learnings> block for injection into a user prompt.
|
|
13
|
+
|
|
14
|
+
By default only `PUBLIC` learnings are injected. Callers can override
|
|
15
|
+
`allowed_sensitivity` to include PROTECTED or PRIVATE learnings, but the
|
|
16
|
+
default prevents accidentally leaking sensitive reasoning into prompts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
max_lines: int = 100,
|
|
22
|
+
allowed_sensitivity: list[str] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.max_lines = max_lines
|
|
25
|
+
self.allowed_sensitivity = set(
|
|
26
|
+
allowed_sensitivity if allowed_sensitivity is not None else ["PUBLIC"]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def _filter(self, learnings: list[Learning]) -> list[Learning]:
|
|
30
|
+
return [
|
|
31
|
+
learning
|
|
32
|
+
for learning in learnings
|
|
33
|
+
if learning.sensitivity in self.allowed_sensitivity
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def compose(self, learnings: list[Learning]) -> str:
|
|
37
|
+
learnings = self._filter(learnings)
|
|
38
|
+
if not learnings:
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
lines: list[str] = [
|
|
42
|
+
"<learnings>",
|
|
43
|
+
"The following are relevant insights from previous steps in this session.",
|
|
44
|
+
"Evaluate them against the current evidence; they may be outdated.",
|
|
45
|
+
"",
|
|
46
|
+
]
|
|
47
|
+
for i, learning in enumerate(learnings, start=1):
|
|
48
|
+
text = learning.short_form(max_chars=240)
|
|
49
|
+
concepts_tag = ""
|
|
50
|
+
if learning.trigger_concepts:
|
|
51
|
+
concepts_tag = f" [concepts: {', '.join(learning.trigger_concepts)}]"
|
|
52
|
+
lines.append(f"{i}. {text}{concepts_tag}")
|
|
53
|
+
|
|
54
|
+
lines.extend(["", "</learnings>"])
|
|
55
|
+
|
|
56
|
+
# Bound total lines
|
|
57
|
+
if len(lines) > self.max_lines:
|
|
58
|
+
lines = lines[: self.max_lines - 1]
|
|
59
|
+
lines.append("...</learnings>")
|
|
60
|
+
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
def compose_system_prompt(self, learnings: list[Learning]) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Alternative: format learnings as a short system-prompt appendix.
|
|
66
|
+
Kept for callers that cannot modify the user prompt.
|
|
67
|
+
"""
|
|
68
|
+
learnings = self._filter(learnings)
|
|
69
|
+
if not learnings:
|
|
70
|
+
return ""
|
|
71
|
+
parts = ["Relevant context from previous reasoning steps:"]
|
|
72
|
+
for i, learning in enumerate(learnings, start=1):
|
|
73
|
+
parts.append(f"{i}. {learning.short_form(max_chars=200)}")
|
|
74
|
+
return "\n".join(parts)
|