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,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.models
|
|
3
|
+
===========================================
|
|
4
|
+
Data models for Reasoning Plus Learning (DRPL).
|
|
5
|
+
|
|
6
|
+
A ReasoningCall is a single recorded step (LLM, tool, memory, or action).
|
|
7
|
+
A Learning is a distilled, reusable insight derived from one or more calls.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
VALID_CALL_TYPES = ("llm", "tool", "memory", "action")
|
|
20
|
+
VALID_OUTCOMES = ("success", "failure", "partial", "unknown")
|
|
21
|
+
VALID_LEARNING_TYPES = ("insight", "correction", "pattern", "avoid", "confirm")
|
|
22
|
+
VALID_VALIDITIES = ("active", "stale", "deprecated")
|
|
23
|
+
VALID_DISCLOSURE_LEVELS = ("PUBLIC", "PROTECTED", "PRIVATE")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_iso() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_id(prefix: str = "drpl") -> str:
|
|
31
|
+
return f"{prefix}_{uuid.uuid4().hex[:16]}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ReasoningCall:
|
|
36
|
+
"""One recorded reasoning step."""
|
|
37
|
+
|
|
38
|
+
reasoning: str
|
|
39
|
+
call_type: str = "llm"
|
|
40
|
+
name: str = ""
|
|
41
|
+
input: str = ""
|
|
42
|
+
output: str = ""
|
|
43
|
+
outcome: str = "unknown"
|
|
44
|
+
state_hash: str = ""
|
|
45
|
+
concepts: list[str] = field(default_factory=list)
|
|
46
|
+
confidence: float = 1.0
|
|
47
|
+
sensitivity: str = "PUBLIC"
|
|
48
|
+
session_id: str = ""
|
|
49
|
+
timestamp: str = field(default_factory=_now_iso)
|
|
50
|
+
id: str = field(default_factory=lambda: _make_id("call"))
|
|
51
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
if self.call_type not in VALID_CALL_TYPES:
|
|
55
|
+
raise ValueError(f"Invalid call_type: {self.call_type}")
|
|
56
|
+
if self.outcome not in VALID_OUTCOMES:
|
|
57
|
+
raise ValueError(f"Invalid outcome: {self.outcome}")
|
|
58
|
+
if self.sensitivity not in VALID_DISCLOSURE_LEVELS:
|
|
59
|
+
raise ValueError(f"Invalid sensitivity: {self.sensitivity}")
|
|
60
|
+
self.confidence = max(0.0, min(1.0, float(self.confidence)))
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return asdict(self)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict[str, Any]) -> "ReasoningCall":
|
|
67
|
+
valid = {f.name for f in fields(cls)}
|
|
68
|
+
return cls(**{k: v for k, v in data.items() if k in valid})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Learning:
|
|
73
|
+
"""A distilled, reusable learning from one or more reasoning calls."""
|
|
74
|
+
|
|
75
|
+
content: str
|
|
76
|
+
type: str = "insight"
|
|
77
|
+
trigger_concepts: list[str] = field(default_factory=list)
|
|
78
|
+
state_hash: str = ""
|
|
79
|
+
validity: str = "active"
|
|
80
|
+
confidence: float = 1.0
|
|
81
|
+
source_reasoning_ids: list[str] = field(default_factory=list)
|
|
82
|
+
sensitivity: str = "PUBLIC"
|
|
83
|
+
created_at: str = field(default_factory=_now_iso)
|
|
84
|
+
updated_at: str = field(default_factory=_now_iso)
|
|
85
|
+
id: str = field(default_factory=lambda: _make_id("learn"))
|
|
86
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
if self.type not in VALID_LEARNING_TYPES:
|
|
90
|
+
raise ValueError(f"Invalid learning type: {self.type}")
|
|
91
|
+
if self.validity not in VALID_VALIDITIES:
|
|
92
|
+
raise ValueError(f"Invalid validity: {self.validity}")
|
|
93
|
+
if self.sensitivity not in VALID_DISCLOSURE_LEVELS:
|
|
94
|
+
raise ValueError(f"Invalid sensitivity: {self.sensitivity}")
|
|
95
|
+
self.confidence = max(0.0, min(1.0, float(self.confidence)))
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
return asdict(self)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, data: dict[str, Any]) -> "Learning":
|
|
102
|
+
valid = {f.name for f in fields(cls)}
|
|
103
|
+
return cls(**{k: v for k, v in data.items() if k in valid})
|
|
104
|
+
|
|
105
|
+
def short_form(self, max_chars: int = 200) -> str:
|
|
106
|
+
"""Compact, prompt-ready representation."""
|
|
107
|
+
text = self.content.strip().replace("\n", " ")
|
|
108
|
+
if len(text) > max_chars:
|
|
109
|
+
text = text[: max_chars - 3].rstrip() + "..."
|
|
110
|
+
return text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def hash_state(*items: str) -> str:
|
|
114
|
+
"""Stable hash for a set of state tokens."""
|
|
115
|
+
joined = "|".join(sorted(items))
|
|
116
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.provenance
|
|
3
|
+
===============================================
|
|
4
|
+
Provenance tracking for injected learnings.
|
|
5
|
+
|
|
6
|
+
Records which Learning records were injected into each LLM/tool call so that
|
|
7
|
+
observed outcomes can be attributed back to the learnings that influenced the
|
|
8
|
+
call.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from devtorch_core.reasoning_plus.learning.models import _make_id, _now_iso
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProvenanceRecord:
|
|
26
|
+
"""Which learnings were injected into a single call, and what the outcome was."""
|
|
27
|
+
|
|
28
|
+
call_id: str
|
|
29
|
+
learning_ids: list[str] = field(default_factory=list)
|
|
30
|
+
session_id: str = ""
|
|
31
|
+
prompt_hash: str = ""
|
|
32
|
+
prompt_excerpt: str = ""
|
|
33
|
+
model_name: str = ""
|
|
34
|
+
outcome: str | None = None
|
|
35
|
+
injected_at: str = field(default_factory=_now_iso)
|
|
36
|
+
updated_at: str = field(default_factory=_now_iso)
|
|
37
|
+
id: str = field(default_factory=lambda: _make_id("prov"))
|
|
38
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict[str, Any]) -> "ProvenanceRecord":
|
|
45
|
+
valid = {f.name for f in fields(cls)}
|
|
46
|
+
return cls(**{k: v for k, v in data.items() if k in valid})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProvenanceStore:
|
|
50
|
+
"""Persist ProvenanceRecord records under .GCC/reasoning_learnings/provenance/."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, gcc_dir: Path | str) -> None:
|
|
53
|
+
self._gcc_dir = Path(gcc_dir)
|
|
54
|
+
self._provenance_dir = self._gcc_dir / "reasoning_learnings" / "provenance"
|
|
55
|
+
|
|
56
|
+
def save(self, record: ProvenanceRecord) -> Path:
|
|
57
|
+
self._provenance_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path = self._provenance_dir / f"{record.id}.json"
|
|
59
|
+
_atomic_json_write(path, record.to_dict())
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
def get(self, record_id: str) -> ProvenanceRecord | None:
|
|
63
|
+
path = self._provenance_dir / f"{record_id}.json"
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
return ProvenanceRecord.from_dict(data)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.warning("devtorch: failed to read provenance %s — %s", record_id, exc)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def get_by_call(self, call_id: str) -> ProvenanceRecord | None:
|
|
74
|
+
for record in self.list():
|
|
75
|
+
if record.call_id == call_id:
|
|
76
|
+
return record
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def list(self, outcome: str | None = None, learning_id: str | None = None) -> list[ProvenanceRecord]:
|
|
80
|
+
out: list[ProvenanceRecord] = []
|
|
81
|
+
if not self._provenance_dir.exists():
|
|
82
|
+
return out
|
|
83
|
+
for path in sorted(self._provenance_dir.glob("*.json")):
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
86
|
+
record = ProvenanceRecord.from_dict(data)
|
|
87
|
+
if outcome and record.outcome != outcome:
|
|
88
|
+
continue
|
|
89
|
+
if learning_id and learning_id not in record.learning_ids:
|
|
90
|
+
continue
|
|
91
|
+
out.append(record)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
logger.debug("devtorch: skipping malformed provenance %s — %s", path, exc)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def update_outcome(self, call_id: str, outcome: str) -> ProvenanceRecord | None:
|
|
97
|
+
record = self.get_by_call(call_id)
|
|
98
|
+
if not record:
|
|
99
|
+
return None
|
|
100
|
+
record.outcome = outcome
|
|
101
|
+
record.updated_at = _now_iso()
|
|
102
|
+
self.save(record)
|
|
103
|
+
return record
|
|
104
|
+
|
|
105
|
+
def count(self) -> int:
|
|
106
|
+
if not self._provenance_dir.exists():
|
|
107
|
+
return 0
|
|
108
|
+
return len(list(self._provenance_dir.glob("*.json")))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _atomic_json_write(path: Path, data: dict[str, Any]) -> None:
|
|
112
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
113
|
+
try:
|
|
114
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
115
|
+
json.dump(data, f, indent=2)
|
|
116
|
+
tmp.replace(path)
|
|
117
|
+
finally:
|
|
118
|
+
if tmp.exists():
|
|
119
|
+
try:
|
|
120
|
+
tmp.unlink()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _hash_prompt(prompt_text: str) -> str:
|
|
126
|
+
return hashlib.sha256(prompt_text.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.recorder
|
|
3
|
+
==============================================
|
|
4
|
+
Record reasoning calls (LLM, tool, memory, action) to .GCC/.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from devtorch_core.reasoning_plus.learning.models import ReasoningCall
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CallRecorder:
|
|
19
|
+
"""Persist ReasoningCall records under .GCC/reasoning_learnings/calls/."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, gcc_dir: Path | str) -> None:
|
|
22
|
+
self._gcc_dir = Path(gcc_dir)
|
|
23
|
+
self._calls_dir = self._gcc_dir / "reasoning_learnings" / "calls"
|
|
24
|
+
|
|
25
|
+
def record(self, call: ReasoningCall) -> Path:
|
|
26
|
+
"""Write a call to disk and return the path."""
|
|
27
|
+
self._calls_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
path = self._calls_dir / f"{call.id}.json"
|
|
29
|
+
try:
|
|
30
|
+
_atomic_json_write(path, call.to_dict())
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
logger.warning("devtorch: failed to record reasoning call — %s", exc)
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
def get(self, call_id: str) -> ReasoningCall | None:
|
|
36
|
+
path = self._calls_dir / f"{call_id}.json"
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
return ReasoningCall.from_dict(data)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
logger.warning("devtorch: failed to read reasoning call %s — %s", call_id, exc)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def list(self, session_id: str | None = None, call_type: str | None = None) -> list[ReasoningCall]:
|
|
47
|
+
"""List recorded calls, optionally filtered by session or type."""
|
|
48
|
+
out: list[ReasoningCall] = []
|
|
49
|
+
if not self._calls_dir.exists():
|
|
50
|
+
return out
|
|
51
|
+
for path in sorted(self._calls_dir.glob("*.json")):
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
call = ReasoningCall.from_dict(data)
|
|
55
|
+
if session_id and call.session_id != session_id:
|
|
56
|
+
continue
|
|
57
|
+
if call_type and call.call_type != call_type:
|
|
58
|
+
continue
|
|
59
|
+
out.append(call)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
logger.debug("devtorch: skipping malformed call record %s — %s", path, exc)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def count(self) -> int:
|
|
65
|
+
if not self._calls_dir.exists():
|
|
66
|
+
return 0
|
|
67
|
+
return len(list(self._calls_dir.glob("*.json")))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _atomic_json_write(path: Path, data: dict[str, Any]) -> None:
|
|
71
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
72
|
+
try:
|
|
73
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
74
|
+
json.dump(data, f, indent=2)
|
|
75
|
+
tmp.replace(path)
|
|
76
|
+
finally:
|
|
77
|
+
if tmp.exists():
|
|
78
|
+
try:
|
|
79
|
+
tmp.unlink()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.relevance
|
|
3
|
+
=================================================
|
|
4
|
+
Rank learnings by relevance to the current context and state.
|
|
5
|
+
|
|
6
|
+
Scoring factors (two-dimensional):
|
|
7
|
+
1. Semantic similarity between context and learning content (embedding)
|
|
8
|
+
2. Concept overlap between context keywords and learning trigger_concepts
|
|
9
|
+
3. Confidence
|
|
10
|
+
4. State freshness (matching state_hash boosts score)
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from devtorch_core.reasoning_plus.context import extract_keywords
|
|
19
|
+
from devtorch_core.reasoning_plus.learning.embeddings import (
|
|
20
|
+
EmbeddingBackend,
|
|
21
|
+
KeywordEmbeddingBackend,
|
|
22
|
+
)
|
|
23
|
+
from devtorch_core.reasoning_plus.learning.models import Learning
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RelevanceEngine:
|
|
27
|
+
"""Score and rank learnings for a given context using an embedding backend."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, backend: EmbeddingBackend | None = None, cache_ttl: float = 0) -> None:
|
|
30
|
+
self._backend = backend or KeywordEmbeddingBackend()
|
|
31
|
+
self._cache_ttl = cache_ttl
|
|
32
|
+
self._cache: dict[str, tuple[list[Learning], float]] = {}
|
|
33
|
+
|
|
34
|
+
def rank(
|
|
35
|
+
self,
|
|
36
|
+
context: str,
|
|
37
|
+
learnings: list[Learning],
|
|
38
|
+
current_state_hash: str | None = None,
|
|
39
|
+
top_n: int = 3,
|
|
40
|
+
) -> list[Learning]:
|
|
41
|
+
"""
|
|
42
|
+
Return the top-N active learnings ranked by relevance.
|
|
43
|
+
|
|
44
|
+
Scoring factors:
|
|
45
|
+
- semantic similarity between context and learning content
|
|
46
|
+
- concept overlap between context keywords and learning trigger_concepts
|
|
47
|
+
- confidence
|
|
48
|
+
- state freshness (matching state_hash boosts score)
|
|
49
|
+
|
|
50
|
+
Results are cached in memory when ``cache_ttl`` > 0 so repeated
|
|
51
|
+
similar prompts in the same session avoid recomputing embeddings.
|
|
52
|
+
"""
|
|
53
|
+
cache_key = self._cache_key(context, learnings, current_state_hash, top_n)
|
|
54
|
+
if self._cache_ttl and cache_key:
|
|
55
|
+
cached = self._cache.get(cache_key)
|
|
56
|
+
if cached and (time.monotonic() - cached[1]) < self._cache_ttl:
|
|
57
|
+
return cached[0]
|
|
58
|
+
|
|
59
|
+
context_embedding = self._backend.encode(context)
|
|
60
|
+
context_keywords = set(extract_keywords(context, max_keywords=30))
|
|
61
|
+
scored: list[tuple[float, Learning]] = []
|
|
62
|
+
|
|
63
|
+
for learning in learnings:
|
|
64
|
+
if learning.validity != "active":
|
|
65
|
+
continue
|
|
66
|
+
score = self._score(
|
|
67
|
+
learning, context_embedding, current_state_hash, context_keywords,
|
|
68
|
+
)
|
|
69
|
+
scored.append((score, learning))
|
|
70
|
+
|
|
71
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
72
|
+
result = [learning for _, learning in scored[:top_n]]
|
|
73
|
+
|
|
74
|
+
if self._cache_ttl and cache_key:
|
|
75
|
+
self._cache[cache_key] = (result, time.monotonic())
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def _cache_key(
|
|
79
|
+
self,
|
|
80
|
+
context: str,
|
|
81
|
+
learnings: list[Learning],
|
|
82
|
+
current_state_hash: str | None,
|
|
83
|
+
top_n: int,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Stable key for the relevance cache."""
|
|
86
|
+
context_hash = hashlib.sha256(context.encode("utf-8")).hexdigest()[:16]
|
|
87
|
+
learning_ids = sorted({l.id for l in learnings})
|
|
88
|
+
learning_hash = hashlib.sha256(
|
|
89
|
+
"|".join(learning_ids).encode("utf-8")
|
|
90
|
+
).hexdigest()[:16]
|
|
91
|
+
return f"{context_hash}:{current_state_hash or ''}:{learning_hash}:{top_n}"
|
|
92
|
+
|
|
93
|
+
def _score(
|
|
94
|
+
self,
|
|
95
|
+
learning: Learning,
|
|
96
|
+
context_embedding: Any,
|
|
97
|
+
current_state_hash: str | None,
|
|
98
|
+
context_keywords: set[str] | None = None,
|
|
99
|
+
) -> float:
|
|
100
|
+
learning_embedding = self._backend.encode(learning.content)
|
|
101
|
+
semantic_score = self._backend.similarity(context_embedding, learning_embedding)
|
|
102
|
+
|
|
103
|
+
confidence_weight = learning.confidence
|
|
104
|
+
|
|
105
|
+
state_bonus = 0.0
|
|
106
|
+
if current_state_hash and learning.state_hash == current_state_hash:
|
|
107
|
+
state_bonus = 0.2
|
|
108
|
+
|
|
109
|
+
# Concept overlap: how much the learning's trigger_concepts overlap
|
|
110
|
+
# with keywords extracted from the current prompt context.
|
|
111
|
+
concept_bonus = 0.0
|
|
112
|
+
if context_keywords and learning.trigger_concepts:
|
|
113
|
+
learning_concepts = {c.lower() for c in learning.trigger_concepts}
|
|
114
|
+
overlap = len(context_keywords & learning_concepts)
|
|
115
|
+
if overlap > 0:
|
|
116
|
+
concept_bonus = min(0.3, overlap * 0.1)
|
|
117
|
+
|
|
118
|
+
return semantic_score + confidence_weight * 0.5 + state_bonus + concept_bonus
|
|
119
|
+
|
|
120
|
+
def invalidate_cache(self) -> None:
|
|
121
|
+
"""Clear the relevance cache."""
|
|
122
|
+
self._cache.clear()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.state
|
|
3
|
+
===========================================
|
|
4
|
+
Workspace state hashing and staleness detection for learnings.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
from devtorch_core.reasoning_plus.learning.models import hash_state
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def workspace_state_hash(repo_path: Path | str, file_paths: Iterable[str] | None = None) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Return a short hash representing the current state of the workspace.
|
|
21
|
+
|
|
22
|
+
Strategy:
|
|
23
|
+
- If inside a git repo, include the current HEAD commit hash.
|
|
24
|
+
- For any provided file_paths, include the current file content hash.
|
|
25
|
+
- If not a git repo and no file paths, fall back to a hash of the directory path.
|
|
26
|
+
"""
|
|
27
|
+
repo_path = Path(repo_path)
|
|
28
|
+
tokens: list[str] = []
|
|
29
|
+
|
|
30
|
+
head = _git_head(repo_path)
|
|
31
|
+
if head:
|
|
32
|
+
tokens.append(f"head:{head}")
|
|
33
|
+
|
|
34
|
+
for fp in file_paths or []:
|
|
35
|
+
content_hash = _file_content_hash(repo_path / fp)
|
|
36
|
+
if content_hash:
|
|
37
|
+
tokens.append(f"{fp}:{content_hash}")
|
|
38
|
+
|
|
39
|
+
if not tokens:
|
|
40
|
+
tokens.append(str(repo_path.resolve()))
|
|
41
|
+
|
|
42
|
+
return hash_state(*tokens)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _git_head(repo_path: Path) -> str | None:
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["git", "rev-parse", "HEAD"],
|
|
49
|
+
cwd=repo_path,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
errors="ignore",
|
|
53
|
+
timeout=5,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode == 0:
|
|
56
|
+
return result.stdout.strip()
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.debug("devtorch: could not read git HEAD — %s", exc)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _file_content_hash(path: Path) -> str | None:
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
return hash_state(path.read_text(encoding="utf-8", errors="ignore"))
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
logger.debug("devtorch: could not hash %s — %s", path, exc)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def affected_by_state_change(repo_path: Path | str, learning_file_paths: Iterable[str], changed_paths: Iterable[str]) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Return True if a learning should be re-evaluated because the workspace changed.
|
|
75
|
+
|
|
76
|
+
A learning is affected when any of the paths it references overlap with a changed path.
|
|
77
|
+
"""
|
|
78
|
+
changed = set(changed_paths)
|
|
79
|
+
for fp in learning_file_paths:
|
|
80
|
+
if fp in changed:
|
|
81
|
+
return True
|
|
82
|
+
# Also invalidate if a parent directory was changed
|
|
83
|
+
for changed_path in changed:
|
|
84
|
+
if changed_path.startswith(fp) or fp.startswith(changed_path):
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
devtorch_core.reasoning_plus.learning.store
|
|
3
|
+
===========================================
|
|
4
|
+
Persistence layer for distilled learnings.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from devtorch_core.reasoning_plus.context import extract_keywords
|
|
14
|
+
from devtorch_core.reasoning_plus.learning.models import Learning
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("devtorch.reasoning_plus.learning")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LearningStore:
|
|
20
|
+
"""Persist Learning records under .GCC/reasoning_learnings/learnings/."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, gcc_dir: Path | str) -> None:
|
|
23
|
+
self._gcc_dir = Path(gcc_dir)
|
|
24
|
+
self._learnings_dir = self._gcc_dir / "reasoning_learnings" / "learnings"
|
|
25
|
+
|
|
26
|
+
def save(self, learning: Learning, deduplicate: bool = True) -> Path:
|
|
27
|
+
self._learnings_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
if deduplicate and learning.validity == "active":
|
|
29
|
+
duplicate = self._find_duplicate(learning)
|
|
30
|
+
if duplicate:
|
|
31
|
+
merged = self._merge(duplicate, learning)
|
|
32
|
+
path = self._learnings_dir / f"{merged.id}.json"
|
|
33
|
+
try:
|
|
34
|
+
_atomic_json_write(path, merged.to_dict())
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
logger.warning("devtorch: failed to save merged learning — %s", exc)
|
|
37
|
+
return path
|
|
38
|
+
path = self._learnings_dir / f"{learning.id}.json"
|
|
39
|
+
try:
|
|
40
|
+
_atomic_json_write(path, learning.to_dict())
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.warning("devtorch: failed to save learning — %s", exc)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
def get(self, learning_id: str) -> Learning | None:
|
|
46
|
+
path = self._learnings_dir / f"{learning_id}.json"
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
return Learning.from_dict(data)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
logger.warning("devtorch: failed to read learning %s — %s", learning_id, exc)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def list(self, validity: str | None = None, concept: str | None = None) -> list[Learning]:
|
|
57
|
+
out: list[Learning] = []
|
|
58
|
+
if not self._learnings_dir.exists():
|
|
59
|
+
return out
|
|
60
|
+
for path in sorted(self._learnings_dir.glob("*.json")):
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
learning = Learning.from_dict(data)
|
|
64
|
+
if validity and learning.validity != validity:
|
|
65
|
+
continue
|
|
66
|
+
if concept and concept.lower() not in {c.lower() for c in learning.trigger_concepts}:
|
|
67
|
+
continue
|
|
68
|
+
out.append(learning)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.debug("devtorch: skipping malformed learning %s — %s", path, exc)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def invalidate(self, learning_id: str, reason: str = "stale") -> Learning | None:
|
|
74
|
+
learning = self.get(learning_id)
|
|
75
|
+
if not learning:
|
|
76
|
+
return None
|
|
77
|
+
learning.validity = "stale" if reason == "stale" else "deprecated"
|
|
78
|
+
learning.updated_at = _now_iso()
|
|
79
|
+
self.save(learning)
|
|
80
|
+
return learning
|
|
81
|
+
|
|
82
|
+
def count(self) -> int:
|
|
83
|
+
if not self._learnings_dir.exists():
|
|
84
|
+
return 0
|
|
85
|
+
return len(list(self._learnings_dir.glob("*.json")))
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Deduplication
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _find_duplicate(self, learning: Learning, threshold: float = 0.8) -> Learning | None:
|
|
92
|
+
"""Return the most similar *other* active learning if it reaches the threshold."""
|
|
93
|
+
best: Learning | None = None
|
|
94
|
+
best_score = threshold
|
|
95
|
+
for existing in self.list(validity="active"):
|
|
96
|
+
if existing.id == learning.id:
|
|
97
|
+
continue
|
|
98
|
+
score = self._similarity(existing, learning)
|
|
99
|
+
if score >= best_score:
|
|
100
|
+
best_score = score
|
|
101
|
+
best = existing
|
|
102
|
+
return best
|
|
103
|
+
|
|
104
|
+
def _similarity(self, a: Learning, b: Learning) -> float:
|
|
105
|
+
"""Combined concept and content similarity in [0, 1].
|
|
106
|
+
|
|
107
|
+
Concept overlap is weighted more heavily because two learnings that share
|
|
108
|
+
the same trigger concepts are likely talking about the same situation.
|
|
109
|
+
"""
|
|
110
|
+
a_concepts = {c.lower() for c in a.trigger_concepts}
|
|
111
|
+
b_concepts = {c.lower() for c in b.trigger_concepts}
|
|
112
|
+
concept_union = len(a_concepts | b_concepts)
|
|
113
|
+
concept_score = len(a_concepts & b_concepts) / concept_union if concept_union else 0.0
|
|
114
|
+
|
|
115
|
+
a_keywords = set(extract_keywords(a.content, max_keywords=15))
|
|
116
|
+
b_keywords = set(extract_keywords(b.content, max_keywords=15))
|
|
117
|
+
content_union = len(a_keywords | b_keywords)
|
|
118
|
+
content_score = len(a_keywords & b_keywords) / content_union if content_union else 0.0
|
|
119
|
+
|
|
120
|
+
return 0.8 * concept_score + 0.2 * content_score
|
|
121
|
+
|
|
122
|
+
def _merge(self, existing: Learning, new: Learning) -> Learning:
|
|
123
|
+
"""Merge *new* into *existing*, keeping the existing stable ID."""
|
|
124
|
+
merged = Learning.from_dict(existing.to_dict())
|
|
125
|
+
merged.content = new.content
|
|
126
|
+
merged.trigger_concepts = sorted(
|
|
127
|
+
set(existing.trigger_concepts) | set(new.trigger_concepts)
|
|
128
|
+
)
|
|
129
|
+
merged.confidence = max(existing.confidence, new.confidence)
|
|
130
|
+
merged.source_reasoning_ids = list(
|
|
131
|
+
set(existing.source_reasoning_ids) | set(new.source_reasoning_ids)
|
|
132
|
+
)
|
|
133
|
+
merged.validity = _more_restrictive_validity(existing.validity, new.validity)
|
|
134
|
+
merged.updated_at = _now_iso()
|
|
135
|
+
return merged
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _atomic_json_write(path: Path, data: dict[str, Any]) -> None:
|
|
139
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
140
|
+
try:
|
|
141
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
142
|
+
json.dump(data, f, indent=2)
|
|
143
|
+
tmp.replace(path)
|
|
144
|
+
finally:
|
|
145
|
+
if tmp.exists():
|
|
146
|
+
try:
|
|
147
|
+
tmp.unlink()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _more_restrictive_validity(a: str, b: str) -> str:
|
|
153
|
+
"""Return the more restrictive validity status."""
|
|
154
|
+
order = {"deprecated": 2, "stale": 1, "active": 0}
|
|
155
|
+
return a if order.get(a, 0) >= order.get(b, 0) else b
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _now_iso() -> str:
|
|
159
|
+
from datetime import datetime, timezone
|
|
160
|
+
return datetime.now(timezone.utc).isoformat()
|