decision-journal 0.10.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.
- backend/config.py +217 -0
- backend/db.py +394 -0
- backend/main.py +130 -0
- backend/models/schemas.py +230 -0
- backend/routes/archive.py +36 -0
- backend/routes/chat.py +216 -0
- backend/routes/config_api.py +90 -0
- backend/routes/decision.py +117 -0
- backend/routes/modes.py +18 -0
- backend/routes/stats.py +103 -0
- backend/routes/tts.py +131 -0
- backend/services/bazi_engine.py +262 -0
- backend/services/decision_score.py +113 -0
- backend/services/llm_service.py +771 -0
- backend/services/mode_recognizer.py +116 -0
- backend/services/modes_data.py +41 -0
- backend/services/nature_service.py +245 -0
- backend/services/nature_signal.py +150 -0
- backend/services/prompts.py +202 -0
- backend/services/weather_service.py +290 -0
- decision_journal-0.10.0.dist-info/METADATA +503 -0
- decision_journal-0.10.0.dist-info/RECORD +57 -0
- decision_journal-0.10.0.dist-info/WHEEL +5 -0
- decision_journal-0.10.0.dist-info/entry_points.txt +2 -0
- decision_journal-0.10.0.dist-info/licenses/LICENSE +21 -0
- decision_journal-0.10.0.dist-info/top_level.txt +3 -0
- frontend/__init__.py +5 -0
- frontend/assets/logo-nav.png +0 -0
- frontend/index.html +308 -0
- frontend/random-effects-preview.html +39 -0
- frontend/scripts/api.js +186 -0
- frontend/scripts/app.js +388 -0
- frontend/scripts/archive.js +277 -0
- frontend/scripts/brief.js +818 -0
- frontend/scripts/chat.js +298 -0
- frontend/scripts/i18n.js +1289 -0
- frontend/scripts/motion.js +89 -0
- frontend/scripts/random-preview.js +67 -0
- frontend/scripts/settings.js +542 -0
- frontend/scripts/stats.js +165 -0
- frontend/scripts/voice.js +310 -0
- frontend/styles/archive.css +262 -0
- frontend/styles/chat.css +1318 -0
- frontend/styles/desktop.css +936 -0
- frontend/styles/main.css +524 -0
- frontend/styles/motion.css +430 -0
- frontend/styles/random-preview.css +183 -0
- frontend/styles/settings.css +540 -0
- frontend/styles/skins.css +400 -0
- frontend/styles/stats.css +182 -0
- frontend/vendor/zdog.LICENSE.txt +21 -0
- frontend/vendor/zdog.dist.min.js +8 -0
- scripts/__init__.py +1 -0
- scripts/decision_journal.py +832 -0
- scripts/package_skill.py +90 -0
- scripts/sync_skill_package.py +61 -0
- scripts/validate_package.py +131 -0
backend/config.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""配置管理(三层优先级)。
|
|
2
|
+
|
|
3
|
+
按优先级从高到低:
|
|
4
|
+
1. 环境变量:DECISION_JOURNAL_LLM_API_KEY / DECISION_JOURNAL_LLM_MODEL / DECISION_JOURNAL_LLM_BASE_URL
|
|
5
|
+
DECISION_JOURNAL_WEATHER_KEY / DECISION_JOURNAL_WEATHER_BASE_URL / DECISION_JOURNAL_WEATHER_CITY
|
|
6
|
+
2. SQLite config 表(UI 设置页写入,落盘到 decision_journal.db)
|
|
7
|
+
3. ~/.decision-journal/config.json(CLI config --save 写入,权限 0600,兼容旧版)
|
|
8
|
+
|
|
9
|
+
约束:
|
|
10
|
+
- API Key 不可写入日志。
|
|
11
|
+
- 回显配置时对 llm_api_key / weather_key 脱敏为 ***已配置***。
|
|
12
|
+
- ponytail: SQLite 中 API Key 明文存储,升级路径为 AES-256-GCM 加密。
|
|
13
|
+
|
|
14
|
+
天气服务从 v0.7.0 起从和风天气切换到高德开放平台,配置字段:
|
|
15
|
+
- 主字段:weather_key(高德 Key) + weather_base_url + weather_city
|
|
16
|
+
- 兼容字段:weather_appsecret(旧版和风 Key,读取时自动当作 weather_key)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict
|
|
23
|
+
|
|
24
|
+
# 配置文件路径(与 CLI 共用)
|
|
25
|
+
# 可用 DECISION_JOURNAL_CONFIG_FILE 覆盖(测试/便携模式隔离用,避免读取真实用户配置)
|
|
26
|
+
CONFIG_FILE_PATH = Path(
|
|
27
|
+
os.environ.get("DECISION_JOURNAL_CONFIG_FILE")
|
|
28
|
+
or (Path.home() / ".decision-journal" / "config.json")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# 配置键名
|
|
32
|
+
CONFIG_KEYS = (
|
|
33
|
+
"llm_api_key",
|
|
34
|
+
"llm_model",
|
|
35
|
+
"llm_base_url",
|
|
36
|
+
"weather_key", # 主字段(高德 Key)
|
|
37
|
+
"weather_base_url", # 高德天气接口地址(用户自配)
|
|
38
|
+
"weather_appsecret", # 兼容字段(旧和风 Key,自动映射到 weather_key)
|
|
39
|
+
"weather_city",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# 环境变量映射
|
|
43
|
+
ENV_KEY_MAP = {
|
|
44
|
+
"llm_api_key": "DECISION_JOURNAL_LLM_API_KEY",
|
|
45
|
+
"llm_model": "DECISION_JOURNAL_LLM_MODEL",
|
|
46
|
+
"llm_base_url": "DECISION_JOURNAL_LLM_BASE_URL",
|
|
47
|
+
"weather_key": "DECISION_JOURNAL_WEATHER_KEY",
|
|
48
|
+
"weather_base_url": "DECISION_JOURNAL_WEATHER_BASE_URL",
|
|
49
|
+
"weather_appsecret": "DECISION_JOURNAL_WEATHER_APPSECRET", # 兼容旧变量名
|
|
50
|
+
"weather_city": "DECISION_JOURNAL_WEATHER_CITY",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# 敏感键(回显时脱敏)
|
|
54
|
+
SENSITIVE_KEYS = ("llm_api_key", "weather_key", "weather_appsecret")
|
|
55
|
+
|
|
56
|
+
def _read_config_file() -> Dict[str, Any]:
|
|
57
|
+
"""从 ~/.decision-journal/config.json 读取(不存在或解析失败返回空 dict)。"""
|
|
58
|
+
if not CONFIG_FILE_PATH.exists():
|
|
59
|
+
return {}
|
|
60
|
+
try:
|
|
61
|
+
with open(CONFIG_FILE_PATH, "r", encoding="utf-8") as f:
|
|
62
|
+
data = json.load(f)
|
|
63
|
+
return data if isinstance(data, dict) else {}
|
|
64
|
+
except (json.JSONDecodeError, OSError):
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _save_config_file(partial: Dict[str, Any]) -> Dict[str, Any]:
|
|
69
|
+
"""合并写入 ~/.decision-journal/config.json(权限 0600)。"""
|
|
70
|
+
CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
merged = dict(_read_config_file())
|
|
72
|
+
for k, v in partial.items():
|
|
73
|
+
if v is not None:
|
|
74
|
+
merged[k] = v
|
|
75
|
+
with open(CONFIG_FILE_PATH, "w", encoding="utf-8") as f:
|
|
76
|
+
json.dump(merged, f, ensure_ascii=False, indent=2)
|
|
77
|
+
try:
|
|
78
|
+
os.chmod(CONFIG_FILE_PATH, 0o600)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass # Windows best-effort
|
|
81
|
+
return merged
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_effective_config() -> Dict[str, Any]:
|
|
85
|
+
"""返回当前生效的 API Key 配置(环境变量 > SQLite > config.json)。
|
|
86
|
+
|
|
87
|
+
注意:返回值含 api_key,调用方不得写入日志。
|
|
88
|
+
"""
|
|
89
|
+
# 从 SQLite 读取(延迟导入避免循环依赖)
|
|
90
|
+
from db import get_all_config
|
|
91
|
+
|
|
92
|
+
cfg: Dict[str, Any] = {k: "" for k in CONFIG_KEYS}
|
|
93
|
+
|
|
94
|
+
# 层 3:config.json
|
|
95
|
+
file_cfg = _read_config_file()
|
|
96
|
+
for k in CONFIG_KEYS:
|
|
97
|
+
if file_cfg.get(k):
|
|
98
|
+
cfg[k] = file_cfg[k]
|
|
99
|
+
|
|
100
|
+
# 层 2:SQLite config 表(覆盖 config.json)
|
|
101
|
+
db_cfg = get_all_config()
|
|
102
|
+
for k in CONFIG_KEYS:
|
|
103
|
+
if db_cfg.get(k):
|
|
104
|
+
cfg[k] = db_cfg[k]
|
|
105
|
+
|
|
106
|
+
# 层 1:环境变量(最高优先级)
|
|
107
|
+
for k, env in ENV_KEY_MAP.items():
|
|
108
|
+
val = os.environ.get(env)
|
|
109
|
+
if val:
|
|
110
|
+
cfg[k] = val
|
|
111
|
+
|
|
112
|
+
return cfg
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_masked_config() -> Dict[str, Any]:
|
|
116
|
+
"""返回脱敏后的配置(用于 UI 显示和 CLI 回显)。"""
|
|
117
|
+
cfg = get_effective_config()
|
|
118
|
+
for k in SENSITIVE_KEYS:
|
|
119
|
+
if cfg.get(k):
|
|
120
|
+
cfg[k] = "***已配置***"
|
|
121
|
+
return cfg
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_api_keys_to_db(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
125
|
+
"""把 API Key 配置保存到 SQLite config 表(UI 设置页用)。
|
|
126
|
+
|
|
127
|
+
值为 None 或空字符串的键会被跳过。
|
|
128
|
+
"""
|
|
129
|
+
from db import set_config_value
|
|
130
|
+
|
|
131
|
+
for k in CONFIG_KEYS:
|
|
132
|
+
val = config.get(k)
|
|
133
|
+
if val:
|
|
134
|
+
set_config_value(k, val)
|
|
135
|
+
return get_masked_config()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def save_api_keys_to_file(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
139
|
+
"""把 API Key 配置保存到 ~/.decision-journal/config.json(CLI config --save 用)。"""
|
|
140
|
+
partial = {k: v for k, v in config.items() if k in CONFIG_KEYS and v is not None}
|
|
141
|
+
merged = _save_config_file(partial)
|
|
142
|
+
masked = dict(merged)
|
|
143
|
+
for k in SENSITIVE_KEYS:
|
|
144
|
+
if masked.get(k):
|
|
145
|
+
masked[k] = "***已配置***"
|
|
146
|
+
return masked
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def has_llm_config(config: Dict[str, Any] = None) -> bool:
|
|
150
|
+
"""判断是否具备真实调用 LLM 的最小条件。"""
|
|
151
|
+
cfg = config or get_effective_config()
|
|
152
|
+
return bool(cfg.get("llm_api_key") and cfg.get("llm_base_url") and cfg.get("llm_model"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def has_weather_config(config: Dict[str, Any] = None) -> bool:
|
|
156
|
+
"""判断是否具备真实调用天气 API 的最小条件。
|
|
157
|
+
|
|
158
|
+
开源版要求用户同时提供 weather_key 和 weather_base_url;city 可选,默认北京。
|
|
159
|
+
兼容旧版:weather_appsecret 也会被当作 key 使用。
|
|
160
|
+
"""
|
|
161
|
+
cfg = config or get_effective_config()
|
|
162
|
+
return bool((cfg.get("weather_key") or cfg.get("weather_appsecret")) and cfg.get("weather_base_url"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ─── 用户偏好(preferences)─────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
PREF_KEYS = (
|
|
168
|
+
"language", # 'zh-CN' / 'yue' / 'en' / 'fr' / 'ja' / 'es'
|
|
169
|
+
"default_mode", # 'auto' / 'rational' / 'random' / 'nature' / 'dialogue' / 'fengshui'
|
|
170
|
+
"theme", # 'light' / 'dark' / 'auto'
|
|
171
|
+
"skin", # 'heritage' / 'workbench' / 'journal' / 'console'
|
|
172
|
+
"logo", # Logo id
|
|
173
|
+
"auto_speak", # bool
|
|
174
|
+
"tts_rate", # float 0.5-1.5
|
|
175
|
+
"tts_pitch", # float 0.5-1.5
|
|
176
|
+
"tts_voice_uri", # string
|
|
177
|
+
"values", # {efficiency, risk, growth, relationship} 0-100
|
|
178
|
+
"demo_mode", # bool: 无 API Key 时是否使用 mock 演示数据
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
DEFAULT_PREFS: Dict[str, Any] = {
|
|
182
|
+
"language": "zh-CN",
|
|
183
|
+
"default_mode": "auto",
|
|
184
|
+
"theme": "auto",
|
|
185
|
+
"skin": "heritage",
|
|
186
|
+
"logo": "tree1",
|
|
187
|
+
"auto_speak": False, # 隐私默认:不自动把决策内容发往 TTS
|
|
188
|
+
"tts_rate": 0.95,
|
|
189
|
+
"tts_pitch": 1.05,
|
|
190
|
+
"tts_voice_uri": "zh-CN-XiaoxiaoNeural",
|
|
191
|
+
"values": {"efficiency": 60, "risk": 50, "growth": 70, "relationship": 55},
|
|
192
|
+
"demo_mode": False,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_preferences() -> Dict[str, Any]:
|
|
197
|
+
"""读取用户偏好(合并默认值 + SQLite)。空字符串值视为未设置,回退默认。"""
|
|
198
|
+
from db import get_config_value
|
|
199
|
+
|
|
200
|
+
prefs = dict(DEFAULT_PREFS)
|
|
201
|
+
stored = get_config_value("preferences", {})
|
|
202
|
+
if isinstance(stored, dict):
|
|
203
|
+
for k, v in stored.items():
|
|
204
|
+
if v == "" and k in DEFAULT_PREFS and DEFAULT_PREFS[k]:
|
|
205
|
+
continue
|
|
206
|
+
prefs[k] = v
|
|
207
|
+
return prefs
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def save_preferences(prefs: Dict[str, Any]) -> Dict[str, Any]:
|
|
211
|
+
"""保存用户偏用到 SQLite。"""
|
|
212
|
+
from db import set_config_value
|
|
213
|
+
|
|
214
|
+
current = get_preferences()
|
|
215
|
+
current.update({k: v for k, v in prefs.items() if k in PREF_KEYS})
|
|
216
|
+
set_config_value("preferences", current)
|
|
217
|
+
return current
|
backend/db.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""SQLite 持久化层。
|
|
2
|
+
|
|
3
|
+
替代 HTML 版本的 IndexedDB,提供 decisions 和 config 两张表。
|
|
4
|
+
单文件 SQLite,无需服务器,适合本地单机工具。
|
|
5
|
+
|
|
6
|
+
表结构:
|
|
7
|
+
- decisions: 决策记录(id/question/mode/result/brief/createdAt/executed/regret/dialogueHistory)
|
|
8
|
+
- config: 配置项(key/value),存储 LLM/天气 API Key 和用户偏好
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
# SQLite 数据库文件路径(与 backend/ 同级,gitignore 已忽略 *.db)
|
|
19
|
+
# 可用环境变量 DECISION_JOURNAL_DB_PATH 覆盖(测试/多实例隔离用)
|
|
20
|
+
DB_PATH = Path(os.environ.get("DECISION_JOURNAL_DB_PATH") or (Path(__file__).parent / "decision_journal.db"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_conn() -> sqlite3.Connection:
|
|
24
|
+
"""获取 SQLite 连接。每次调用创建新连接,避免线程问题。"""
|
|
25
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
26
|
+
conn.row_factory = sqlite3.Row
|
|
27
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
28
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
29
|
+
return conn
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
33
|
+
"""表是否存在(不依赖 sqlite_master 的大小写)。"""
|
|
34
|
+
row = conn.execute(
|
|
35
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
36
|
+
(table,),
|
|
37
|
+
).fetchone()
|
|
38
|
+
return row is not None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _table_count(conn: sqlite3.Connection, table: str) -> int:
|
|
42
|
+
"""表行数;表不存在时返回 0。"""
|
|
43
|
+
if not _table_exists(conn, table):
|
|
44
|
+
return 0
|
|
45
|
+
row = conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()
|
|
46
|
+
return int(row[0]) if row else 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
50
|
+
"""创建 decisions / config 表与索引(幂等)。"""
|
|
51
|
+
conn.executescript(
|
|
52
|
+
"""
|
|
53
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
question TEXT NOT NULL,
|
|
56
|
+
mode TEXT NOT NULL,
|
|
57
|
+
result TEXT NOT NULL,
|
|
58
|
+
brief TEXT,
|
|
59
|
+
created_at TEXT NOT NULL,
|
|
60
|
+
executed INTEGER DEFAULT 0,
|
|
61
|
+
regret INTEGER DEFAULT 0,
|
|
62
|
+
dialogue_history TEXT
|
|
63
|
+
);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_created_at ON decisions(created_at);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_mode ON decisions(mode);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
68
|
+
key TEXT PRIMARY KEY,
|
|
69
|
+
value TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
"""
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# 当前 schema 的 decisions 全部列;旧库缺任何一列时不走整体 backup,
|
|
76
|
+
# 而是先建新 schema 再按列合并,避免复制出一个后续写入会失败的老表。
|
|
77
|
+
_DECISION_COLUMNS = {
|
|
78
|
+
"id", "question", "mode", "result", "brief", "created_at",
|
|
79
|
+
"executed", "regret", "dialogue_history",
|
|
80
|
+
}
|
|
81
|
+
_REQUIRED_DECISION_COLUMNS = {"id", "question", "mode", "result", "created_at"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _decision_columns(conn: sqlite3.Connection) -> set:
|
|
85
|
+
return {
|
|
86
|
+
str(row[1])
|
|
87
|
+
for row in conn.execute("PRAGMA table_info(decisions)").fetchall()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _legacy_decision_rows(conn: sqlite3.Connection) -> list:
|
|
92
|
+
"""读取旧库 decisions 行;兼容缺少可选列的老库,跳过不可用的行。
|
|
93
|
+
|
|
94
|
+
返回顺序与 decisions 表列顺序一致:
|
|
95
|
+
(id, question, mode, result, brief, created_at, executed, regret, dialogue_history)
|
|
96
|
+
"""
|
|
97
|
+
columns = _decision_columns(conn)
|
|
98
|
+
if not _REQUIRED_DECISION_COLUMNS.issubset(columns):
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
def col(name: str, default: str) -> str:
|
|
102
|
+
return name if name in columns else default
|
|
103
|
+
|
|
104
|
+
query = (
|
|
105
|
+
"SELECT "
|
|
106
|
+
+ ", ".join(
|
|
107
|
+
(
|
|
108
|
+
col("id", "NULL"),
|
|
109
|
+
col("question", "NULL"),
|
|
110
|
+
col("mode", "NULL"),
|
|
111
|
+
col("result", "'{}'"),
|
|
112
|
+
col("brief", "NULL"),
|
|
113
|
+
col("created_at", "NULL"),
|
|
114
|
+
col("executed", "0"),
|
|
115
|
+
col("regret", "0"),
|
|
116
|
+
col("dialogue_history", "NULL"),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
+ " FROM decisions"
|
|
120
|
+
)
|
|
121
|
+
rows = []
|
|
122
|
+
for row in conn.execute(query).fetchall():
|
|
123
|
+
if not row[0] or row[1] is None or not row[2] or row[3] is None or not row[5]:
|
|
124
|
+
continue
|
|
125
|
+
rows.append(tuple(row))
|
|
126
|
+
return rows
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _backup_legacy(legacy_conn: sqlite3.Connection, new_conn: sqlite3.Connection) -> None:
|
|
130
|
+
"""用 SQLite backup API 整体复制(含未 checkpoint 的 WAL 数据)。"""
|
|
131
|
+
legacy_conn.backup(new_conn)
|
|
132
|
+
new_conn.commit()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _merge_legacy_into_new(legacy_conn: sqlite3.Connection, new_conn: sqlite3.Connection) -> bool:
|
|
136
|
+
"""把旧库 decisions/config 合并进新库,不覆盖新库已有配置。
|
|
137
|
+
|
|
138
|
+
- BEGIN IMMEDIATE 让并发启动的实例串行化;进入事务后重新检查新库是否已有决策;
|
|
139
|
+
- decisions 用 INSERT OR IGNORE,按主键幂等;
|
|
140
|
+
- config 只补新库缺失的键,绝不覆盖已有 Key/偏好;
|
|
141
|
+
- 单事务提交,失败整体回滚。
|
|
142
|
+
"""
|
|
143
|
+
_ensure_schema(new_conn)
|
|
144
|
+
new_conn.execute("BEGIN IMMEDIATE")
|
|
145
|
+
try:
|
|
146
|
+
if _table_count(new_conn, "decisions") > 0:
|
|
147
|
+
new_conn.rollback()
|
|
148
|
+
return False
|
|
149
|
+
for row in _legacy_decision_rows(legacy_conn):
|
|
150
|
+
new_conn.execute(
|
|
151
|
+
"""
|
|
152
|
+
INSERT OR IGNORE INTO decisions
|
|
153
|
+
(id, question, mode, result, brief, created_at, executed, regret, dialogue_history)
|
|
154
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
155
|
+
""",
|
|
156
|
+
row,
|
|
157
|
+
)
|
|
158
|
+
if _table_exists(legacy_conn, "config"):
|
|
159
|
+
for row in legacy_conn.execute("SELECT key, value FROM config").fetchall():
|
|
160
|
+
if row[0] is None or row[1] is None:
|
|
161
|
+
continue
|
|
162
|
+
new_conn.execute(
|
|
163
|
+
"INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)",
|
|
164
|
+
(row[0], row[1]),
|
|
165
|
+
)
|
|
166
|
+
new_conn.commit()
|
|
167
|
+
return True
|
|
168
|
+
except Exception:
|
|
169
|
+
new_conn.rollback()
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _migrate_legacy_db() -> None:
|
|
174
|
+
"""v0.10.0 更名迁移:把旧 choice.db 的数据接管到 decision_journal.db。
|
|
175
|
+
|
|
176
|
+
安全边界:
|
|
177
|
+
- 旧库不存在、已标记 migrated、不是本项目的库(没有 decisions 表)时不动;
|
|
178
|
+
- 新库已有决策时绝不覆盖、也不打标记,保留旧库供用户手动检查;
|
|
179
|
+
- 新库文件全新且为空时用 backup API 整体复制(连同 WAL);
|
|
180
|
+
- 新库已存在但为空、或只含配置时,事务内只补 decisions 与缺失的 config 键,
|
|
181
|
+
保留新库已有 Key/偏好与未知表,并让并发启动串行化;
|
|
182
|
+
- 成功后把旧库改名为 choice.db.migrated;改名失败保留旧库,重复调用幂等;
|
|
183
|
+
- 任何失败静默跳过并回滚,不阻塞启动。
|
|
184
|
+
"""
|
|
185
|
+
legacy = DB_PATH.parent / "choice.db" # 跟随 DB_PATH 所在目录(测试隔离时指向临时目录)
|
|
186
|
+
marker = DB_PATH.parent / "choice.db.migrated"
|
|
187
|
+
if not legacy.exists() or marker.exists():
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
new_existed = DB_PATH.exists()
|
|
191
|
+
legacy_conn = None
|
|
192
|
+
new_conn = None
|
|
193
|
+
adopted = False
|
|
194
|
+
try:
|
|
195
|
+
legacy_conn = sqlite3.connect(str(legacy))
|
|
196
|
+
# 只接管本项目的旧库;别的同名 SQLite 文件原样保留。
|
|
197
|
+
if not _table_exists(legacy_conn, "decisions"):
|
|
198
|
+
return
|
|
199
|
+
new_conn = sqlite3.connect(str(DB_PATH))
|
|
200
|
+
new_decisions = _table_count(new_conn, "decisions")
|
|
201
|
+
new_config = _table_count(new_conn, "config")
|
|
202
|
+
|
|
203
|
+
if new_decisions > 0:
|
|
204
|
+
return # 新库已有决策:不覆盖、不标记、不动旧库
|
|
205
|
+
legacy_has_full_schema = _DECISION_COLUMNS.issubset(_decision_columns(legacy_conn))
|
|
206
|
+
if not new_existed and new_config == 0 and legacy_has_full_schema:
|
|
207
|
+
# 全新数据库且旧 schema 完整:整体复制最完整,backup API 会带上 WAL。
|
|
208
|
+
_backup_legacy(legacy_conn, new_conn)
|
|
209
|
+
adopted = True
|
|
210
|
+
else:
|
|
211
|
+
# 已存在/仅配置/旧 schema 缺列:新建 schema 后幂等合并,保留新库配置。
|
|
212
|
+
adopted = _merge_legacy_into_new(legacy_conn, new_conn)
|
|
213
|
+
except (sqlite3.Error, OSError):
|
|
214
|
+
adopted = False
|
|
215
|
+
finally:
|
|
216
|
+
for conn in (legacy_conn, new_conn):
|
|
217
|
+
if conn is not None:
|
|
218
|
+
try:
|
|
219
|
+
conn.close()
|
|
220
|
+
except sqlite3.Error:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
if adopted:
|
|
224
|
+
try:
|
|
225
|
+
legacy.rename(marker)
|
|
226
|
+
except OSError:
|
|
227
|
+
# 改名失败不影响已提交的数据;下次启动会按幂等路径重试或跳过。
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def init_db() -> None:
|
|
232
|
+
"""初始化数据库表。应用启动时调用一次。"""
|
|
233
|
+
_migrate_legacy_db()
|
|
234
|
+
with get_conn() as conn:
|
|
235
|
+
_ensure_schema(conn)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ─── decisions 表操作 ───────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def save_decision(dec: dict) -> dict:
|
|
242
|
+
"""新增/更新一条决策记录。返回传入的 dec(补齐默认值)。"""
|
|
243
|
+
if not dec.get("id"):
|
|
244
|
+
dec["id"] = f"dec_{datetime.now().strftime('%Y%m%d%H%M%S')}_{_random_suffix()}"
|
|
245
|
+
if not dec.get("createdAt"):
|
|
246
|
+
dec["createdAt"] = datetime.now().isoformat()
|
|
247
|
+
|
|
248
|
+
with get_conn() as conn:
|
|
249
|
+
conn.execute(
|
|
250
|
+
"""
|
|
251
|
+
INSERT OR REPLACE INTO decisions
|
|
252
|
+
(id, question, mode, result, brief, created_at, executed, regret, dialogue_history)
|
|
253
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
254
|
+
""",
|
|
255
|
+
(
|
|
256
|
+
dec["id"],
|
|
257
|
+
dec["question"],
|
|
258
|
+
dec["mode"],
|
|
259
|
+
json.dumps(dec.get("result", {}), ensure_ascii=False),
|
|
260
|
+
json.dumps(dec.get("brief"), ensure_ascii=False) if dec.get("brief") else None,
|
|
261
|
+
dec["createdAt"],
|
|
262
|
+
1 if dec.get("executed") else 0,
|
|
263
|
+
1 if dec.get("regret") else 0,
|
|
264
|
+
json.dumps(dec.get("dialogueHistory"), ensure_ascii=False) if dec.get("dialogueHistory") else None,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
return dec
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_decision(decision_id: str) -> Optional[dict]:
|
|
271
|
+
"""按 id 查询单条决策记录。"""
|
|
272
|
+
with get_conn() as conn:
|
|
273
|
+
row = conn.execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
|
|
274
|
+
return _row_to_decision(row) if row else None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def list_decisions(limit: int = 100, offset: int = 0) -> list[dict]:
|
|
278
|
+
"""查询决策历史列表(按 createdAt 降序)。"""
|
|
279
|
+
with get_conn() as conn:
|
|
280
|
+
rows = conn.execute(
|
|
281
|
+
"SELECT * FROM decisions ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
282
|
+
(limit, offset),
|
|
283
|
+
).fetchall()
|
|
284
|
+
return [_row_to_decision(r) for r in rows]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def update_decision(decision_id: str, patches: dict) -> Optional[dict]:
|
|
288
|
+
"""部分更新决策记录(仅 executed/regret/dialogueHistory 可更新)。"""
|
|
289
|
+
allowed = {"executed", "regret", "dialogueHistory"}
|
|
290
|
+
updates = {k: v for k, v in patches.items() if k in allowed}
|
|
291
|
+
if not updates:
|
|
292
|
+
return get_decision(decision_id)
|
|
293
|
+
|
|
294
|
+
sets = []
|
|
295
|
+
vals = []
|
|
296
|
+
if "executed" in updates:
|
|
297
|
+
sets.append("executed = ?")
|
|
298
|
+
vals.append(1 if updates["executed"] else 0)
|
|
299
|
+
if "regret" in updates:
|
|
300
|
+
sets.append("regret = ?")
|
|
301
|
+
vals.append(1 if updates["regret"] else 0)
|
|
302
|
+
if "dialogueHistory" in updates:
|
|
303
|
+
sets.append("dialogue_history = ?")
|
|
304
|
+
vals.append(json.dumps(updates["dialogueHistory"], ensure_ascii=False))
|
|
305
|
+
|
|
306
|
+
vals.append(decision_id)
|
|
307
|
+
with get_conn() as conn:
|
|
308
|
+
conn.execute(f"UPDATE decisions SET {', '.join(sets)} WHERE id = ?", vals)
|
|
309
|
+
return get_decision(decision_id)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def delete_decision(decision_id: str) -> bool:
|
|
313
|
+
"""删除一条决策记录。返回是否删除成功。"""
|
|
314
|
+
with get_conn() as conn:
|
|
315
|
+
cur = conn.execute("DELETE FROM decisions WHERE id = ?", (decision_id,))
|
|
316
|
+
return cur.rowcount > 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def count_decisions() -> int:
|
|
320
|
+
"""决策总数。"""
|
|
321
|
+
with get_conn() as conn:
|
|
322
|
+
row = conn.execute("SELECT COUNT(*) AS n FROM decisions").fetchone()
|
|
323
|
+
return row["n"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ─── config 表操作 ──────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_config_value(key: str, default: Any = None) -> Any:
|
|
330
|
+
"""读取单个配置项。"""
|
|
331
|
+
with get_conn() as conn:
|
|
332
|
+
row = conn.execute("SELECT value FROM config WHERE key = ?", (key,)).fetchone()
|
|
333
|
+
if not row:
|
|
334
|
+
return default
|
|
335
|
+
try:
|
|
336
|
+
return json.loads(row["value"])
|
|
337
|
+
except (json.JSONDecodeError, TypeError):
|
|
338
|
+
return default
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def set_config_value(key: str, value: Any) -> None:
|
|
342
|
+
"""写入单个配置项(upsert)。"""
|
|
343
|
+
with get_conn() as conn:
|
|
344
|
+
conn.execute(
|
|
345
|
+
"INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)",
|
|
346
|
+
(key, json.dumps(value, ensure_ascii=False)),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_all_config() -> dict:
|
|
351
|
+
"""读取全部配置项,返回 {key: value} 字典。"""
|
|
352
|
+
with get_conn() as conn:
|
|
353
|
+
rows = conn.execute("SELECT key, value FROM config").fetchall()
|
|
354
|
+
out = {}
|
|
355
|
+
for r in rows:
|
|
356
|
+
try:
|
|
357
|
+
out[r["key"]] = json.loads(r["value"])
|
|
358
|
+
except (json.JSONDecodeError, TypeError):
|
|
359
|
+
out[r["key"]] = r["value"]
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def delete_config_value(key: str) -> None:
|
|
364
|
+
"""删除单个配置项。"""
|
|
365
|
+
with get_conn() as conn:
|
|
366
|
+
conn.execute("DELETE FROM config WHERE key = ?", (key,))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ─── 辅助函数 ───────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _row_to_decision(row: sqlite3.Row) -> dict:
|
|
373
|
+
"""把数据库行转换为 Decision dict。"""
|
|
374
|
+
out = {
|
|
375
|
+
"id": row["id"],
|
|
376
|
+
"question": row["question"],
|
|
377
|
+
"mode": row["mode"],
|
|
378
|
+
"result": json.loads(row["result"]) if row["result"] else {},
|
|
379
|
+
"createdAt": row["created_at"],
|
|
380
|
+
"executed": bool(row["executed"]),
|
|
381
|
+
"regret": bool(row["regret"]),
|
|
382
|
+
}
|
|
383
|
+
if row["brief"]:
|
|
384
|
+
out["brief"] = json.loads(row["brief"])
|
|
385
|
+
if row["dialogue_history"]:
|
|
386
|
+
out["dialogueHistory"] = json.loads(row["dialogue_history"])
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _random_suffix() -> str:
|
|
391
|
+
"""6 位随机后缀(base36)。"""
|
|
392
|
+
import random
|
|
393
|
+
import string
|
|
394
|
+
return "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|