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/reasoner.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from realitydiff.compress import compact_belief, is_material_change
|
|
7
|
+
from realitydiff.models import ClaimOutcome, ClaimState, Evidence, Prediction, Unknown, utcnow
|
|
8
|
+
from realitydiff.xai import LanguageModel, parse_json_object, XAIError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = """You are Reality Diff, a belief-versioning engine. You do not chat.
|
|
12
|
+
You turn claims into living, evidence-weighted belief objects and update them from the live internet.
|
|
13
|
+
|
|
14
|
+
Search the live web and X. Prefer primary sources: company docs, SEC filings, official blogs,
|
|
15
|
+
reputable reporting, patents, job posts.
|
|
16
|
+
|
|
17
|
+
You have a close_claim tool. Call it only when the claim's real-world outcome is now known
|
|
18
|
+
from primary evidence (official confirmation, shipping product, recorded result, court ruling).
|
|
19
|
+
Do not close on rumors, high confidence, or expired due dates without an actual outcome.
|
|
20
|
+
After close_claim, still return the JSON belief object with changed=true, final confidence,
|
|
21
|
+
and evidence for the outcome.
|
|
22
|
+
|
|
23
|
+
Citations are mandatory. Every evidence item MUST include a https source_url from search.
|
|
24
|
+
If you cannot cite a fact, put it in unknowns instead of evidence.
|
|
25
|
+
Return ONLY a JSON object. No markdown outside JSON."""
|
|
26
|
+
|
|
27
|
+
INIT_USER = """CLAIM:
|
|
28
|
+
{claim}
|
|
29
|
+
|
|
30
|
+
Return JSON with this exact shape:
|
|
31
|
+
{{
|
|
32
|
+
"statement": "canonical claim, precise and testable",
|
|
33
|
+
"refined_statement": "more precise operational version of the claim",
|
|
34
|
+
"confidence": 0-100 number (not a string),
|
|
35
|
+
"reason": "why this confidence, citing the strongest evidence with URLs",
|
|
36
|
+
"summary": "one paragraph current belief",
|
|
37
|
+
"evidence_for": [
|
|
38
|
+
{{
|
|
39
|
+
"statement": "what the evidence actually shows",
|
|
40
|
+
"source_url": "https://...",
|
|
41
|
+
"source_title": "title",
|
|
42
|
+
"weight": 0.0-1.0,
|
|
43
|
+
"notes": "how this supports the claim and caveats"
|
|
44
|
+
}}
|
|
45
|
+
],
|
|
46
|
+
"evidence_against": [ same shape ],
|
|
47
|
+
"unknowns": [
|
|
48
|
+
{{"question": "...", "why_it_matters": "..."}}
|
|
49
|
+
],
|
|
50
|
+
"predictions": [
|
|
51
|
+
{{
|
|
52
|
+
"statement": "a falsifiable near-term prediction implied by the claim",
|
|
53
|
+
"due": "YYYY-MM or null",
|
|
54
|
+
"status": "open",
|
|
55
|
+
"how_to_falsify": "what would prove this wrong"
|
|
56
|
+
}}
|
|
57
|
+
]
|
|
58
|
+
}}
|
|
59
|
+
|
|
60
|
+
Calibration:
|
|
61
|
+
- 50 = truly mixed / unknown.
|
|
62
|
+
- Do not exceed 85 without an official confirmation of the exact claim.
|
|
63
|
+
- Rumors and leaks cap around 65 unless independently corroborated.
|
|
64
|
+
- Official denials or contradictory shipping products should drop confidence.
|
|
65
|
+
- Separate "LLM features inside Siri" from "replace Siri's architecture".
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
WATCH_USER = """Update this living claim from NEW internet evidence.
|
|
70
|
+
Do not rewrite history. Preserve existing evidence ids when the same source/fact remains.
|
|
71
|
+
Do not invent a change. If nothing material moved, set changed=false.
|
|
72
|
+
|
|
73
|
+
CLAIM:
|
|
74
|
+
{claim}
|
|
75
|
+
|
|
76
|
+
CURRENT STATE (compressed):
|
|
77
|
+
{state}
|
|
78
|
+
|
|
79
|
+
Previously known source URLs:
|
|
80
|
+
{urls}
|
|
81
|
+
|
|
82
|
+
Search the live web and X for anything new: docs, code, job posts, earnings, leaks,
|
|
83
|
+
official statements, shipping products, regulatory filings.
|
|
84
|
+
|
|
85
|
+
Return ONLY JSON:
|
|
86
|
+
{{
|
|
87
|
+
"changed": true or false,
|
|
88
|
+
"reason": "if changed, one paragraph: what new evidence appeared and how it moves confidence, with URLs",
|
|
89
|
+
"statement": "canonical claim",
|
|
90
|
+
"refined_statement": "...",
|
|
91
|
+
"confidence": 0-100,
|
|
92
|
+
"summary": "...",
|
|
93
|
+
"evidence_for": [
|
|
94
|
+
{{
|
|
95
|
+
"id": "keep existing id if same fact, else omit",
|
|
96
|
+
"statement": "...",
|
|
97
|
+
"source_url": "https://...",
|
|
98
|
+
"source_title": "...",
|
|
99
|
+
"weight": 0.0-1.0,
|
|
100
|
+
"notes": "..."
|
|
101
|
+
}}
|
|
102
|
+
],
|
|
103
|
+
"evidence_against": [ same ],
|
|
104
|
+
"unknowns": [
|
|
105
|
+
{{"id": "keep if same question", "question": "...", "why_it_matters": "..."}}
|
|
106
|
+
],
|
|
107
|
+
"predictions": [
|
|
108
|
+
{{
|
|
109
|
+
"id": "keep if same prediction",
|
|
110
|
+
"statement": "...",
|
|
111
|
+
"due": "YYYY-MM or null",
|
|
112
|
+
"status": "open|confirmed|falsified",
|
|
113
|
+
"how_to_falsify": "..."
|
|
114
|
+
}}
|
|
115
|
+
],
|
|
116
|
+
"new_source_urls": ["..."]
|
|
117
|
+
}}
|
|
118
|
+
|
|
119
|
+
Rules:
|
|
120
|
+
- changed=false if you found only duplicates of existing evidence, or only reworded the same facts.
|
|
121
|
+
- Ignore retrieved timestamps and stylistic rewrites. A commit needs new URLs, status changes, or ≥1 point of confidence.
|
|
122
|
+
- Move confidence conservatively. A single rumor is +3 to +8, not +30.
|
|
123
|
+
- Direct official documentation that confirms the refined claim can jump more.
|
|
124
|
+
- Direct official contradiction should drop more.
|
|
125
|
+
- Keep prior evidence unless retracted or obsolete; mark obsolescence in notes.
|
|
126
|
+
- If the claim itself has resolved, call close_claim and set changed=true.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Reasoner:
|
|
131
|
+
def __init__(self, model: LanguageModel) -> None:
|
|
132
|
+
self.model = model
|
|
133
|
+
|
|
134
|
+
def initialize(self, claim: str, *, conv_id: str | None = None) -> tuple[ClaimState, str, dict[str, Any]]:
|
|
135
|
+
result = self.model.complete(
|
|
136
|
+
INIT_USER.format(claim=claim.strip()),
|
|
137
|
+
search=True,
|
|
138
|
+
system=SYSTEM_PROMPT,
|
|
139
|
+
conv_id=conv_id or "realitydiff-init",
|
|
140
|
+
cache_key="realitydiff-init-v3",
|
|
141
|
+
)
|
|
142
|
+
data = _payload_from_result(
|
|
143
|
+
result,
|
|
144
|
+
required=not bool(isinstance(result.get("close_claim"), dict) and result.get("close_claim", {}).get("ok")),
|
|
145
|
+
)
|
|
146
|
+
if not data:
|
|
147
|
+
data = {"statement": claim.strip(), "confidence": 50, "reason": ""}
|
|
148
|
+
state = self._state_from_payload(data, fallback_statement=claim, sources=result.get("citations") or [])
|
|
149
|
+
state = apply_close_claim(state, result.get("close_claim"))
|
|
150
|
+
reason = str(data.get("reason") or (state.outcome.summary if state.outcome else "Initial internet survey."))
|
|
151
|
+
return state, reason, result
|
|
152
|
+
|
|
153
|
+
def update(
|
|
154
|
+
self,
|
|
155
|
+
claim: str,
|
|
156
|
+
current: ClaimState,
|
|
157
|
+
*,
|
|
158
|
+
conv_id: str | None = None,
|
|
159
|
+
previous_response_id: str | None = None,
|
|
160
|
+
compaction: dict[str, Any] | None = None,
|
|
161
|
+
) -> tuple[ClaimState, str, bool, dict[str, Any]]:
|
|
162
|
+
urls = sorted(
|
|
163
|
+
{
|
|
164
|
+
item.source_url
|
|
165
|
+
for item in current.evidence_for + current.evidence_against
|
|
166
|
+
if item.source_url
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
result = self.model.complete(
|
|
170
|
+
WATCH_USER.format(
|
|
171
|
+
claim=claim.strip(),
|
|
172
|
+
state=json.dumps(compact_belief(current), ensure_ascii=False, separators=(",", ":")),
|
|
173
|
+
urls="\n".join(urls) or "(none)",
|
|
174
|
+
),
|
|
175
|
+
search=True,
|
|
176
|
+
system=SYSTEM_PROMPT,
|
|
177
|
+
conv_id=conv_id,
|
|
178
|
+
cache_key=conv_id or "realitydiff-watch-v3",
|
|
179
|
+
previous_response_id=previous_response_id,
|
|
180
|
+
compaction=compaction,
|
|
181
|
+
)
|
|
182
|
+
close = result.get("close_claim") if isinstance(result.get("close_claim"), dict) else None
|
|
183
|
+
data = _payload_from_result(result, required=not bool(close and close.get("ok")))
|
|
184
|
+
changed = bool(data.get("changed")) or bool(close and close.get("ok"))
|
|
185
|
+
reason = str(data.get("reason") or "")
|
|
186
|
+
if not changed:
|
|
187
|
+
return current, reason or "No new evidence.", False, result
|
|
188
|
+
if data:
|
|
189
|
+
state = self._state_from_payload(
|
|
190
|
+
data,
|
|
191
|
+
fallback_statement=current.statement,
|
|
192
|
+
previous=current,
|
|
193
|
+
sources=result.get("citations") or [],
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
state = current
|
|
197
|
+
state = apply_close_claim(state, close)
|
|
198
|
+
if state.tree_hash() == current.tree_hash() or not is_material_change(current, state):
|
|
199
|
+
note = reason.strip()
|
|
200
|
+
detail = "No material change."
|
|
201
|
+
if note:
|
|
202
|
+
detail = f"No material change. {note}"
|
|
203
|
+
return current, detail, False, result
|
|
204
|
+
if state.outcome and not reason:
|
|
205
|
+
reason = state.outcome.summary
|
|
206
|
+
return state, reason or "New evidence updated the belief.", True, result
|
|
207
|
+
|
|
208
|
+
def _state_from_payload(
|
|
209
|
+
self,
|
|
210
|
+
data: dict[str, Any],
|
|
211
|
+
*,
|
|
212
|
+
fallback_statement: str,
|
|
213
|
+
previous: ClaimState | None = None,
|
|
214
|
+
sources: list[dict[str, str]] | None = None,
|
|
215
|
+
) -> ClaimState:
|
|
216
|
+
prev_for = {item.id: item for item in previous.evidence_for} if previous else {}
|
|
217
|
+
prev_against = {item.id: item for item in previous.evidence_against} if previous else {}
|
|
218
|
+
prev_unknowns = {item.id: item for item in previous.unknowns} if previous else {}
|
|
219
|
+
prev_preds = {item.id: item for item in previous.predictions} if previous else {}
|
|
220
|
+
|
|
221
|
+
evidence_for = self._parse_evidence(data.get("evidence_for") or [], prev_for)
|
|
222
|
+
evidence_against = self._parse_evidence(data.get("evidence_against") or [], prev_against)
|
|
223
|
+
evidence_for, evidence_against = _attach_sources(evidence_for, evidence_against, sources or [], data)
|
|
224
|
+
|
|
225
|
+
return ClaimState(
|
|
226
|
+
statement=str(data.get("statement") or fallback_statement).strip(),
|
|
227
|
+
refined_statement=str(data.get("refined_statement") or "").strip(),
|
|
228
|
+
confidence=_coerce_number(data.get("confidence"), default=50.0),
|
|
229
|
+
summary=str(data.get("summary") or "").strip(),
|
|
230
|
+
evidence_for=evidence_for,
|
|
231
|
+
evidence_against=evidence_against,
|
|
232
|
+
unknowns=self._parse_unknowns(data.get("unknowns") or [], prev_unknowns),
|
|
233
|
+
predictions=self._parse_predictions(data.get("predictions") or [], prev_preds),
|
|
234
|
+
outcome=previous.outcome if previous else None,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def _parse_evidence(self, items: list[Any], previous: dict[str, Evidence]) -> list[Evidence]:
|
|
238
|
+
parsed: list[Evidence] = []
|
|
239
|
+
seen: set[str] = set()
|
|
240
|
+
for item in items:
|
|
241
|
+
if not isinstance(item, dict):
|
|
242
|
+
continue
|
|
243
|
+
statement = str(item.get("statement") or "").strip()
|
|
244
|
+
if not statement:
|
|
245
|
+
continue
|
|
246
|
+
source_url = str(item.get("source_url") or "").strip() or None
|
|
247
|
+
if source_url and not source_url.startswith(("http://", "https://")):
|
|
248
|
+
source_url = None
|
|
249
|
+
candidate = Evidence.make(
|
|
250
|
+
statement,
|
|
251
|
+
source_url=source_url,
|
|
252
|
+
source_title=item.get("source_title") or None,
|
|
253
|
+
weight=_coerce_number(item.get("weight"), default=0.5, lo=0.0, hi=1.0),
|
|
254
|
+
notes=str(item.get("notes") or ""),
|
|
255
|
+
retrieved_at=utcnow(),
|
|
256
|
+
)
|
|
257
|
+
requested_id = str(item.get("id") or "")
|
|
258
|
+
if requested_id and requested_id in previous:
|
|
259
|
+
prior = previous[requested_id]
|
|
260
|
+
candidate = candidate.model_copy(
|
|
261
|
+
update={
|
|
262
|
+
"id": requested_id,
|
|
263
|
+
"retrieved_at": prior.retrieved_at,
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
if candidate.id in seen:
|
|
267
|
+
continue
|
|
268
|
+
seen.add(candidate.id)
|
|
269
|
+
parsed.append(candidate)
|
|
270
|
+
parsed.sort(key=lambda item: item.weight, reverse=True)
|
|
271
|
+
return parsed[:12]
|
|
272
|
+
|
|
273
|
+
def _parse_unknowns(self, items: list[Any], previous: dict[str, Unknown]) -> list[Unknown]:
|
|
274
|
+
parsed: list[Unknown] = []
|
|
275
|
+
seen: set[str] = set()
|
|
276
|
+
for item in items:
|
|
277
|
+
if not isinstance(item, dict):
|
|
278
|
+
continue
|
|
279
|
+
question = str(item.get("question") or "").strip()
|
|
280
|
+
if not question:
|
|
281
|
+
continue
|
|
282
|
+
unknown = Unknown.make(question, str(item.get("why_it_matters") or ""))
|
|
283
|
+
requested_id = str(item.get("id") or "")
|
|
284
|
+
if requested_id and requested_id in previous:
|
|
285
|
+
unknown = unknown.model_copy(update={"id": requested_id})
|
|
286
|
+
if unknown.id in seen:
|
|
287
|
+
continue
|
|
288
|
+
seen.add(unknown.id)
|
|
289
|
+
parsed.append(unknown)
|
|
290
|
+
return parsed[:8]
|
|
291
|
+
|
|
292
|
+
def _parse_predictions(self, items: list[Any], previous: dict[str, Prediction]) -> list[Prediction]:
|
|
293
|
+
parsed: list[Prediction] = []
|
|
294
|
+
seen: set[str] = set()
|
|
295
|
+
for item in items:
|
|
296
|
+
if not isinstance(item, dict):
|
|
297
|
+
continue
|
|
298
|
+
statement = str(item.get("statement") or "").strip()
|
|
299
|
+
if not statement:
|
|
300
|
+
continue
|
|
301
|
+
status = item.get("status") or "open"
|
|
302
|
+
if status not in {"open", "confirmed", "falsified"}:
|
|
303
|
+
status = "open"
|
|
304
|
+
prediction = Prediction.make(
|
|
305
|
+
statement,
|
|
306
|
+
due=item.get("due") or None,
|
|
307
|
+
status=status,
|
|
308
|
+
how_to_falsify=str(item.get("how_to_falsify") or ""),
|
|
309
|
+
)
|
|
310
|
+
requested_id = str(item.get("id") or "")
|
|
311
|
+
if requested_id and requested_id in previous:
|
|
312
|
+
prediction = prediction.model_copy(update={"id": requested_id})
|
|
313
|
+
if prediction.id in seen:
|
|
314
|
+
continue
|
|
315
|
+
seen.add(prediction.id)
|
|
316
|
+
parsed.append(prediction)
|
|
317
|
+
return parsed[:8]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _payload_from_result(result: dict[str, Any], *, required: bool = True) -> dict[str, Any]:
|
|
321
|
+
text = str(result.get("text") or "")
|
|
322
|
+
if not text.strip():
|
|
323
|
+
if required:
|
|
324
|
+
return parse_json_object(text)
|
|
325
|
+
return {}
|
|
326
|
+
try:
|
|
327
|
+
return parse_json_object(text)
|
|
328
|
+
except XAIError:
|
|
329
|
+
if required:
|
|
330
|
+
raise
|
|
331
|
+
return {}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def apply_close_claim(state: ClaimState, close: Any) -> ClaimState:
|
|
335
|
+
if not isinstance(close, dict) or not close.get("ok"):
|
|
336
|
+
return state
|
|
337
|
+
verdict = close.get("verdict")
|
|
338
|
+
if verdict not in {"confirmed", "falsified", "settled"}:
|
|
339
|
+
return state
|
|
340
|
+
summary = str(close.get("outcome") or "").strip()
|
|
341
|
+
if not summary:
|
|
342
|
+
return state
|
|
343
|
+
confidence = close.get("confidence")
|
|
344
|
+
if confidence is None:
|
|
345
|
+
if verdict == "confirmed":
|
|
346
|
+
confidence = 100.0
|
|
347
|
+
elif verdict == "falsified":
|
|
348
|
+
confidence = 0.0
|
|
349
|
+
else:
|
|
350
|
+
confidence = state.confidence
|
|
351
|
+
outcome = ClaimOutcome(
|
|
352
|
+
verdict=verdict,
|
|
353
|
+
summary=summary,
|
|
354
|
+
source_url=close.get("source_url") or None,
|
|
355
|
+
source_title=close.get("source_title") or None,
|
|
356
|
+
)
|
|
357
|
+
evidence_for = list(state.evidence_for)
|
|
358
|
+
evidence_against = list(state.evidence_against)
|
|
359
|
+
source_url = outcome.source_url
|
|
360
|
+
if source_url:
|
|
361
|
+
item = Evidence.make(
|
|
362
|
+
summary,
|
|
363
|
+
source_url=source_url,
|
|
364
|
+
source_title=outcome.source_title,
|
|
365
|
+
weight=1.0,
|
|
366
|
+
notes="Primary source for the final outcome.",
|
|
367
|
+
)
|
|
368
|
+
bucket = evidence_for if verdict != "falsified" else evidence_against
|
|
369
|
+
if all((existing.source_url or "").rstrip("/") != source_url.rstrip("/") for existing in bucket):
|
|
370
|
+
bucket.insert(0, item)
|
|
371
|
+
return state.model_copy(
|
|
372
|
+
update={
|
|
373
|
+
"confidence": _coerce_number(confidence, default=state.confidence),
|
|
374
|
+
"outcome": outcome,
|
|
375
|
+
"summary": summary if not state.summary else state.summary,
|
|
376
|
+
"evidence_for": evidence_for[:12],
|
|
377
|
+
"evidence_against": evidence_against[:12],
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _source_links(raw: list[dict[str, str]], data: dict[str, Any]) -> list[tuple[str, str]]:
|
|
383
|
+
found: list[tuple[str, str]] = []
|
|
384
|
+
seen: set[str] = set()
|
|
385
|
+
|
|
386
|
+
def add(url: str, title: str = "") -> None:
|
|
387
|
+
url = (url or "").strip()
|
|
388
|
+
if not url.startswith(("http://", "https://")) or url in seen:
|
|
389
|
+
return
|
|
390
|
+
seen.add(url)
|
|
391
|
+
found.append((url, (title or "").strip()))
|
|
392
|
+
|
|
393
|
+
for item in raw:
|
|
394
|
+
add(item.get("url") or "", item.get("title") or "")
|
|
395
|
+
extra = data.get("new_source_urls") or []
|
|
396
|
+
if isinstance(extra, list):
|
|
397
|
+
for url in extra:
|
|
398
|
+
if isinstance(url, str):
|
|
399
|
+
add(url)
|
|
400
|
+
return found[:24]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _attach_sources(
|
|
404
|
+
for_items: list[Evidence],
|
|
405
|
+
against_items: list[Evidence],
|
|
406
|
+
raw_sources: list[dict[str, str]],
|
|
407
|
+
data: dict[str, Any],
|
|
408
|
+
) -> tuple[list[Evidence], list[Evidence]]:
|
|
409
|
+
"""Fill evidence that omitted a URL using search links. Sources stay on the evidence item."""
|
|
410
|
+
links = _source_links(raw_sources, data)
|
|
411
|
+
used = {(item.source_url or "").rstrip("/") for item in for_items + against_items if item.source_url}
|
|
412
|
+
leftover = [(url, title) for url, title in links if url.rstrip("/") not in used]
|
|
413
|
+
|
|
414
|
+
def patch(items: list[Evidence]) -> list[Evidence]:
|
|
415
|
+
patched: list[Evidence] = []
|
|
416
|
+
for item in items:
|
|
417
|
+
if item.source_url or not leftover:
|
|
418
|
+
patched.append(item)
|
|
419
|
+
continue
|
|
420
|
+
url, title = leftover.pop(0)
|
|
421
|
+
patched.append(item.model_copy(update={"source_url": url, "source_title": item.source_title or title}))
|
|
422
|
+
return patched
|
|
423
|
+
|
|
424
|
+
return patch(for_items), patch(against_items)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _coerce_number(value: Any, *, default: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
|
428
|
+
if value is None or value == "":
|
|
429
|
+
return default
|
|
430
|
+
if isinstance(value, bool):
|
|
431
|
+
return default
|
|
432
|
+
if isinstance(value, (int, float)):
|
|
433
|
+
number = float(value)
|
|
434
|
+
else:
|
|
435
|
+
text = str(value).strip().replace("%", "")
|
|
436
|
+
try:
|
|
437
|
+
number = float(text)
|
|
438
|
+
except ValueError:
|
|
439
|
+
return default
|
|
440
|
+
return max(lo, min(hi, number))
|
realitydiff/schedule.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
LABEL = "com.realitydiff.watch"
|
|
11
|
+
WINDOWS_TASK = "RealityDiffWatch"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def install(interval_seconds: int, *, db: str, env: dict[str, str] | None = None) -> dict[str, str]:
|
|
15
|
+
if interval_seconds < 30:
|
|
16
|
+
raise ValueError("Interval must be at least 30 seconds")
|
|
17
|
+
env = {**_watch_env(db), **(env or {})}
|
|
18
|
+
system = platform.system()
|
|
19
|
+
if system == "Darwin":
|
|
20
|
+
path = _macos_plist_path()
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
_write_private(path, _macos_plist(interval_seconds, env))
|
|
23
|
+
_run(["launchctl", "unload", str(path)], check=False)
|
|
24
|
+
_run(["launchctl", "load", str(path)])
|
|
25
|
+
return {"platform": "darwin", "path": str(path), "interval_seconds": str(interval_seconds)}
|
|
26
|
+
if system == "Linux":
|
|
27
|
+
unit_dir = Path.home() / ".config/systemd/user"
|
|
28
|
+
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
service = unit_dir / "realitydiff-watch.service"
|
|
30
|
+
timer = unit_dir / "realitydiff-watch.timer"
|
|
31
|
+
_write_private(service, _systemd_service(env))
|
|
32
|
+
_write_private(timer, _systemd_timer(interval_seconds))
|
|
33
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
34
|
+
_run(["systemctl", "--user", "enable", "--now", "realitydiff-watch.timer"])
|
|
35
|
+
return {"platform": "linux", "path": str(timer), "interval_seconds": str(interval_seconds)}
|
|
36
|
+
if system == "Windows":
|
|
37
|
+
minutes = max(1, round(interval_seconds / 60))
|
|
38
|
+
script = _windows_script_path()
|
|
39
|
+
script.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
_write_private(script, _windows_cmd(env))
|
|
41
|
+
_run(
|
|
42
|
+
[
|
|
43
|
+
"schtasks",
|
|
44
|
+
"/Create",
|
|
45
|
+
"/TN",
|
|
46
|
+
WINDOWS_TASK,
|
|
47
|
+
"/SC",
|
|
48
|
+
"MINUTE",
|
|
49
|
+
"/MO",
|
|
50
|
+
str(minutes),
|
|
51
|
+
"/TR",
|
|
52
|
+
f'"{script}"',
|
|
53
|
+
"/F",
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
return {"platform": "windows", "path": str(script), "interval_seconds": str(minutes * 60)}
|
|
57
|
+
raise RuntimeError(f"No service installer for {system}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def uninstall() -> dict[str, str]:
|
|
61
|
+
system = platform.system()
|
|
62
|
+
if system == "Darwin":
|
|
63
|
+
path = _macos_plist_path()
|
|
64
|
+
_run(["launchctl", "unload", str(path)], check=False)
|
|
65
|
+
if path.exists():
|
|
66
|
+
path.unlink()
|
|
67
|
+
return {"platform": "darwin", "removed": str(path)}
|
|
68
|
+
if system == "Linux":
|
|
69
|
+
_run(["systemctl", "--user", "disable", "--now", "realitydiff-watch.timer"], check=False)
|
|
70
|
+
unit_dir = Path.home() / ".config/systemd/user"
|
|
71
|
+
for name in ("realitydiff-watch.service", "realitydiff-watch.timer"):
|
|
72
|
+
path = unit_dir / name
|
|
73
|
+
if path.exists():
|
|
74
|
+
path.unlink()
|
|
75
|
+
return {"platform": "linux", "removed": str(unit_dir / "realitydiff-watch.timer")}
|
|
76
|
+
if system == "Windows":
|
|
77
|
+
_run(["schtasks", "/Delete", "/TN", WINDOWS_TASK, "/F"], check=False)
|
|
78
|
+
script = _windows_script_path()
|
|
79
|
+
if script.exists():
|
|
80
|
+
script.unlink()
|
|
81
|
+
return {"platform": "windows", "removed": str(script)}
|
|
82
|
+
raise RuntimeError(f"No service installer for {system}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def status() -> dict[str, str]:
|
|
86
|
+
system = platform.system()
|
|
87
|
+
if system == "Darwin":
|
|
88
|
+
path = _macos_plist_path()
|
|
89
|
+
return {"platform": "darwin", "installed": str(path.exists()).lower(), "path": str(path)}
|
|
90
|
+
if system == "Linux":
|
|
91
|
+
timer = Path.home() / ".config/systemd/user/realitydiff-watch.timer"
|
|
92
|
+
return {"platform": "linux", "installed": str(timer.exists()).lower(), "path": str(timer)}
|
|
93
|
+
if system == "Windows":
|
|
94
|
+
result = _run(["schtasks", "/Query", "/TN", WINDOWS_TASK], check=False)
|
|
95
|
+
installed = result.returncode == 0
|
|
96
|
+
return {"platform": "windows", "installed": str(installed).lower(), "path": WINDOWS_TASK}
|
|
97
|
+
return {"platform": system.lower(), "installed": "false"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _watch_env(db: str) -> dict[str, str]:
|
|
101
|
+
# XAI_API_KEY is deliberately not copied here: service files are plain
|
|
102
|
+
# text on disk. The watcher runs the CLI, which loads the key from
|
|
103
|
+
# ~/.config/realitydiff/env (see realitydiff.config).
|
|
104
|
+
return {
|
|
105
|
+
"REALITYDIFF_DB": str(Path(db).expanduser().resolve()),
|
|
106
|
+
"PATH": os.environ.get("PATH", ""),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write_private(path: Path, text: str) -> None:
|
|
111
|
+
"""Write a file readable only by the current user (0600)."""
|
|
112
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
113
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
114
|
+
fh.write(text)
|
|
115
|
+
try:
|
|
116
|
+
os.chmod(path, 0o600)
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _windows_script_path() -> Path:
|
|
122
|
+
base = Path(os.environ.get("APPDATA") or Path.home()) / "RealityDiff"
|
|
123
|
+
return base / "watch-all.cmd"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _bat_escape(value: str) -> str:
|
|
127
|
+
return (
|
|
128
|
+
str(value)
|
|
129
|
+
.replace("%", "%%")
|
|
130
|
+
.replace("^", "^^")
|
|
131
|
+
.replace("&", "^&")
|
|
132
|
+
.replace("|", "^|")
|
|
133
|
+
.replace("<", "^<")
|
|
134
|
+
.replace(">", "^>")
|
|
135
|
+
.replace('"', "")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _windows_cmd(env: dict[str, str]) -> str:
|
|
140
|
+
lines = ["@echo off"]
|
|
141
|
+
for key, value in env.items():
|
|
142
|
+
lines.append(f'set "{key}={_bat_escape(value)}"')
|
|
143
|
+
exe, *args = _program_args()
|
|
144
|
+
quoted = " ".join(f'"{part}"' for part in [exe, *args])
|
|
145
|
+
lines.append(quoted)
|
|
146
|
+
return "\n".join(lines) + "\n"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _macos_plist_path() -> Path:
|
|
150
|
+
return Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _program_args() -> list[str]:
|
|
154
|
+
return [sys.executable, "-m", "realitydiff", "watch-all"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _macos_plist(interval_seconds: int, env: dict[str, str]) -> str:
|
|
158
|
+
args = "".join(f" <string>{_xml(arg)}</string>\n" for arg in _program_args())
|
|
159
|
+
env_xml = "".join(f" <key>{_xml(k)}</key>\n <string>{_xml(v)}</string>\n" for k, v in env.items())
|
|
160
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
161
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
162
|
+
<plist version="1.0">
|
|
163
|
+
<dict>
|
|
164
|
+
<key>Label</key>
|
|
165
|
+
<string>{LABEL}</string>
|
|
166
|
+
<key>ProgramArguments</key>
|
|
167
|
+
<array>
|
|
168
|
+
{args} </array>
|
|
169
|
+
<key>StartInterval</key>
|
|
170
|
+
<integer>{int(interval_seconds)}</integer>
|
|
171
|
+
<key>RunAtLoad</key>
|
|
172
|
+
<true/>
|
|
173
|
+
<key>EnvironmentVariables</key>
|
|
174
|
+
<dict>
|
|
175
|
+
{env_xml} </dict>
|
|
176
|
+
</dict>
|
|
177
|
+
</plist>
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _systemd_service(env: dict[str, str]) -> str:
|
|
182
|
+
env_lines = "\n".join(f"Environment={k}={v}" for k, v in env.items())
|
|
183
|
+
exe, *args = _program_args()
|
|
184
|
+
return f"""[Unit]
|
|
185
|
+
Description=Reality Diff claim.watch()
|
|
186
|
+
[Service]
|
|
187
|
+
Type=oneshot
|
|
188
|
+
ExecStart={exe} {" ".join(args)}
|
|
189
|
+
{env_lines}
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _systemd_timer(interval_seconds: int) -> str:
|
|
194
|
+
return f"""[Unit]
|
|
195
|
+
Description=Reality Diff watch timer
|
|
196
|
+
[Timer]
|
|
197
|
+
OnBootSec=30
|
|
198
|
+
OnUnitActiveSec={int(interval_seconds)}
|
|
199
|
+
AccuracySec=10
|
|
200
|
+
Persistent=true
|
|
201
|
+
[Install]
|
|
202
|
+
WantedBy=timers.target
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _xml(value: str) -> str:
|
|
207
|
+
return (
|
|
208
|
+
str(value)
|
|
209
|
+
.replace("&", "&")
|
|
210
|
+
.replace("<", "<")
|
|
211
|
+
.replace(">", ">")
|
|
212
|
+
.replace('"', """)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
217
|
+
return subprocess.run(cmd, check=check, capture_output=True, text=True)
|