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 ADDED
@@ -0,0 +1,3 @@
1
+ """DreamLoop local-first dream journal."""
2
+
3
+ __version__ = "0.1.0"
dreamloop/analysis.py ADDED
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+ import httpx
10
+
11
+
12
+ DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1"
13
+ DEFAULT_OLLAMA_MODEL = "qwen3:8b"
14
+ DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
15
+ DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash"
16
+ DEFAULT_OPENAI_MODEL = "gpt-4.1-mini"
17
+
18
+ REFLECTION_LABELS = {
19
+ "strongest_emotion": "Dream's strongest emotion",
20
+ "waking_feeling": "Feeling after waking",
21
+ "important_elements": "Most important people, objects, or scenes",
22
+ "real_life_context": "Recent real-life situations that may be related",
23
+ "personal_association": "What this dream makes the dreamer think of",
24
+ }
25
+
26
+
27
+ class Analyzer(Protocol):
28
+ def analyze(
29
+ self,
30
+ content: str,
31
+ language: str = "en",
32
+ reflections: dict[str, str] | None = None,
33
+ ) -> dict[str, Any]:
34
+ """Return structured dream analysis for a dream text."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class AIStatus:
39
+ provider: str
40
+ model: str | None
41
+ base_url: str | None
42
+ mode: str
43
+ ready: bool
44
+ warning: str | None = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class StaticAnalyzer:
49
+ result: dict[str, Any]
50
+
51
+ def analyze(
52
+ self,
53
+ content: str,
54
+ language: str = "en",
55
+ reflections: dict[str, str] | None = None,
56
+ ) -> dict[str, Any]:
57
+ return dict(self.result)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class OpenAICompatibleAnalyzer:
62
+ provider: str
63
+ model: str
64
+ base_url: str
65
+ api_key: str
66
+ response_format: dict[str, str]
67
+
68
+ def analyze(
69
+ self,
70
+ content: str,
71
+ language: str = "en",
72
+ reflections: dict[str, str] | None = None,
73
+ ) -> dict[str, Any]:
74
+ try:
75
+ from openai import OpenAI
76
+ except ImportError as exc:
77
+ raise RuntimeError("Install dreamloop[ai] to enable cloud model analysis.") from exc
78
+
79
+ output_language = "Simplified Chinese" if language == "zh" else "English"
80
+ client = OpenAI(api_key=self.api_key, base_url=self.base_url)
81
+ response = client.chat.completions.create(
82
+ model=self.model,
83
+ messages=[
84
+ {
85
+ "role": "system",
86
+ "content": analysis_system_prompt(language),
87
+ },
88
+ {"role": "user", "content": build_analysis_user_payload(content, reflections or {})},
89
+ ],
90
+ response_format=self.response_format,
91
+ )
92
+ text = response.choices[0].message.content or "{}"
93
+ return json.loads(text)
94
+
95
+
96
+ class DeepSeekAnalyzer(OpenAICompatibleAnalyzer):
97
+ def __init__(
98
+ self,
99
+ api_key: str,
100
+ model: str = DEFAULT_DEEPSEEK_MODEL,
101
+ base_url: str = DEFAULT_DEEPSEEK_BASE_URL,
102
+ ) -> None:
103
+ super().__init__(
104
+ provider="deepseek",
105
+ model=model,
106
+ base_url=base_url,
107
+ api_key=api_key,
108
+ response_format={"type": "json_object"},
109
+ )
110
+
111
+
112
+ class OllamaAnalyzer(OpenAICompatibleAnalyzer):
113
+ def __init__(
114
+ self,
115
+ model: str = DEFAULT_OLLAMA_MODEL,
116
+ base_url: str = DEFAULT_OLLAMA_BASE_URL,
117
+ ) -> None:
118
+ super().__init__(
119
+ provider="ollama",
120
+ model=model,
121
+ base_url=base_url,
122
+ api_key="ollama",
123
+ response_format={"type": "json_object"},
124
+ )
125
+
126
+
127
+ class OpenAIAnalyzer(OpenAICompatibleAnalyzer):
128
+ def __init__(self, model: str = DEFAULT_OPENAI_MODEL, api_key: str | None = None) -> None:
129
+ key = api_key or os.getenv("OPENAI_API_KEY")
130
+ if not key:
131
+ raise RuntimeError("OPENAI_API_KEY is not configured.")
132
+ super().__init__(
133
+ provider="openai",
134
+ model=model,
135
+ base_url="https://api.openai.com/v1",
136
+ api_key=key,
137
+ response_format={"type": "json_object"},
138
+ )
139
+
140
+
141
+ class LegacyResponsesAnalyzer:
142
+ def __init__(self, model: str = DEFAULT_OPENAI_MODEL) -> None:
143
+ self.model = model
144
+
145
+ def analyze(
146
+ self,
147
+ content: str,
148
+ language: str = "en",
149
+ reflections: dict[str, str] | None = None,
150
+ ) -> dict[str, Any]:
151
+ try:
152
+ from openai import OpenAI
153
+ except ImportError as exc:
154
+ raise RuntimeError("Install dreamloop[ai] to enable OpenAI analysis.") from exc
155
+
156
+ if not os.getenv("OPENAI_API_KEY"):
157
+ raise RuntimeError("OPENAI_API_KEY is not configured.")
158
+
159
+ client = OpenAI()
160
+ response = client.responses.create(
161
+ model=self.model,
162
+ input=[
163
+ {
164
+ "role": "system",
165
+ "content": analysis_system_prompt(language),
166
+ },
167
+ {"role": "user", "content": build_analysis_user_payload(content, reflections or {})},
168
+ ],
169
+ )
170
+ text = response.output_text
171
+ return json.loads(text)
172
+
173
+
174
+ def save_ai_config(
175
+ root: str | Path | None = None,
176
+ *,
177
+ provider: str,
178
+ model: str | None = None,
179
+ base_url: str | None = None,
180
+ ) -> Path:
181
+ config = default_ai_config(provider)
182
+ if model:
183
+ config["model"] = model
184
+ if base_url:
185
+ config["base_url"] = base_url
186
+ path = dreamloop_dir(root) / "config.json"
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ path.write_text(json.dumps({"ai": config}, ensure_ascii=False, indent=2), encoding="utf-8")
189
+ ensure_gitignore(root)
190
+ return path
191
+
192
+
193
+ def save_secret(root: str | Path | None, name: str, value: str) -> Path:
194
+ path = dreamloop_dir(root) / "secrets.env"
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ secrets = read_secret_file(path)
197
+ secrets[name] = value
198
+ path.write_text(
199
+ "\n".join(f"{key}={val}" for key, val in sorted(secrets.items())) + "\n",
200
+ encoding="utf-8",
201
+ )
202
+ ensure_gitignore(root)
203
+ return path
204
+
205
+
206
+ def ai_status(root: str | Path | None = None) -> AIStatus:
207
+ config = load_ai_config(root)
208
+ provider = config["provider"]
209
+ secrets = load_secrets(root)
210
+ model = config.get("model")
211
+ base_url = config.get("base_url")
212
+
213
+ if provider == "none":
214
+ return AIStatus("none", None, None, "local", False, "AI analysis disabled.")
215
+ if provider == "ollama":
216
+ return AIStatus("ollama", model, base_url, "local", True, "Ollama optional; capture works if it is offline.")
217
+ if provider == "deepseek":
218
+ ready = bool(secrets.get("DEEPSEEK_API_KEY"))
219
+ warning = None if ready else "DEEPSEEK_API_KEY is not configured."
220
+ return AIStatus("deepseek", model, base_url, "cloud", ready, warning)
221
+ if provider == "openai":
222
+ ready = bool(secrets.get("OPENAI_API_KEY"))
223
+ warning = None if ready else "OPENAI_API_KEY is not configured."
224
+ return AIStatus("openai", model, base_url, "cloud", ready, warning)
225
+ if provider == "custom":
226
+ ready = bool(model and base_url)
227
+ warning = None if ready else "Custom provider needs both model and base URL."
228
+ return AIStatus("custom", model, base_url, "custom", ready, warning)
229
+ return AIStatus(provider, model, base_url, "unknown", False, f"Unknown AI provider: {provider}")
230
+
231
+
232
+ def ai_is_configured(root: str | Path | None = None) -> bool:
233
+ return ai_status(root).ready
234
+
235
+
236
+ def build_analyzer(root: str | Path | None = None) -> Analyzer | None:
237
+ status = ai_status(root)
238
+ if not status.ready:
239
+ return None
240
+ secrets = load_secrets(root)
241
+ if status.provider == "ollama":
242
+ return OllamaAnalyzer(model=status.model or DEFAULT_OLLAMA_MODEL, base_url=status.base_url or DEFAULT_OLLAMA_BASE_URL)
243
+ if status.provider == "deepseek":
244
+ return DeepSeekAnalyzer(
245
+ api_key=secrets["DEEPSEEK_API_KEY"],
246
+ model=status.model or DEFAULT_DEEPSEEK_MODEL,
247
+ base_url=status.base_url or DEFAULT_DEEPSEEK_BASE_URL,
248
+ )
249
+ if status.provider == "openai":
250
+ return OpenAIAnalyzer(model=status.model or DEFAULT_OPENAI_MODEL, api_key=secrets["OPENAI_API_KEY"])
251
+ if status.provider == "custom":
252
+ return OpenAICompatibleAnalyzer(
253
+ provider="custom",
254
+ model=status.model or "model",
255
+ base_url=status.base_url or "",
256
+ api_key=secrets.get("CUSTOM_API_KEY") or "local",
257
+ response_format={"type": "json_object"},
258
+ )
259
+ return None
260
+
261
+
262
+ def test_provider_connection(root: str | Path | None = None) -> str:
263
+ status = ai_status(root)
264
+ if not status.ready:
265
+ return status.warning or "AI provider is not ready."
266
+ if status.provider == "ollama":
267
+ url = (status.base_url or DEFAULT_OLLAMA_BASE_URL).rstrip("/").removesuffix("/v1")
268
+ try:
269
+ response = httpx.get(f"{url}/api/tags", timeout=2)
270
+ response.raise_for_status()
271
+ except httpx.HTTPError as exc:
272
+ return f"Ollama configured, but local server is not responding: {exc}"
273
+ return f"Ollama ready at {status.base_url}"
274
+ return f"{status.provider} configured with model {status.model}"
275
+
276
+
277
+ def load_ai_config(root: str | Path | None = None) -> dict[str, str]:
278
+ path = dreamloop_dir(root) / "config.json"
279
+ if not path.exists():
280
+ return default_ai_config("ollama")
281
+ payload = json.loads(path.read_text(encoding="utf-8"))
282
+ provider = payload.get("ai", {}).get("provider", "ollama")
283
+ config = default_ai_config(provider)
284
+ config.update({key: val for key, val in payload.get("ai", {}).items() if val is not None})
285
+ return config
286
+
287
+
288
+ def default_ai_config(provider: str) -> dict[str, str]:
289
+ if provider == "deepseek":
290
+ return {"provider": "deepseek", "model": DEFAULT_DEEPSEEK_MODEL, "base_url": DEFAULT_DEEPSEEK_BASE_URL}
291
+ if provider == "openai":
292
+ return {"provider": "openai", "model": DEFAULT_OPENAI_MODEL, "base_url": "https://api.openai.com/v1"}
293
+ if provider == "none":
294
+ return {"provider": "none", "model": "", "base_url": ""}
295
+ if provider == "custom":
296
+ return {"provider": "custom", "model": "", "base_url": ""}
297
+ return {"provider": "ollama", "model": DEFAULT_OLLAMA_MODEL, "base_url": DEFAULT_OLLAMA_BASE_URL}
298
+
299
+
300
+ def load_secrets(root: str | Path | None = None) -> dict[str, str]:
301
+ secrets = read_secret_file(dreamloop_dir(root) / "secrets.env")
302
+ for name in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CUSTOM_API_KEY"):
303
+ if os.getenv(name):
304
+ secrets[name] = os.environ[name]
305
+ return secrets
306
+
307
+
308
+ def read_secret_file(path: Path) -> dict[str, str]:
309
+ if not path.exists():
310
+ return {}
311
+ secrets: dict[str, str] = {}
312
+ for line in path.read_text(encoding="utf-8").splitlines():
313
+ if not line.strip() or line.strip().startswith("#") or "=" not in line:
314
+ continue
315
+ key, value = line.split("=", 1)
316
+ secrets[key.strip().lstrip("\ufeff")] = value.strip()
317
+ return secrets
318
+
319
+
320
+ def clean_reflections(reflections: dict[str, Any] | None) -> dict[str, str]:
321
+ if not reflections:
322
+ return {}
323
+ cleaned: dict[str, str] = {}
324
+ for key in REFLECTION_LABELS:
325
+ value = reflections.get(key)
326
+ if value is None:
327
+ continue
328
+ text = str(value).strip()
329
+ if text:
330
+ cleaned[key] = text
331
+ return cleaned
332
+
333
+
334
+ def build_analysis_user_payload(content: str, reflections: dict[str, Any] | None = None) -> str:
335
+ cleaned = clean_reflections(reflections)
336
+ lines = ["梦境内容 / Dream content:", content.strip()]
337
+ if cleaned:
338
+ lines.append("")
339
+ lines.append("用户可选补充 / Optional dreamer context:")
340
+ for key, value in cleaned.items():
341
+ lines.append(f"{key}: {value}")
342
+ return "\n".join(lines)
343
+
344
+
345
+ def analysis_system_prompt(language: str = "en") -> str:
346
+ output_language = "Simplified Chinese" if language == "zh" else "English"
347
+ length_hint = "800-1500 Chinese characters" if language == "zh" else "900-1600 English words"
348
+ zh_requirements = (
349
+ "不要把梦说死。不要用玄学断言。必须回到梦境具体细节,重视情绪多于物品符号,"
350
+ "并联系用户提供的现实处境。至少 2 个 possible_interpretations。"
351
+ )
352
+ return (
353
+ "You are DreamLoop's dream analysis engine. Return only valid JSON. "
354
+ "Keep JSON keys in English and write all field values in "
355
+ f"{output_language}. Produce a detailed, reality-grounded report of about {length_hint}. "
356
+ f"{zh_requirements} "
357
+ "The analysis must be specific to the dream text, not a generic template. "
358
+ "Prefer emotional dynamics and recent life context over mystical symbolism. "
359
+ "Offer multiple hypotheses and make each one verifiable by the dreamer. "
360
+ "Never claim certainty, diagnose, predict the future, or say one dream proves something. "
361
+ "Return this schema: analysis_version, emotional_tone, symbols, themes, summary, confidence, "
362
+ "dream_details, core_emotion, waking_feeling, important_elements, real_life_links, "
363
+ "personal_associations, possible_interpretations, real_life_questions, verification_prompts. "
364
+ "possible_interpretations must contain at least 2 objects with title, interpretation, "
365
+ "dream_evidence, real_life_connection, and verification_question. "
366
+ "real_life_questions should focus on what reality problem the dream may help the user notice."
367
+ )
368
+
369
+
370
+ def dreamloop_dir(root: str | Path | None = None) -> Path:
371
+ return Path(root or Path.cwd()) / ".dreamloop"
372
+
373
+
374
+ def ensure_gitignore(root: str | Path | None = None) -> None:
375
+ root_path = Path(root or Path.cwd())
376
+ gitignore = root_path / ".gitignore"
377
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
378
+ if ".dreamloop/" in existing.splitlines():
379
+ return
380
+ prefix = "" if not existing or existing.endswith("\n") else "\n"
381
+ gitignore.write_text(existing + prefix + ".dreamloop/\n", encoding="utf-8")
382
+
383
+
384
+ def normalize_analysis(result: dict[str, Any]) -> dict[str, Any]:
385
+ symbols = result.get("symbols") or []
386
+ themes = result.get("themes") or []
387
+ if isinstance(symbols, str):
388
+ symbols = [symbols]
389
+ if isinstance(themes, str):
390
+ themes = [themes]
391
+
392
+ confidence = result.get("confidence", 0.0)
393
+ try:
394
+ confidence_value = float(confidence)
395
+ except (TypeError, ValueError):
396
+ confidence_value = 0.0
397
+
398
+ return {
399
+ "emotional_tone": str(result.get("emotional_tone") or "unknown"),
400
+ "symbols": [str(item) for item in symbols],
401
+ "themes": [str(item) for item in themes],
402
+ "summary": str(result.get("summary") or ""),
403
+ "confidence": max(0.0, min(confidence_value, 1.0)),
404
+ "report": result,
405
+ "raw_json": json.dumps(result, ensure_ascii=False),
406
+ }
dreamloop/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from .analysis import ai_status, save_ai_config, test_provider_connection
11
+ from .core import DreamLoop
12
+
13
+ app = typer.Typer(help="Local-first AI dream journal.")
14
+ import_app = typer.Typer(help="Import local data.")
15
+ weather_app = typer.Typer(help="Sync local context such as weather.")
16
+ ai_app = typer.Typer(help="Configure local and optional cloud AI providers.")
17
+ app.add_typer(import_app, name="import")
18
+ app.add_typer(weather_app, name="weather")
19
+ app.add_typer(ai_app, name="ai")
20
+
21
+
22
+ @app.command()
23
+ def init() -> None:
24
+ loop = DreamLoop()
25
+ loop.init()
26
+ typer.echo(f"DreamLoop initialized at {loop.data_dir}")
27
+
28
+
29
+ @app.command()
30
+ def add(
31
+ content: Annotated[str, typer.Argument(help="Dream text to save.")],
32
+ tag: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
33
+ mood: Annotated[str | None, typer.Option("--mood", "-m")] = None,
34
+ dreamed_on: Annotated[str | None, typer.Option("--date", help="Dream date as YYYY-MM-DD.")] = None,
35
+ ) -> None:
36
+ parsed_date = date.fromisoformat(dreamed_on) if dreamed_on else None
37
+ dream_id = DreamLoop().add_dream(content, tags=tag or [], mood=mood, dreamed_on=parsed_date)
38
+ typer.echo(f"Saved dream #{dream_id} (analysis pending)")
39
+
40
+
41
+ @app.command("list")
42
+ def list_dreams() -> None:
43
+ dreams = DreamLoop().list_dreams()
44
+ for dream in dreams:
45
+ tags = ", ".join(dream["tags"]) or "no tags"
46
+ mood = dream["manual_mood"] or "no mood"
47
+ typer.echo(f"#{dream['id']} {dream['dreamed_on']} [{mood}] {tags} - {dream['content']}")
48
+
49
+
50
+ @app.command()
51
+ def show(dream_id: int) -> None:
52
+ dream = DreamLoop().get_dream(dream_id)
53
+ typer.echo(json.dumps(dream, ensure_ascii=False, indent=2))
54
+
55
+
56
+ @app.command()
57
+ def analyze(pending: Annotated[bool, typer.Option("--pending")] = False) -> None:
58
+ if not pending:
59
+ typer.echo("Use --pending to analyze queued dreams.")
60
+ raise typer.Exit(code=1)
61
+ status = ai_status()
62
+ if not status.ready:
63
+ typer.echo(status.warning or "AI provider is not ready. Keep using DreamLoop as a local journal.")
64
+ return
65
+ analyzed = DreamLoop().analyze_pending()
66
+ typer.echo(f"Analyzed {len(analyzed)} pending dream(s).")
67
+
68
+
69
+ @app.command()
70
+ def web(host: str = "127.0.0.1", port: int = 8765) -> None:
71
+ import uvicorn
72
+
73
+ uvicorn.run("dreamloop.web:create_app", factory=True, host=host, port=port)
74
+
75
+
76
+ @app.command()
77
+ def export() -> None:
78
+ loop = DreamLoop()
79
+ out = loop.data_dir / "exports" / f"dreamloop-export-{date.today().isoformat()}.json"
80
+ loop.init()
81
+ out.write_text(json.dumps(loop.list_dreams(), ensure_ascii=False, indent=2), encoding="utf-8")
82
+ typer.echo(f"Exported dreams to {out}")
83
+
84
+
85
+ @import_app.command("ics")
86
+ def import_ics(path: Path) -> None:
87
+ count = DreamLoop().import_ics(path)
88
+ typer.echo(f"Imported {count} calendar event(s).")
89
+
90
+
91
+ @weather_app.command("sync")
92
+ def weather_sync(lat: float, lon: float) -> None:
93
+ count = DreamLoop().sync_weather(lat, lon)
94
+ typer.echo(f"Synced {count} weather day(s).")
95
+
96
+
97
+ @ai_app.command("status")
98
+ def ai_status_command() -> None:
99
+ status = ai_status()
100
+ typer.echo(f"provider: {status.provider}")
101
+ typer.echo(f"model: {status.model or 'none'}")
102
+ typer.echo(f"mode: {status.mode}")
103
+ typer.echo(f"base_url: {status.base_url or 'none'}")
104
+ typer.echo(f"ready: {status.ready}")
105
+ if status.warning:
106
+ typer.echo(f"warning: {status.warning}")
107
+
108
+
109
+ @ai_app.command("use")
110
+ def ai_use(
111
+ provider: Annotated[str, typer.Argument(help="Provider: ollama, deepseek, openai, custom, or none.")],
112
+ model: Annotated[str | None, typer.Option("--model")] = None,
113
+ base_url: Annotated[str | None, typer.Option("--base-url")] = None,
114
+ ) -> None:
115
+ if provider not in {"ollama", "deepseek", "openai", "custom", "none"}:
116
+ typer.echo("Provider must be one of: ollama, deepseek, openai, custom, none.")
117
+ raise typer.Exit(code=1)
118
+ path = save_ai_config(provider=provider, model=model, base_url=base_url)
119
+ status = ai_status()
120
+ typer.echo(f"AI provider set to {status.provider} ({status.model or 'no model'}).")
121
+ typer.echo(f"Config saved to {path}")
122
+
123
+
124
+ @ai_app.command("test")
125
+ def ai_test() -> None:
126
+ typer.echo(test_provider_connection())
127
+
128
+
129
+ def main() -> None:
130
+ app()