realitydiff 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- realitydiff/__init__.py +8 -0
- realitydiff/__main__.py +4 -0
- realitydiff/api.py +270 -0
- realitydiff/blame.py +85 -0
- realitydiff/claim.py +104 -0
- realitydiff/cli.py +168 -0
- realitydiff/compress.py +94 -0
- realitydiff/config.py +66 -0
- realitydiff/diffing.py +210 -0
- realitydiff/hashing.py +24 -0
- realitydiff/models.py +239 -0
- realitydiff/reasoner.py +440 -0
- realitydiff/schedule.py +217 -0
- realitydiff/service.py +173 -0
- realitydiff/static/app.js +814 -0
- realitydiff/static/favicon.svg +4 -0
- realitydiff/static/index.html +175 -0
- realitydiff/static/styles.css +477 -0
- realitydiff/store.py +296 -0
- realitydiff/watcher.py +42 -0
- realitydiff/xai.py +506 -0
- realitydiff-0.4.0.dist-info/METADATA +115 -0
- realitydiff-0.4.0.dist-info/RECORD +26 -0
- realitydiff-0.4.0.dist-info/WHEEL +4 -0
- realitydiff-0.4.0.dist-info/entry_points.txt +2 -0
- realitydiff-0.4.0.dist-info/licenses/LICENSE +21 -0
realitydiff/__init__.py
ADDED
realitydiff/__main__.py
ADDED
realitydiff/api.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
8
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
9
|
+
from fastapi.staticfiles import StaticFiles
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
12
|
+
|
|
13
|
+
from realitydiff import __version__
|
|
14
|
+
from realitydiff.config import default_db, load_env
|
|
15
|
+
from realitydiff.models import BlameReport, ClaimDiff, ClaimRecord, Commit, WatchResult
|
|
16
|
+
from realitydiff.schedule import install as install_service
|
|
17
|
+
from realitydiff.schedule import status as service_status
|
|
18
|
+
from realitydiff.schedule import uninstall as uninstall_service
|
|
19
|
+
from realitydiff.service import RealityDiff
|
|
20
|
+
from realitydiff.watcher import WatchLoop
|
|
21
|
+
from realitydiff.xai import XAIError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
STATIC = Path(__file__).parent / "static"
|
|
25
|
+
load_env()
|
|
26
|
+
DEFAULT_DB = default_db()
|
|
27
|
+
log = logging.getLogger("realitydiff.api")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CreateClaimBody(BaseModel):
|
|
31
|
+
statement: str = Field(min_length=3, max_length=2000)
|
|
32
|
+
author: str = Field(default="reasoner", max_length=80)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CommitBody(BaseModel):
|
|
36
|
+
message: str = Field(min_length=1, max_length=500)
|
|
37
|
+
author: str = Field(default="user", max_length=80)
|
|
38
|
+
reason: str = Field(default="", max_length=4000)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RevertBody(BaseModel):
|
|
42
|
+
sha: str = Field(min_length=7, max_length=64)
|
|
43
|
+
author: str = Field(default="user", max_length=80)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServiceBody(BaseModel):
|
|
47
|
+
interval_seconds: int = Field(default=300, ge=30, le=86_400)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WatchConfigBody(BaseModel):
|
|
51
|
+
watching: bool = True
|
|
52
|
+
interval_seconds: int | None = Field(default=None, ge=30, le=86_400)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_app(
|
|
56
|
+
engine: RealityDiff | None = None,
|
|
57
|
+
*,
|
|
58
|
+
start_watcher: bool = True,
|
|
59
|
+
) -> FastAPI:
|
|
60
|
+
engine = engine or RealityDiff.open(DEFAULT_DB)
|
|
61
|
+
watcher = WatchLoop(engine)
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def lifespan(_app: FastAPI):
|
|
65
|
+
if start_watcher:
|
|
66
|
+
watcher.start()
|
|
67
|
+
yield
|
|
68
|
+
watcher.stop()
|
|
69
|
+
|
|
70
|
+
app = FastAPI(title="Reality Diff", version=__version__, lifespan=lifespan)
|
|
71
|
+
app.state.engine = engine
|
|
72
|
+
app.state.watcher = watcher
|
|
73
|
+
|
|
74
|
+
@app.exception_handler(StarletteHTTPException)
|
|
75
|
+
async def _http_exc(request: Request, exc: StarletteHTTPException):
|
|
76
|
+
detail = exc.detail
|
|
77
|
+
if not isinstance(detail, str):
|
|
78
|
+
detail = str(detail)
|
|
79
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": detail})
|
|
80
|
+
|
|
81
|
+
@app.get("/api/health")
|
|
82
|
+
def health() -> dict[str, str]:
|
|
83
|
+
try:
|
|
84
|
+
engine.store.list_claims()
|
|
85
|
+
db = "ok"
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
db = f"error:{exc}"
|
|
88
|
+
status = "ok" if db == "ok" else "degraded"
|
|
89
|
+
return {"status": status, "name": "realitydiff", "version": __version__, "db": db}
|
|
90
|
+
|
|
91
|
+
@app.get("/api/claims")
|
|
92
|
+
def list_claims() -> list[dict]:
|
|
93
|
+
out = []
|
|
94
|
+
for record in engine.store.list_claims():
|
|
95
|
+
head = engine.store.head_commit(record.id)
|
|
96
|
+
history = engine.store.log(record.id)
|
|
97
|
+
out.append(
|
|
98
|
+
{
|
|
99
|
+
**record.model_dump(mode="json"),
|
|
100
|
+
"confidence": head.state.confidence if head else None,
|
|
101
|
+
"summary": head.state.summary if head else "",
|
|
102
|
+
"commit_count": len(history),
|
|
103
|
+
"spark": [c.new_confidence for c in reversed(history[-24:])],
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
@app.post("/api/claims")
|
|
109
|
+
def create_claim(body: CreateClaimBody) -> dict:
|
|
110
|
+
try:
|
|
111
|
+
living, commit = engine.open_claim(body.statement, author=body.author)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise HTTPException(400, str(exc)) from exc
|
|
114
|
+
except XAIError as exc:
|
|
115
|
+
raise HTTPException(502, f"Reasoner failed: {exc}") from exc
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
log.exception("open_claim failed")
|
|
118
|
+
raise HTTPException(502, f"Reasoner failed: {exc}") from exc
|
|
119
|
+
return {
|
|
120
|
+
"claim": living.refresh().model_dump(mode="json"),
|
|
121
|
+
"commit": commit.model_dump(mode="json"),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@app.get("/api/claims/{claim_id}")
|
|
125
|
+
def get_claim(claim_id: str) -> dict:
|
|
126
|
+
living = _claim(engine, claim_id)
|
|
127
|
+
head = living.head
|
|
128
|
+
working = living.working
|
|
129
|
+
return {
|
|
130
|
+
"claim": living.refresh().model_dump(mode="json"),
|
|
131
|
+
"head": head.model_dump(mode="json") if head else None,
|
|
132
|
+
"working": working.model_dump(mode="json") if working else None,
|
|
133
|
+
"dirty": bool(head and working and head.state.tree_hash() != working.tree_hash()),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@app.get("/api/claims/{claim_id}/log")
|
|
137
|
+
def claim_log(claim_id: str) -> list[Commit]:
|
|
138
|
+
return _claim(engine, claim_id).log()
|
|
139
|
+
|
|
140
|
+
@app.get("/api/claims/{claim_id}/diff")
|
|
141
|
+
def claim_diff(
|
|
142
|
+
claim_id: str,
|
|
143
|
+
a: str | None = Query(default=None),
|
|
144
|
+
b: str | None = Query(default=None),
|
|
145
|
+
) -> ClaimDiff:
|
|
146
|
+
try:
|
|
147
|
+
return _claim(engine, claim_id).diff(a, b)
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise HTTPException(400, str(exc)) from exc
|
|
150
|
+
|
|
151
|
+
@app.get("/api/claims/{claim_id}/blame")
|
|
152
|
+
def claim_blame(claim_id: str) -> BlameReport:
|
|
153
|
+
return _claim(engine, claim_id).blame()
|
|
154
|
+
|
|
155
|
+
@app.post("/api/claims/{claim_id}/commit")
|
|
156
|
+
def claim_commit(claim_id: str, body: CommitBody) -> Commit:
|
|
157
|
+
try:
|
|
158
|
+
return _claim(engine, claim_id).commit(body.message, author=body.author, reason=body.reason)
|
|
159
|
+
except RuntimeError as exc:
|
|
160
|
+
raise HTTPException(409, str(exc)) from exc
|
|
161
|
+
|
|
162
|
+
@app.post("/api/claims/{claim_id}/revert")
|
|
163
|
+
def claim_revert(claim_id: str, body: RevertBody) -> Commit:
|
|
164
|
+
try:
|
|
165
|
+
return _claim(engine, claim_id).revert(body.sha, author=body.author)
|
|
166
|
+
except KeyError as exc:
|
|
167
|
+
raise HTTPException(404, str(exc)) from exc
|
|
168
|
+
except (ValueError, RuntimeError) as exc:
|
|
169
|
+
raise HTTPException(400, str(exc)) from exc
|
|
170
|
+
|
|
171
|
+
@app.get("/api/claims/{claim_id}/at/{sha}")
|
|
172
|
+
def claim_at(claim_id: str, sha: str) -> dict:
|
|
173
|
+
living = _claim(engine, claim_id)
|
|
174
|
+
try:
|
|
175
|
+
commit = living._resolve(sha)
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
raise HTTPException(400, str(exc)) from exc
|
|
178
|
+
if commit is None:
|
|
179
|
+
raise HTTPException(404, f"Unknown commit {sha}")
|
|
180
|
+
return {
|
|
181
|
+
"claim": living.refresh().model_dump(mode="json"),
|
|
182
|
+
"commit": commit.model_dump(mode="json"),
|
|
183
|
+
"parent_id": commit.parent_id,
|
|
184
|
+
"is_head": living.head is not None and living.head.id == commit.id,
|
|
185
|
+
"browse": True,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@app.get("/api/claims/{claim_id}/tree")
|
|
189
|
+
def claim_tree(claim_id: str) -> dict:
|
|
190
|
+
living = _claim(engine, claim_id)
|
|
191
|
+
history = living.log()
|
|
192
|
+
nodes = []
|
|
193
|
+
for commit in reversed(history):
|
|
194
|
+
nodes.append(
|
|
195
|
+
{
|
|
196
|
+
"id": commit.id,
|
|
197
|
+
"parent_id": commit.parent_id,
|
|
198
|
+
"message": commit.message,
|
|
199
|
+
"author": commit.author,
|
|
200
|
+
"created_at": commit.created_at.isoformat(),
|
|
201
|
+
"confidence": commit.new_confidence,
|
|
202
|
+
"previous_confidence": commit.previous_confidence,
|
|
203
|
+
"reason": commit.reason,
|
|
204
|
+
"is_head": living.head is not None and living.head.id == commit.id,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
return {"claim_id": claim_id, "head": living.head.id if living.head else None, "nodes": nodes}
|
|
208
|
+
|
|
209
|
+
@app.post("/api/claims/{claim_id}/watch")
|
|
210
|
+
def claim_watch(claim_id: str) -> WatchResult:
|
|
211
|
+
_claim(engine, claim_id)
|
|
212
|
+
try:
|
|
213
|
+
return engine.watch(claim_id, author="watcher")
|
|
214
|
+
except XAIError as exc:
|
|
215
|
+
raise HTTPException(502, f"Watch failed: {exc}") from exc
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
log.exception("watch failed")
|
|
218
|
+
raise HTTPException(502, f"Watch failed: {exc}") from exc
|
|
219
|
+
|
|
220
|
+
@app.post("/api/claims/{claim_id}/watching")
|
|
221
|
+
def set_watching(claim_id: str, body: WatchConfigBody) -> ClaimRecord:
|
|
222
|
+
_claim(engine, claim_id)
|
|
223
|
+
try:
|
|
224
|
+
engine.store.set_watching(claim_id, body.watching, body.interval_seconds)
|
|
225
|
+
except RuntimeError as exc:
|
|
226
|
+
raise HTTPException(409, str(exc)) from exc
|
|
227
|
+
record = engine.store.get_claim(claim_id)
|
|
228
|
+
assert record is not None
|
|
229
|
+
return record
|
|
230
|
+
|
|
231
|
+
@app.get("/api/service")
|
|
232
|
+
def get_service() -> dict:
|
|
233
|
+
return {**service_status(), "db": str(Path(engine.store.path).expanduser().resolve())}
|
|
234
|
+
|
|
235
|
+
@app.post("/api/service")
|
|
236
|
+
def put_service(body: ServiceBody) -> dict:
|
|
237
|
+
try:
|
|
238
|
+
return install_service(body.interval_seconds, db=DEFAULT_DB)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
raise HTTPException(500, f"Could not install watch service: {exc}") from exc
|
|
241
|
+
|
|
242
|
+
@app.delete("/api/service")
|
|
243
|
+
def drop_service() -> dict:
|
|
244
|
+
try:
|
|
245
|
+
return uninstall_service()
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
raise HTTPException(500, f"Could not remove watch service: {exc}") from exc
|
|
248
|
+
|
|
249
|
+
if STATIC.exists():
|
|
250
|
+
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
|
251
|
+
|
|
252
|
+
@app.get("/")
|
|
253
|
+
def index() -> FileResponse:
|
|
254
|
+
return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-store"})
|
|
255
|
+
|
|
256
|
+
@app.get("/favicon.svg")
|
|
257
|
+
def favicon() -> FileResponse:
|
|
258
|
+
path = STATIC / "favicon.svg"
|
|
259
|
+
if not path.exists():
|
|
260
|
+
raise HTTPException(404, "no favicon")
|
|
261
|
+
return FileResponse(path, media_type="image/svg+xml")
|
|
262
|
+
|
|
263
|
+
return app
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _claim(engine: RealityDiff, claim_id: str):
|
|
267
|
+
try:
|
|
268
|
+
return engine.claim(claim_id)
|
|
269
|
+
except KeyError as exc:
|
|
270
|
+
raise HTTPException(404, f"Unknown claim {claim_id}") from exc
|
realitydiff/blame.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from realitydiff.models import BlameEntry, BlameReport, ClaimState, Commit
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def blame_history(claim_id: str, history: list[Commit]) -> BlameReport:
|
|
7
|
+
"""history is newest-first (git log order)."""
|
|
8
|
+
if not history:
|
|
9
|
+
return BlameReport(claim_id=claim_id, head=None, entries=[])
|
|
10
|
+
|
|
11
|
+
chronological = list(reversed(history))
|
|
12
|
+
head = history[0].id
|
|
13
|
+
first_seen: dict[str, tuple[Commit, object]] = {}
|
|
14
|
+
last_seen: dict[str, tuple[Commit, object]] = {}
|
|
15
|
+
|
|
16
|
+
def note(path: str, commit: Commit, value: object) -> None:
|
|
17
|
+
if path not in first_seen:
|
|
18
|
+
first_seen[path] = (commit, value)
|
|
19
|
+
last_seen[path] = (commit, value)
|
|
20
|
+
|
|
21
|
+
previous: ClaimState | None = None
|
|
22
|
+
for commit in chronological:
|
|
23
|
+
state = commit.state
|
|
24
|
+
if previous is None or previous.statement != state.statement:
|
|
25
|
+
note("statement", commit, state.statement)
|
|
26
|
+
if previous is None or previous.refined_statement != state.refined_statement:
|
|
27
|
+
note("refined_statement", commit, state.refined_statement)
|
|
28
|
+
if previous is None or previous.confidence != state.confidence:
|
|
29
|
+
note("confidence", commit, state.confidence)
|
|
30
|
+
if previous is None or previous.summary != state.summary:
|
|
31
|
+
note("summary", commit, state.summary)
|
|
32
|
+
if previous is None or (
|
|
33
|
+
(previous.outcome is None) != (state.outcome is None)
|
|
34
|
+
or (
|
|
35
|
+
previous.outcome
|
|
36
|
+
and state.outcome
|
|
37
|
+
and previous.outcome.model_dump(mode="json") != state.outcome.model_dump(mode="json")
|
|
38
|
+
)
|
|
39
|
+
):
|
|
40
|
+
note("outcome", commit, state.outcome.model_dump(mode="json") if state.outcome else None)
|
|
41
|
+
|
|
42
|
+
prev_for = {item.id: item for item in previous.evidence_for} if previous else {}
|
|
43
|
+
prev_against = {item.id: item for item in previous.evidence_against} if previous else {}
|
|
44
|
+
prev_unknowns = {item.id: item for item in previous.unknowns} if previous else {}
|
|
45
|
+
prev_preds = {item.id: item for item in previous.predictions} if previous else {}
|
|
46
|
+
|
|
47
|
+
for item in state.evidence_for:
|
|
48
|
+
dumped = item.model_dump(mode="json")
|
|
49
|
+
if prev_for.get(item.id) is None or prev_for[item.id].model_dump(mode="json") != dumped:
|
|
50
|
+
note(f"evidence_for/{item.id}", commit, dumped)
|
|
51
|
+
for item in state.evidence_against:
|
|
52
|
+
dumped = item.model_dump(mode="json")
|
|
53
|
+
if prev_against.get(item.id) is None or prev_against[item.id].model_dump(mode="json") != dumped:
|
|
54
|
+
note(f"evidence_against/{item.id}", commit, dumped)
|
|
55
|
+
for item in state.unknowns:
|
|
56
|
+
dumped = item.model_dump(mode="json")
|
|
57
|
+
if prev_unknowns.get(item.id) is None or prev_unknowns[item.id].model_dump(mode="json") != dumped:
|
|
58
|
+
note(f"unknowns/{item.id}", commit, dumped)
|
|
59
|
+
for item in state.predictions:
|
|
60
|
+
dumped = item.model_dump(mode="json")
|
|
61
|
+
if prev_preds.get(item.id) is None or prev_preds[item.id].model_dump(mode="json") != dumped:
|
|
62
|
+
note(f"predictions/{item.id}", commit, dumped)
|
|
63
|
+
previous = state
|
|
64
|
+
|
|
65
|
+
entries: list[BlameEntry] = []
|
|
66
|
+
for path, (introduced, value) in first_seen.items():
|
|
67
|
+
last_commit, last_value = last_seen[path]
|
|
68
|
+
item_id = path.split("/", 1)[1] if "/" in path else None
|
|
69
|
+
entries.append(
|
|
70
|
+
BlameEntry(
|
|
71
|
+
path=path,
|
|
72
|
+
item_id=item_id,
|
|
73
|
+
introduced_in=introduced.id,
|
|
74
|
+
introduced_at=introduced.created_at,
|
|
75
|
+
author=introduced.author,
|
|
76
|
+
message=introduced.message,
|
|
77
|
+
last_changed_in=last_commit.id,
|
|
78
|
+
last_changed_at=last_commit.created_at,
|
|
79
|
+
last_author=last_commit.author,
|
|
80
|
+
last_message=last_commit.message,
|
|
81
|
+
value=last_value if last_value is not None else value,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
entries.sort(key=lambda entry: entry.path)
|
|
85
|
+
return BlameReport(claim_id=claim_id, head=head, entries=entries)
|
realitydiff/claim.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from realitydiff.blame import blame_history
|
|
4
|
+
from realitydiff.diffing import diff_states
|
|
5
|
+
from realitydiff.models import BlameReport, ClaimDiff, ClaimRecord, ClaimState, Commit
|
|
6
|
+
from realitydiff.store import BeliefStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Claim:
|
|
10
|
+
"""Living claim with git-like operations: commit, diff, blame, revert."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, store: BeliefStore, record: ClaimRecord) -> None:
|
|
13
|
+
self._store = store
|
|
14
|
+
self.id = record.id
|
|
15
|
+
self.record = record
|
|
16
|
+
|
|
17
|
+
def refresh(self) -> ClaimRecord:
|
|
18
|
+
record = self._store.get_claim(self.id)
|
|
19
|
+
if record is None:
|
|
20
|
+
raise KeyError(self.id)
|
|
21
|
+
self.record = record
|
|
22
|
+
return record
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def head(self) -> Commit | None:
|
|
26
|
+
return self._store.head_commit(self.id)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def working(self) -> ClaimState | None:
|
|
30
|
+
return self._store.working_state(self.id)
|
|
31
|
+
|
|
32
|
+
def checkout(self) -> ClaimState:
|
|
33
|
+
state = self.working or (self.head.state if self.head else None)
|
|
34
|
+
if state is None:
|
|
35
|
+
raise RuntimeError("Claim has no state yet")
|
|
36
|
+
return state
|
|
37
|
+
|
|
38
|
+
def commit(self, message: str, *, author: str = "user", reason: str = "") -> Commit:
|
|
39
|
+
working = self._store.working_state(self.id)
|
|
40
|
+
head = self._store.head_commit(self.id)
|
|
41
|
+
if working is None:
|
|
42
|
+
if head is None:
|
|
43
|
+
raise RuntimeError("Nothing to commit")
|
|
44
|
+
working = head.state
|
|
45
|
+
if head and working.tree_hash() == head.state.tree_hash():
|
|
46
|
+
raise RuntimeError("Working tree clean")
|
|
47
|
+
commit = Commit.create(
|
|
48
|
+
claim_id=self.id,
|
|
49
|
+
parent_id=head.id if head else None,
|
|
50
|
+
state=working,
|
|
51
|
+
author=author,
|
|
52
|
+
message=message,
|
|
53
|
+
reason=reason,
|
|
54
|
+
previous_confidence=head.state.confidence if head else None,
|
|
55
|
+
)
|
|
56
|
+
return self._store.append_commit(commit)
|
|
57
|
+
|
|
58
|
+
def diff(self, a: str | None = None, b: str | None = None) -> ClaimDiff:
|
|
59
|
+
if a is None and b is None:
|
|
60
|
+
head = self.head
|
|
61
|
+
working = self.working
|
|
62
|
+
if head is None:
|
|
63
|
+
return diff_states(None, working, to_id="WORKING")
|
|
64
|
+
return diff_states(head.state, working, from_id=head.id, to_id="WORKING")
|
|
65
|
+
left = self._resolve(a) if a else None
|
|
66
|
+
right = self._resolve(b) if b else self.head
|
|
67
|
+
return diff_states(
|
|
68
|
+
left.state if left else None,
|
|
69
|
+
right.state if right else None,
|
|
70
|
+
from_id=left.id if left else None,
|
|
71
|
+
to_id=right.id if right else None,
|
|
72
|
+
reason=(right.reason if right else ""),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def log(self) -> list[Commit]:
|
|
76
|
+
return self._store.log(self.id)
|
|
77
|
+
|
|
78
|
+
def blame(self) -> BlameReport:
|
|
79
|
+
return blame_history(self.id, self.log())
|
|
80
|
+
|
|
81
|
+
def revert(self, sha: str, *, author: str = "user") -> Commit:
|
|
82
|
+
target = self._store.get_commit(sha, claim_id=self.id)
|
|
83
|
+
if target is None:
|
|
84
|
+
raise KeyError(f"Unknown commit {sha}")
|
|
85
|
+
head = self.head
|
|
86
|
+
if head is None:
|
|
87
|
+
raise RuntimeError("Nothing to revert")
|
|
88
|
+
if target.state.tree_hash() == head.state.tree_hash():
|
|
89
|
+
raise RuntimeError("HEAD already matches that commit")
|
|
90
|
+
commit = Commit.create(
|
|
91
|
+
claim_id=self.id,
|
|
92
|
+
parent_id=head.id,
|
|
93
|
+
state=target.state,
|
|
94
|
+
author=author,
|
|
95
|
+
message=f"Revert to {sha[:12]}",
|
|
96
|
+
reason=f"Restored belief snapshot {sha[:12]}. Previous confidence {head.state.confidence}% → {target.state.confidence}%.",
|
|
97
|
+
previous_confidence=head.state.confidence,
|
|
98
|
+
)
|
|
99
|
+
return self._store.append_commit(commit)
|
|
100
|
+
|
|
101
|
+
def _resolve(self, ref: str) -> Commit | None:
|
|
102
|
+
if ref.upper() in {"HEAD", "WORKING"}:
|
|
103
|
+
return self.head
|
|
104
|
+
return self._store.get_commit(ref, claim_id=self.id)
|
realitydiff/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from realitydiff import __version__
|
|
9
|
+
from realitydiff.config import config_file, default_db, load_env
|
|
10
|
+
from realitydiff.service import RealityDiff
|
|
11
|
+
from realitydiff.xai import XAIError
|
|
12
|
+
|
|
13
|
+
load_env()
|
|
14
|
+
DEFAULT_DB = default_db()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _db_path() -> str:
|
|
18
|
+
return str(Path(DEFAULT_DB).expanduser().resolve())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _engine() -> RealityDiff:
|
|
22
|
+
return RealityDiff.open(DEFAULT_DB)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _print(data) -> None:
|
|
26
|
+
if hasattr(data, "model_dump"):
|
|
27
|
+
print(json.dumps(data.model_dump(mode="json"), indent=2, default=str))
|
|
28
|
+
else:
|
|
29
|
+
print(json.dumps(data, indent=2, default=str))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="realitydiff", description="Git for beliefs")
|
|
34
|
+
parser.add_argument("--version", action="version", version=f"realitydiff {__version__}")
|
|
35
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
36
|
+
|
|
37
|
+
open_p = sub.add_parser("open", help="Create a living claim and survey the internet")
|
|
38
|
+
open_p.add_argument("statement")
|
|
39
|
+
|
|
40
|
+
watch_p = sub.add_parser("watch", help="Search for new evidence and maybe commit")
|
|
41
|
+
watch_p.add_argument("claim_id")
|
|
42
|
+
|
|
43
|
+
sub.add_parser("watch-all", help="Watch every due claim once (used by the installed service)")
|
|
44
|
+
|
|
45
|
+
log_p = sub.add_parser("log", help="Commit history")
|
|
46
|
+
log_p.add_argument("claim_id")
|
|
47
|
+
|
|
48
|
+
show_p = sub.add_parser("show", help="HEAD state")
|
|
49
|
+
show_p.add_argument("claim_id")
|
|
50
|
+
|
|
51
|
+
diff_p = sub.add_parser("diff", help="Diff two commits (default HEAD vs WORKING)")
|
|
52
|
+
diff_p.add_argument("claim_id")
|
|
53
|
+
diff_p.add_argument("a", nargs="?")
|
|
54
|
+
diff_p.add_argument("b", nargs="?")
|
|
55
|
+
|
|
56
|
+
blame_p = sub.add_parser("blame", help="Who introduced each piece of the belief")
|
|
57
|
+
blame_p.add_argument("claim_id")
|
|
58
|
+
|
|
59
|
+
revert_p = sub.add_parser("revert", help="Restore a prior belief snapshot as a new commit")
|
|
60
|
+
revert_p.add_argument("claim_id")
|
|
61
|
+
revert_p.add_argument("sha")
|
|
62
|
+
|
|
63
|
+
commit_p = sub.add_parser("commit", help="Commit the working tree")
|
|
64
|
+
commit_p.add_argument("claim_id")
|
|
65
|
+
commit_p.add_argument("-m", "--message", required=True)
|
|
66
|
+
|
|
67
|
+
ls_p = sub.add_parser("ls", help="List claims")
|
|
68
|
+
|
|
69
|
+
serve_p = sub.add_parser("serve", help="Run the web UI")
|
|
70
|
+
serve_p.add_argument("--host", default="0.0.0.0")
|
|
71
|
+
serve_p.add_argument("--port", type=int, default=8000)
|
|
72
|
+
|
|
73
|
+
svc = sub.add_parser("service", help="Install a background timer that runs claim.watch()")
|
|
74
|
+
svc_sub = svc.add_subparsers(dest="service_cmd", required=True)
|
|
75
|
+
inst = svc_sub.add_parser("install", help="Install a LaunchAgent / systemd user timer / Windows task")
|
|
76
|
+
inst.add_argument("--interval", type=int, default=300, help="Seconds between watches (min 30)")
|
|
77
|
+
svc_sub.add_parser("uninstall", help="Remove the background watch service")
|
|
78
|
+
svc_sub.add_parser("status", help="Show whether the service is installed")
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
if args.cmd == "serve":
|
|
82
|
+
import uvicorn
|
|
83
|
+
|
|
84
|
+
print(f"realitydiff: database {_db_path()}", file=sys.stderr)
|
|
85
|
+
uvicorn.run("realitydiff.api:create_app", host=args.host, port=args.port, factory=True, reload=False)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
if args.cmd == "service":
|
|
89
|
+
from realitydiff.schedule import install, status, uninstall
|
|
90
|
+
|
|
91
|
+
if args.service_cmd == "install":
|
|
92
|
+
_print(install(args.interval, db=DEFAULT_DB))
|
|
93
|
+
return 0
|
|
94
|
+
if args.service_cmd == "uninstall":
|
|
95
|
+
_print(uninstall())
|
|
96
|
+
return 0
|
|
97
|
+
_print({**status(), "db": _db_path()})
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
engine = _engine()
|
|
102
|
+
except XAIError as exc:
|
|
103
|
+
print(f"realitydiff: {exc}. Export it or save it in {config_file()}", file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
if args.cmd == "watch-all":
|
|
106
|
+
_print([item.model_dump(mode="json") for item in engine.watch_due()])
|
|
107
|
+
return 0
|
|
108
|
+
if args.cmd == "open":
|
|
109
|
+
living, commit = engine.open_claim(args.statement)
|
|
110
|
+
record = living.refresh()
|
|
111
|
+
_print(
|
|
112
|
+
{
|
|
113
|
+
"claim_id": living.id,
|
|
114
|
+
"commit": commit.id,
|
|
115
|
+
"confidence": commit.new_confidence,
|
|
116
|
+
"reason": commit.reason,
|
|
117
|
+
"closed": record.closed,
|
|
118
|
+
"outcome_verdict": record.outcome_verdict,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return 0
|
|
122
|
+
if args.cmd == "ls":
|
|
123
|
+
_print([record.model_dump(mode="json") for record in engine.store.list_claims()])
|
|
124
|
+
return 0
|
|
125
|
+
if args.cmd == "watch":
|
|
126
|
+
_print(engine.watch(args.claim_id))
|
|
127
|
+
return 0
|
|
128
|
+
living = engine.claim(args.claim_id)
|
|
129
|
+
if args.cmd == "log":
|
|
130
|
+
_print(
|
|
131
|
+
[
|
|
132
|
+
{
|
|
133
|
+
"id": c.id,
|
|
134
|
+
"parent": c.parent_id,
|
|
135
|
+
"confidence": c.new_confidence,
|
|
136
|
+
"message": c.message,
|
|
137
|
+
"reason": c.reason,
|
|
138
|
+
"author": c.author,
|
|
139
|
+
"at": c.created_at.isoformat(),
|
|
140
|
+
}
|
|
141
|
+
for c in living.log()
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
return 0
|
|
145
|
+
if args.cmd == "show":
|
|
146
|
+
head = living.head
|
|
147
|
+
if head is None:
|
|
148
|
+
print("no HEAD", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
_print(head)
|
|
151
|
+
return 0
|
|
152
|
+
if args.cmd == "diff":
|
|
153
|
+
_print(living.diff(args.a, args.b))
|
|
154
|
+
return 0
|
|
155
|
+
if args.cmd == "blame":
|
|
156
|
+
_print(living.blame())
|
|
157
|
+
return 0
|
|
158
|
+
if args.cmd == "revert":
|
|
159
|
+
_print(living.revert(args.sha))
|
|
160
|
+
return 0
|
|
161
|
+
if args.cmd == "commit":
|
|
162
|
+
_print(living.commit(args.message))
|
|
163
|
+
return 0
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
raise SystemExit(main())
|