dreamloop 0.1.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.
- dreamloop/__init__.py +3 -0
- dreamloop/analysis.py +406 -0
- dreamloop/cli.py +130 -0
- dreamloop/core.py +601 -0
- dreamloop/static/style.css +1503 -0
- dreamloop/templates/detail.html +181 -0
- dreamloop/templates/index.html +509 -0
- dreamloop/web.py +766 -0
- dreamloop-0.1.0.dist-info/METADATA +252 -0
- dreamloop-0.1.0.dist-info/RECORD +13 -0
- dreamloop-0.1.0.dist-info/WHEEL +4 -0
- dreamloop-0.1.0.dist-info/entry_points.txt +2 -0
- dreamloop-0.1.0.dist-info/licenses/LICENSE +21 -0
dreamloop/core.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
|
|
13
|
+
from .analysis import Analyzer, build_analyzer, clean_reflections, normalize_analysis
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DreamLoop:
|
|
17
|
+
def __init__(self, root: str | Path | None = None) -> None:
|
|
18
|
+
self.root = Path(root or Path.cwd())
|
|
19
|
+
self.data_dir = self.root / ".dreamloop"
|
|
20
|
+
self.db_path = self.data_dir / "dreamloop.sqlite3"
|
|
21
|
+
|
|
22
|
+
def init(self) -> None:
|
|
23
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
for name in ("chroma", "exports", "imports"):
|
|
25
|
+
(self.data_dir / name).mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self._ensure_gitignore()
|
|
27
|
+
self._migrate()
|
|
28
|
+
|
|
29
|
+
def add_dream(
|
|
30
|
+
self,
|
|
31
|
+
content: str,
|
|
32
|
+
tags: list[str] | None = None,
|
|
33
|
+
mood: str | None = None,
|
|
34
|
+
dreamed_on: date | None = None,
|
|
35
|
+
reflections: dict[str, Any] | None = None,
|
|
36
|
+
) -> int:
|
|
37
|
+
self.init()
|
|
38
|
+
if not content.strip():
|
|
39
|
+
raise ValueError("Dream content cannot be empty.")
|
|
40
|
+
dreamed_on = dreamed_on or date.today()
|
|
41
|
+
with self._connect() as db:
|
|
42
|
+
cursor = db.execute(
|
|
43
|
+
"""
|
|
44
|
+
INSERT INTO dreams (
|
|
45
|
+
content, created_at, dreamed_on, manual_mood, tags_json, reflection_json, analysis_status
|
|
46
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
47
|
+
""",
|
|
48
|
+
(
|
|
49
|
+
content.strip(),
|
|
50
|
+
datetime.now().isoformat(timespec="seconds"),
|
|
51
|
+
dreamed_on.isoformat(),
|
|
52
|
+
mood,
|
|
53
|
+
json.dumps(tags or [], ensure_ascii=False),
|
|
54
|
+
json.dumps(clean_reflections(reflections), ensure_ascii=False),
|
|
55
|
+
"pending",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
return int(cursor.lastrowid)
|
|
59
|
+
|
|
60
|
+
def add_dream_with_analysis(
|
|
61
|
+
self,
|
|
62
|
+
content: str,
|
|
63
|
+
analysis: dict[str, Any],
|
|
64
|
+
*,
|
|
65
|
+
language: str = "en",
|
|
66
|
+
dreamed_on: date | None = None,
|
|
67
|
+
reflections: dict[str, Any] | None = None,
|
|
68
|
+
) -> int:
|
|
69
|
+
self.init()
|
|
70
|
+
if not content.strip():
|
|
71
|
+
raise ValueError("Dream content cannot be empty.")
|
|
72
|
+
dreamed_on = dreamed_on or date.today()
|
|
73
|
+
with self._connect() as db:
|
|
74
|
+
cursor = db.execute(
|
|
75
|
+
"""
|
|
76
|
+
INSERT INTO dreams (
|
|
77
|
+
content, created_at, dreamed_on, manual_mood, tags_json, reflection_json, analysis_status
|
|
78
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
79
|
+
""",
|
|
80
|
+
(
|
|
81
|
+
content.strip(),
|
|
82
|
+
datetime.now().isoformat(timespec="seconds"),
|
|
83
|
+
dreamed_on.isoformat(),
|
|
84
|
+
None,
|
|
85
|
+
"[]",
|
|
86
|
+
json.dumps(clean_reflections(reflections), ensure_ascii=False),
|
|
87
|
+
"analyzed",
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
dream_id = int(cursor.lastrowid)
|
|
91
|
+
self._store_analysis(db, dream_id, normalize_analysis(analysis), language)
|
|
92
|
+
return dream_id
|
|
93
|
+
|
|
94
|
+
def list_dreams(self) -> list[dict[str, Any]]:
|
|
95
|
+
self.init()
|
|
96
|
+
with self._connect() as db:
|
|
97
|
+
rows = db.execute("SELECT * FROM dreams ORDER BY dreamed_on DESC, id DESC").fetchall()
|
|
98
|
+
return [self._dream_from_row(row) for row in rows]
|
|
99
|
+
|
|
100
|
+
def get_dream(self, dream_id: int, language: str = "en") -> dict[str, Any]:
|
|
101
|
+
self.init()
|
|
102
|
+
with self._connect() as db:
|
|
103
|
+
row = db.execute("SELECT * FROM dreams WHERE id = ?", (dream_id,)).fetchone()
|
|
104
|
+
if row is None:
|
|
105
|
+
raise KeyError(f"Dream {dream_id} was not found.")
|
|
106
|
+
dream = self._dream_from_row(row)
|
|
107
|
+
analysis = db.execute(
|
|
108
|
+
"SELECT * FROM dream_analyses WHERE dream_id = ? AND language = ?",
|
|
109
|
+
(dream_id, normalize_language(language)),
|
|
110
|
+
).fetchone()
|
|
111
|
+
if analysis:
|
|
112
|
+
raw_report = parse_json_any(analysis["raw_json"])
|
|
113
|
+
dream["analysis"] = {
|
|
114
|
+
"language": analysis["language"],
|
|
115
|
+
"emotional_tone": analysis["emotional_tone"],
|
|
116
|
+
"symbols": json.loads(analysis["symbols_json"]),
|
|
117
|
+
"themes": json.loads(analysis["themes_json"]),
|
|
118
|
+
"summary": analysis["summary"],
|
|
119
|
+
"confidence": analysis["confidence"],
|
|
120
|
+
"report": raw_report if isinstance(raw_report, dict) else {},
|
|
121
|
+
"raw_json": analysis["raw_json"],
|
|
122
|
+
}
|
|
123
|
+
else:
|
|
124
|
+
dream["analysis"] = None
|
|
125
|
+
return dream
|
|
126
|
+
|
|
127
|
+
def analyze_pending(
|
|
128
|
+
self,
|
|
129
|
+
analyzer: Analyzer | None = None,
|
|
130
|
+
limit: int | None = None,
|
|
131
|
+
*,
|
|
132
|
+
language: str = "en",
|
|
133
|
+
) -> list[int]:
|
|
134
|
+
self.init()
|
|
135
|
+
if analyzer is None:
|
|
136
|
+
analyzer = build_analyzer(self.root)
|
|
137
|
+
if analyzer is None:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
sql = "SELECT * FROM dreams WHERE analysis_status = 'pending' ORDER BY id"
|
|
141
|
+
params: tuple[Any, ...] = ()
|
|
142
|
+
if limit is not None:
|
|
143
|
+
sql += " LIMIT ?"
|
|
144
|
+
params = (limit,)
|
|
145
|
+
|
|
146
|
+
analyzed: list[int] = []
|
|
147
|
+
with self._connect() as db:
|
|
148
|
+
rows = db.execute(sql, params).fetchall()
|
|
149
|
+
for row in rows:
|
|
150
|
+
self._write_analysis(db, int(row["id"]), row["content"], analyzer, language, row["reflection_json"])
|
|
151
|
+
analyzed.append(int(row["id"]))
|
|
152
|
+
return analyzed
|
|
153
|
+
|
|
154
|
+
def analyze_dream(self, dream_id: int, analyzer: Analyzer | None = None, *, language: str = "en") -> int:
|
|
155
|
+
self.init()
|
|
156
|
+
if analyzer is None:
|
|
157
|
+
analyzer = build_analyzer(self.root)
|
|
158
|
+
if analyzer is None:
|
|
159
|
+
raise RuntimeError("AI provider is not ready.")
|
|
160
|
+
|
|
161
|
+
with self._connect() as db:
|
|
162
|
+
row = db.execute("SELECT * FROM dreams WHERE id = ?", (dream_id,)).fetchone()
|
|
163
|
+
if row is None:
|
|
164
|
+
raise KeyError(f"Dream {dream_id} was not found.")
|
|
165
|
+
self._write_analysis(db, dream_id, row["content"], analyzer, language, row["reflection_json"])
|
|
166
|
+
return dream_id
|
|
167
|
+
|
|
168
|
+
def import_ics(self, path: str | Path) -> int:
|
|
169
|
+
self.init()
|
|
170
|
+
events = parse_ics(Path(path).read_text(encoding="utf-8"))
|
|
171
|
+
with self._connect() as db:
|
|
172
|
+
for event in events:
|
|
173
|
+
db.execute(
|
|
174
|
+
"""
|
|
175
|
+
INSERT INTO calendar_events (uid, starts_on, ends_on, summary, raw_json)
|
|
176
|
+
VALUES (?, ?, ?, ?, ?)
|
|
177
|
+
""",
|
|
178
|
+
(
|
|
179
|
+
event.get("uid"),
|
|
180
|
+
event["starts_on"],
|
|
181
|
+
event.get("ends_on"),
|
|
182
|
+
event.get("summary", ""),
|
|
183
|
+
json.dumps(event, ensure_ascii=False),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
return len(events)
|
|
187
|
+
|
|
188
|
+
def sync_weather(
|
|
189
|
+
self,
|
|
190
|
+
lat: float,
|
|
191
|
+
lon: float,
|
|
192
|
+
*,
|
|
193
|
+
fetcher: Callable[..., dict[str, Any]] | None = None,
|
|
194
|
+
) -> int:
|
|
195
|
+
self.init()
|
|
196
|
+
payload = fetcher(lat, lon) if fetcher else fetch_open_meteo(lat, lon)
|
|
197
|
+
daily = payload.get("daily", {})
|
|
198
|
+
times = daily.get("time", [])
|
|
199
|
+
with self._connect() as db:
|
|
200
|
+
for index, day in enumerate(times):
|
|
201
|
+
db.execute(
|
|
202
|
+
"""
|
|
203
|
+
INSERT INTO weather_daily (
|
|
204
|
+
weather_date, lat, lon, temperature_2m_max, temperature_2m_min,
|
|
205
|
+
precipitation_sum, weather_code, raw_json
|
|
206
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
207
|
+
ON CONFLICT(weather_date) DO UPDATE SET
|
|
208
|
+
lat = excluded.lat,
|
|
209
|
+
lon = excluded.lon,
|
|
210
|
+
temperature_2m_max = excluded.temperature_2m_max,
|
|
211
|
+
temperature_2m_min = excluded.temperature_2m_min,
|
|
212
|
+
precipitation_sum = excluded.precipitation_sum,
|
|
213
|
+
weather_code = excluded.weather_code,
|
|
214
|
+
raw_json = excluded.raw_json
|
|
215
|
+
""",
|
|
216
|
+
(
|
|
217
|
+
day,
|
|
218
|
+
lat,
|
|
219
|
+
lon,
|
|
220
|
+
value_at(daily, "temperature_2m_max", index),
|
|
221
|
+
value_at(daily, "temperature_2m_min", index),
|
|
222
|
+
value_at(daily, "precipitation_sum", index),
|
|
223
|
+
value_at(daily, "weather_code", index),
|
|
224
|
+
json.dumps(payload, ensure_ascii=False),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
return len(times)
|
|
228
|
+
|
|
229
|
+
def heatmap(self) -> list[dict[str, Any]]:
|
|
230
|
+
self.init()
|
|
231
|
+
with self._connect() as db:
|
|
232
|
+
rows = db.execute(
|
|
233
|
+
"""
|
|
234
|
+
SELECT dreamed_on, manual_mood, COUNT(*) AS count
|
|
235
|
+
FROM dreams
|
|
236
|
+
GROUP BY dreamed_on, manual_mood
|
|
237
|
+
ORDER BY dreamed_on
|
|
238
|
+
"""
|
|
239
|
+
).fetchall()
|
|
240
|
+
grouped: dict[str, dict[str, Any]] = {}
|
|
241
|
+
for row in rows:
|
|
242
|
+
bucket = grouped.setdefault(row["dreamed_on"], {"date": row["dreamed_on"], "count": 0, "moods": {}})
|
|
243
|
+
bucket["count"] += row["count"]
|
|
244
|
+
mood = row["manual_mood"] or "unknown"
|
|
245
|
+
bucket["moods"][mood] = row["count"]
|
|
246
|
+
return list(grouped.values())
|
|
247
|
+
|
|
248
|
+
def day_context(self, day: date) -> dict[str, Any]:
|
|
249
|
+
self.init()
|
|
250
|
+
day_text = day.isoformat()
|
|
251
|
+
with self._connect() as db:
|
|
252
|
+
events = db.execute(
|
|
253
|
+
"SELECT * FROM calendar_events WHERE starts_on = ? ORDER BY id", (day_text,)
|
|
254
|
+
).fetchall()
|
|
255
|
+
weather = db.execute(
|
|
256
|
+
"SELECT * FROM weather_daily WHERE weather_date = ?", (day_text,)
|
|
257
|
+
).fetchone()
|
|
258
|
+
return {
|
|
259
|
+
"events": [dict(event) for event in events],
|
|
260
|
+
"weather": dict(weather) if weather else None,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def similar_dreams(self, dream_id: int, limit: int = 5) -> list[dict[str, Any]]:
|
|
264
|
+
target = self.get_dream(dream_id)
|
|
265
|
+
matches: list[dict[str, Any]] = []
|
|
266
|
+
for dream in self.list_dreams():
|
|
267
|
+
if dream["id"] == dream_id:
|
|
268
|
+
continue
|
|
269
|
+
score = dream_similarity(target, dream)
|
|
270
|
+
if score > 0:
|
|
271
|
+
dream["score"] = round(score, 3)
|
|
272
|
+
matches.append(dream)
|
|
273
|
+
return sorted(matches, key=lambda item: item["score"], reverse=True)[:limit]
|
|
274
|
+
|
|
275
|
+
def trends(self, language: str = "en") -> dict[str, list[dict[str, Any]]]:
|
|
276
|
+
self.init()
|
|
277
|
+
tags: Counter[str] = Counter()
|
|
278
|
+
symbols: Counter[str] = Counter()
|
|
279
|
+
themes: Counter[str] = Counter()
|
|
280
|
+
for dream in self.list_dreams():
|
|
281
|
+
tags.update(dream["tags"])
|
|
282
|
+
analysis = self.get_dream(dream["id"], language=language)["analysis"]
|
|
283
|
+
if analysis:
|
|
284
|
+
symbols.update(analysis["symbols"])
|
|
285
|
+
themes.update(analysis["themes"])
|
|
286
|
+
return {
|
|
287
|
+
"tags": counter_items(tags),
|
|
288
|
+
"symbols": counter_items(symbols, secondary=tags),
|
|
289
|
+
"themes": counter_items(themes),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
def _connect(self) -> sqlite3.Connection:
|
|
293
|
+
db = sqlite3.connect(self.db_path)
|
|
294
|
+
db.row_factory = sqlite3.Row
|
|
295
|
+
return db
|
|
296
|
+
|
|
297
|
+
def _write_analysis(
|
|
298
|
+
self,
|
|
299
|
+
db: sqlite3.Connection,
|
|
300
|
+
dream_id: int,
|
|
301
|
+
content: str,
|
|
302
|
+
analyzer: Analyzer,
|
|
303
|
+
language: str,
|
|
304
|
+
reflection_json: str | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
reflections = parse_json_object(reflection_json)
|
|
307
|
+
normalized = normalize_analysis(call_analyzer(analyzer, content, normalize_language(language), reflections))
|
|
308
|
+
self._store_analysis(db, dream_id, normalized, language)
|
|
309
|
+
|
|
310
|
+
def _store_analysis(
|
|
311
|
+
self,
|
|
312
|
+
db: sqlite3.Connection,
|
|
313
|
+
dream_id: int,
|
|
314
|
+
normalized: dict[str, Any],
|
|
315
|
+
language: str,
|
|
316
|
+
) -> None:
|
|
317
|
+
language = normalize_language(language)
|
|
318
|
+
db.execute(
|
|
319
|
+
"""
|
|
320
|
+
INSERT INTO dream_analyses (
|
|
321
|
+
dream_id, language, emotional_tone, symbols_json, themes_json, summary,
|
|
322
|
+
confidence, raw_json
|
|
323
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
324
|
+
ON CONFLICT(dream_id, language) DO UPDATE SET
|
|
325
|
+
emotional_tone = excluded.emotional_tone,
|
|
326
|
+
symbols_json = excluded.symbols_json,
|
|
327
|
+
themes_json = excluded.themes_json,
|
|
328
|
+
summary = excluded.summary,
|
|
329
|
+
confidence = excluded.confidence,
|
|
330
|
+
raw_json = excluded.raw_json
|
|
331
|
+
""",
|
|
332
|
+
(
|
|
333
|
+
dream_id,
|
|
334
|
+
language,
|
|
335
|
+
normalized["emotional_tone"],
|
|
336
|
+
json.dumps(normalized["symbols"], ensure_ascii=False),
|
|
337
|
+
json.dumps(normalized["themes"], ensure_ascii=False),
|
|
338
|
+
normalized["summary"],
|
|
339
|
+
normalized["confidence"],
|
|
340
|
+
normalized["raw_json"],
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
db.execute("UPDATE dreams SET analysis_status = 'analyzed' WHERE id = ?", (dream_id,))
|
|
344
|
+
|
|
345
|
+
def _migrate(self) -> None:
|
|
346
|
+
with self._connect() as db:
|
|
347
|
+
db.executescript(
|
|
348
|
+
"""
|
|
349
|
+
CREATE TABLE IF NOT EXISTS dreams (
|
|
350
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
351
|
+
content TEXT NOT NULL,
|
|
352
|
+
created_at TEXT NOT NULL,
|
|
353
|
+
dreamed_on TEXT NOT NULL,
|
|
354
|
+
manual_mood TEXT,
|
|
355
|
+
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
356
|
+
reflection_json TEXT NOT NULL DEFAULT '{}',
|
|
357
|
+
analysis_status TEXT NOT NULL DEFAULT 'pending'
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
361
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
362
|
+
uid TEXT,
|
|
363
|
+
starts_on TEXT NOT NULL,
|
|
364
|
+
ends_on TEXT,
|
|
365
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
366
|
+
raw_json TEXT NOT NULL
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
CREATE TABLE IF NOT EXISTS weather_daily (
|
|
370
|
+
weather_date TEXT PRIMARY KEY,
|
|
371
|
+
lat REAL NOT NULL,
|
|
372
|
+
lon REAL NOT NULL,
|
|
373
|
+
temperature_2m_max REAL,
|
|
374
|
+
temperature_2m_min REAL,
|
|
375
|
+
precipitation_sum REAL,
|
|
376
|
+
weather_code INTEGER,
|
|
377
|
+
raw_json TEXT NOT NULL
|
|
378
|
+
);
|
|
379
|
+
"""
|
|
380
|
+
)
|
|
381
|
+
self._migrate_dreams_table(db)
|
|
382
|
+
self._migrate_analysis_table(db)
|
|
383
|
+
|
|
384
|
+
def _migrate_dreams_table(self, db: sqlite3.Connection) -> None:
|
|
385
|
+
columns = db.execute("PRAGMA table_info(dreams)").fetchall()
|
|
386
|
+
column_names = {column["name"] for column in columns}
|
|
387
|
+
if "reflection_json" not in column_names:
|
|
388
|
+
db.execute("ALTER TABLE dreams ADD COLUMN reflection_json TEXT NOT NULL DEFAULT '{}'")
|
|
389
|
+
|
|
390
|
+
def _migrate_analysis_table(self, db: sqlite3.Connection) -> None:
|
|
391
|
+
columns = db.execute("PRAGMA table_info(dream_analyses)").fetchall()
|
|
392
|
+
if not columns:
|
|
393
|
+
self._create_analysis_table(db)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
column_names = {column["name"] for column in columns}
|
|
397
|
+
pk_columns = [
|
|
398
|
+
column["name"]
|
|
399
|
+
for column in sorted((column for column in columns if column["pk"]), key=lambda item: item["pk"])
|
|
400
|
+
]
|
|
401
|
+
if "language" in column_names and pk_columns == ["dream_id", "language"]:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
db.execute("ALTER TABLE dream_analyses RENAME TO dream_analyses_old")
|
|
405
|
+
self._create_analysis_table(db)
|
|
406
|
+
required = {
|
|
407
|
+
"dream_id",
|
|
408
|
+
"emotional_tone",
|
|
409
|
+
"symbols_json",
|
|
410
|
+
"themes_json",
|
|
411
|
+
"summary",
|
|
412
|
+
"confidence",
|
|
413
|
+
"raw_json",
|
|
414
|
+
}
|
|
415
|
+
if required.issubset(column_names):
|
|
416
|
+
language_expression = "language" if "language" in column_names else "'en'"
|
|
417
|
+
db.execute(
|
|
418
|
+
f"""
|
|
419
|
+
INSERT OR REPLACE INTO dream_analyses (
|
|
420
|
+
dream_id, language, emotional_tone, symbols_json, themes_json, summary,
|
|
421
|
+
confidence, raw_json
|
|
422
|
+
)
|
|
423
|
+
SELECT
|
|
424
|
+
dream_id,
|
|
425
|
+
COALESCE(NULLIF({language_expression}, ''), 'en'),
|
|
426
|
+
emotional_tone,
|
|
427
|
+
symbols_json,
|
|
428
|
+
themes_json,
|
|
429
|
+
summary,
|
|
430
|
+
confidence,
|
|
431
|
+
raw_json
|
|
432
|
+
FROM dream_analyses_old
|
|
433
|
+
"""
|
|
434
|
+
)
|
|
435
|
+
db.execute("DROP TABLE dream_analyses_old")
|
|
436
|
+
|
|
437
|
+
@staticmethod
|
|
438
|
+
def _create_analysis_table(db: sqlite3.Connection) -> None:
|
|
439
|
+
db.execute(
|
|
440
|
+
"""
|
|
441
|
+
CREATE TABLE IF NOT EXISTS dream_analyses (
|
|
442
|
+
dream_id INTEGER NOT NULL REFERENCES dreams(id) ON DELETE CASCADE,
|
|
443
|
+
language TEXT NOT NULL DEFAULT 'en',
|
|
444
|
+
emotional_tone TEXT NOT NULL,
|
|
445
|
+
symbols_json TEXT NOT NULL DEFAULT '[]',
|
|
446
|
+
themes_json TEXT NOT NULL DEFAULT '[]',
|
|
447
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
448
|
+
confidence REAL NOT NULL DEFAULT 0,
|
|
449
|
+
raw_json TEXT NOT NULL,
|
|
450
|
+
PRIMARY KEY (dream_id, language)
|
|
451
|
+
)
|
|
452
|
+
"""
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def _ensure_gitignore(self) -> None:
|
|
456
|
+
gitignore = self.root / ".gitignore"
|
|
457
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
458
|
+
if ".dreamloop/" not in existing.splitlines():
|
|
459
|
+
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
460
|
+
gitignore.write_text(existing + prefix + ".dreamloop/\n", encoding="utf-8")
|
|
461
|
+
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _dream_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
|
464
|
+
dream = dict(row)
|
|
465
|
+
dream["tags"] = json.loads(dream.pop("tags_json"))
|
|
466
|
+
dream["reflections"] = parse_json_object(dream.pop("reflection_json", "{}"))
|
|
467
|
+
return dream
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def value_at(payload: dict[str, Any], key: str, index: int) -> Any:
|
|
471
|
+
values = payload.get(key, [])
|
|
472
|
+
return values[index] if index < len(values) else None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def normalize_language(language: str | None) -> str:
|
|
476
|
+
return language if language in {"en", "zh"} else "en"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def parse_json_object(text: str | None) -> dict[str, str]:
|
|
480
|
+
if not text:
|
|
481
|
+
return {}
|
|
482
|
+
try:
|
|
483
|
+
payload = json.loads(text)
|
|
484
|
+
except json.JSONDecodeError:
|
|
485
|
+
return {}
|
|
486
|
+
return clean_reflections(payload if isinstance(payload, dict) else {})
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def parse_json_any(text: str | None) -> Any:
|
|
490
|
+
if not text:
|
|
491
|
+
return {}
|
|
492
|
+
try:
|
|
493
|
+
return json.loads(text)
|
|
494
|
+
except json.JSONDecodeError:
|
|
495
|
+
return {}
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def call_analyzer(
|
|
499
|
+
analyzer: Analyzer,
|
|
500
|
+
content: str,
|
|
501
|
+
language: str,
|
|
502
|
+
reflections: dict[str, str],
|
|
503
|
+
) -> dict[str, Any]:
|
|
504
|
+
import inspect
|
|
505
|
+
|
|
506
|
+
parameters = inspect.signature(analyzer.analyze).parameters
|
|
507
|
+
if "reflections" in parameters:
|
|
508
|
+
return analyzer.analyze(content, language=language, reflections=reflections)
|
|
509
|
+
return analyzer.analyze(content, language=language)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def dream_terms(dream: dict[str, Any]) -> set[str]:
|
|
513
|
+
stopwords = {"the", "was", "were", "and", "with", "through", "around"}
|
|
514
|
+
terms = set(dream.get("tags", []))
|
|
515
|
+
terms.update(
|
|
516
|
+
term
|
|
517
|
+
for term in re.findall(r"[a-zA-Z]{3,}", dream.get("content", "").lower())
|
|
518
|
+
if term not in stopwords
|
|
519
|
+
)
|
|
520
|
+
analysis = dream.get("analysis")
|
|
521
|
+
if analysis:
|
|
522
|
+
terms.update(analysis.get("symbols", []))
|
|
523
|
+
terms.update(analysis.get("themes", []))
|
|
524
|
+
return {term.lower() for term in terms}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def dream_similarity(target: dict[str, Any], candidate: dict[str, Any]) -> float:
|
|
528
|
+
target_tags = {tag.lower() for tag in target.get("tags", [])}
|
|
529
|
+
candidate_tags = {tag.lower() for tag in candidate.get("tags", [])}
|
|
530
|
+
target_all = dream_terms(target)
|
|
531
|
+
candidate_all = dream_terms(candidate)
|
|
532
|
+
target_text = dream_terms({"content": target.get("content", ""), "tags": []})
|
|
533
|
+
candidate_text = dream_terms({"content": candidate.get("content", ""), "tags": []})
|
|
534
|
+
|
|
535
|
+
weighted_overlap = (
|
|
536
|
+
3 * len(target_tags & candidate_tags)
|
|
537
|
+
+ 2 * len(target_text & candidate_text)
|
|
538
|
+
+ len(target_all & candidate_all)
|
|
539
|
+
)
|
|
540
|
+
weighted_size = (
|
|
541
|
+
3 * len(target_tags | candidate_tags)
|
|
542
|
+
+ 2 * len(target_text | candidate_text)
|
|
543
|
+
+ len(target_all | candidate_all)
|
|
544
|
+
)
|
|
545
|
+
return weighted_overlap / weighted_size if weighted_size else 0.0
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def counter_items(counter: Counter[str], secondary: Counter[str] | None = None) -> list[dict[str, Any]]:
|
|
549
|
+
secondary = secondary or Counter()
|
|
550
|
+
return [
|
|
551
|
+
{"name": name, "count": count}
|
|
552
|
+
for name, count in sorted(
|
|
553
|
+
counter.items(), key=lambda item: (-item[1], -secondary[item[0]], item[0])
|
|
554
|
+
)
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def fetch_open_meteo(lat: float, lon: float) -> dict[str, Any]:
|
|
559
|
+
query = urlencode(
|
|
560
|
+
{
|
|
561
|
+
"latitude": lat,
|
|
562
|
+
"longitude": lon,
|
|
563
|
+
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code",
|
|
564
|
+
"timezone": "auto",
|
|
565
|
+
"past_days": 30,
|
|
566
|
+
"forecast_days": 1,
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
with urlopen(f"https://api.open-meteo.com/v1/forecast?{query}", timeout=20) as response:
|
|
570
|
+
return json.loads(response.read().decode("utf-8"))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def parse_ics(text: str) -> list[dict[str, str]]:
|
|
574
|
+
events: list[dict[str, str]] = []
|
|
575
|
+
current: dict[str, str] | None = None
|
|
576
|
+
for raw_line in text.splitlines():
|
|
577
|
+
line = raw_line.strip()
|
|
578
|
+
if line == "BEGIN:VEVENT":
|
|
579
|
+
current = {}
|
|
580
|
+
elif line == "END:VEVENT" and current is not None:
|
|
581
|
+
if "starts_on" in current:
|
|
582
|
+
events.append(current)
|
|
583
|
+
current = None
|
|
584
|
+
elif current is not None and ":" in line:
|
|
585
|
+
key, value = line.split(":", 1)
|
|
586
|
+
upper_key = key.upper()
|
|
587
|
+
if upper_key == "UID":
|
|
588
|
+
current["uid"] = value
|
|
589
|
+
elif upper_key.startswith("DTSTART"):
|
|
590
|
+
current["starts_on"] = parse_ics_date(value)
|
|
591
|
+
elif upper_key.startswith("DTEND"):
|
|
592
|
+
current["ends_on"] = parse_ics_date(value)
|
|
593
|
+
elif upper_key == "SUMMARY":
|
|
594
|
+
current["summary"] = value.replace("\\,", ",")
|
|
595
|
+
return events
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def parse_ics_date(value: str) -> str:
|
|
599
|
+
if "T" in value:
|
|
600
|
+
return date.fromisoformat(value[:8]).isoformat()
|
|
601
|
+
return date.fromisoformat(f"{value[:4]}-{value[4:6]}-{value[6:8]}").isoformat()
|