Rong6693 commited on
Commit
0046031
·
verified ·
1 Parent(s): f56a977

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +243 -212
app.py CHANGED
@@ -1,74 +1,33 @@
1
  # app.py
2
- import os
3
- import json
4
- import random
5
  from datetime import datetime
6
-
7
  import streamlit as st
8
 
9
- # ========= Page Config =========
10
- st.set_page_config(
11
- page_title="SoulCompass - Find Your Inner North Star",
12
- page_icon="🧭",
13
- layout="wide"
14
- )
15
-
16
- # ========= Session Init =========
17
- if "daily_usage" not in st.session_state:
18
- st.session_state.daily_usage = 0
19
- if "journal" not in st.session_state:
20
- st.session_state.journal = []
21
 
22
- # ========= CSS (simple) =========
23
- st.markdown("""
24
- <style>
25
- .header {text-align:center;background:linear-gradient(135deg,#6366F1,#C084FC);color:white;padding:2.2rem;border-radius:18px;margin-bottom:1.2rem}
26
- .badge {display:inline-block;background:rgba(255,255,255,.18);padding:.35rem .75rem;border-radius:999px;margin:.25rem}
27
- .card {background:linear-gradient(135deg,#1E3A8A,#6366F1);color:white;padding:1.1rem;border-radius:12px;border:2px solid #F59E0B}
28
- .result {background:linear-gradient(135deg,#6366F1,#C084FC);color:white;padding:1.1rem;border-radius:12px}
29
- .quota {background:linear-gradient(135deg,#F59E0B,#FB923C);color:white;padding:.5rem 1rem;border-radius:999px;
30
- text-align:center;font-weight:700;margin:0 auto;max-width:360px}
31
- .small {font-size:.9rem;color:#6B7280}
32
- hr{border:none;height:1px;background:#E5E7EB;margin:1.2rem 0}
33
- </style>
34
- """, unsafe_allow_html=True)
35
 
36
- # ========= Header =========
37
  st.markdown("""
38
- <div class="header">
39
- <h1>🧭 SoulCompass</h1>
40
- <h3>Find Your Inner North Star</h3>
41
- <div>
42
- <span class="badge">🔮 Tarot</span>
43
- <span class="badge">🔢 Numerology</span>
44
- <span class="badge">📖 Soul Journal</span>
45
- <span class="badge">🤖 AI Guide</span>
46
- </div>
47
  </div>
48
  """, unsafe_allow_html=True)
49
 
50
- # ========= Sidebar (Model / RAG Controls) =========
51
- st.sidebar.header("⚙️ Settings")
52
- model_choice = st.sidebar.selectbox(
53
- "LLM Model",
54
- ["cerebras/btlm-3b-8k-base", "mistralai/Mistral-7B-Instruct-v0.2"],
55
- help="BTLM 輕量穩定、適合免費空間;Mistral 口條更好、但較吃資源。"
56
- )
57
- rag_k = st.sidebar.slider("RAG Top-K", 1, 5, 3)
58
- temperature = st.sidebar.slider("Generation Temperature", 0.2, 1.2, 0.7, 0.1)
59
-
60
- st.sidebar.markdown("---")
61
- st.sidebar.caption("資料庫:`data/tarot_data_full.json`, `data/numerology_data_full.json`")
62
- st.sidebar.caption("檢索程式:`rag_utils.py`")
63
- # ========= Data Loaders (Tarot / Numerology) =========
64
  @st.cache_resource
65
  def load_tarot_names():
66
- """從 data/tarot_data_full.json 讀牌名(支援 name 或 card_name)。"""
67
  path = os.path.join("data", "tarot_data_full.json")
68
  with open(path, "r", encoding="utf-8") as f:
69
  data = json.load(f)
70
  names = []
71
- for i, c in enumerate(data):
72
  nm = c.get("name") or c.get("card_name")
73
  if nm:
74
  names.append(nm)
@@ -78,36 +37,47 @@ def load_tarot_names():
78
 
79
  @st.cache_resource
80
  def load_numerology_data():
81
- """讀取 data/numerology_data_full.json,回傳 (numbers_list, by_number_dict)。"""
82
  path = os.path.join("data", "numerology_data_full.json")
83
  with open(path, "r", encoding="utf-8") as f:
84
  data = json.load(f)
85
- numbers, by_num = [], {}
86
- for row in data:
87
- num = row.get("number")
88
- if num is None:
89
  continue
90
- numbers.append(int(num))
91
- by_num[int(num)] = {
92
- "name": row.get("name", ""),
93
- "keywords": row.get("keywords", []),
94
- "description": row.get("description", ""),
95
- "advice": row.get("advice", "")
 
96
  }
97
- numbers = sorted(set(numbers))
98
- if not numbers:
99
  raise ValueError("No numerology numbers found in data/numerology_data_full.json")
100
- return numbers, by_num
101
 
102
  def life_path_number_from_date(dt):
103
- """把生日轉成生命靈數(保留 11/22/33)。"""
104
  s = dt.strftime("%Y%m%d")
105
  total = sum(int(d) for d in s)
106
  while total > 9 and total not in (11, 22, 33):
107
  total = sum(int(d) for d in str(total))
108
  return total
109
 
110
- # ========= RAG + LLM Loader =========
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  @st.cache_resource
112
  def load_rag():
113
  try:
@@ -118,42 +88,37 @@ def load_rag():
118
  return None
119
 
120
  @st.cache_resource
121
- def load_llm(model_id: str = "cerebras/btlm-3b-8k-base"):
122
  import torch
123
  from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
124
-
125
  tok = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
126
  model = AutoModelForCausalLM.from_pretrained(
127
  model_id,
128
  trust_remote_code=True,
129
- torch_dtype=torch.float32, # 免費 CPU 用 float32 最穩
130
  device_map="auto",
131
  low_cpu_mem_usage=True,
132
- offload_folder="/tmp", # ★ 提供 offload 路徑
133
  )
134
- pipe = pipeline(
135
  "text-generation",
136
  model=model,
137
  tokenizer=tok,
138
- max_new_tokens=320,
139
- temperature=0.7,
140
  top_p=0.9,
141
  repetition_penalty=1.08,
142
  do_sample=True,
143
  )
144
- return pipe
145
 
146
  rag = load_rag()
147
  llm = load_llm(model_choice)
148
 
149
  def rag_answer(query: str, domain: str = "Tarot", k: int = 3):
150
- """Search with FAISS and generate an answer using the LLM."""
151
  if rag is None:
152
  return "RAG 模組未載入,請檢查 rag_utils.py。", []
153
- # retrieve
154
  hits = rag.search_tarot(query, k) if domain == "Tarot" else rag.search_numerology(query, k)
155
  context = "\n\n".join([h["text"] for h in hits]) if hits else "(無檢索結果)"
156
- # prompt
157
  prompt = f"""You are SoulCompass, a compassionate spiritual guide.
158
  Use ONLY the CONTEXT to answer the USER in Traditional Chinese with empathy and clarity.
159
  If info is missing, admit briefly and suggest a next step.
@@ -168,157 +133,223 @@ USER QUESTION: {query}
168
  answer = out[len(prompt):].strip()
169
  return answer, hits
170
 
171
- # ========= Data Helpers =========
172
- @st.cache_resource
173
- def load_tarot_names():
174
- path = os.path.join("data", "tarot_data_full.json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  if not os.path.exists(path):
176
- # tiny fallback
177
- return ["The Fool", "The Magician", "The High Priestess", "The Empress", "The Emperor"]
178
- with open(path, "r", encoding="utf-8") as f:
179
- data = json.load(f)
180
- return [c["name"] if "name" in c else c.get("card_name", "Card") for c in data]
 
 
 
 
 
181
 
182
- TAROT_NAMES = load_tarot_names()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
- # ========= Tabs =========
185
- tab1, tab2, tab3, tab4 = st.tabs(["🔮 Tarot", "🔢 Numerology", "📖 Journal", "🤖 AI Guide"])
186
 
187
- # ---- TAB 1: Tarot ----
 
 
 
 
 
 
 
188
  with tab1:
189
  st.subheader("🔮 Tarot Reading")
190
- st.write("*Let ancient wisdom guide your path*")
191
-
192
- colA, colB = st.columns([2, 1])
193
- with colA:
194
- question = st.text_area(
195
- "你想得到什麼指引?",
196
- placeholder="例如:我適合換工作嗎?和他未來的關係會如何?",
197
- height=90
198
- )
199
- with colB:
200
- spread = st.selectbox(
201
- "抽牌陣列",
202
- ["Single (1 card)", "Three (Past • Present • Future)", "Five (Deep Insight)"]
203
- )
204
-
205
- draw = st.button("🔮 抽牌")
206
- if draw and not question:
207
- st.warning("請先輸入你的問題")
208
- if draw and question:
209
- if st.session_state.daily_usage >= 5:
210
- st.error("今日免費占卜次數已滿,明天再來吧!")
211
  else:
212
- st.session_state.daily_usage += 1
213
- num_cards = 1 if "Single" in spread else (3 if "Three" in spread else 5)
214
- drawn = random.sample(TAROT_NAMES, k=min(num_cards, len(TAROT_NAMES)))
215
-
216
- st.success("🌟 抽牌完成")
217
- cols = st.columns(len(drawn))
218
- for i, name in enumerate(drawn):
219
- with cols[i]:
220
- st.markdown(f'<div class="card"><h4>Card {i+1}</h4><p>{name}</p></div>', unsafe_allow_html=True)
221
-
222
- # RAG + LLM 解讀:把「問題 + 牌名列表」一併送去檢索
223
- query = f"問題:{question};抽到的牌:{', '.join(drawn)}。請用塔羅牌義做整體解讀。"
224
- with st.spinner("RAG 檢索中 & AI 正在生成解讀…"):
225
- answer, refs = rag_answer(query, domain="Tarot", k=rag_k)
226
-
227
- st.markdown(f'<div class="result">{answer.replace(chr(10), "<br>")}</div>', unsafe_allow_html=True)
228
- st.caption(f"已使用:{st.session_state.daily_usage}/5 次")
229
-
230
- with st.expander("查看參考段落(RAG 來源)"):
231
- for i, r in enumerate(refs, 1):
232
- tag = r.get("card_name") or r.get("name") or "Card"
233
- st.markdown(f"**#{i} {tag}** · score: {r['score']:.3f}")
234
- st.write(r["text"])
235
- st.markdown("---")
236
-
237
- # ---- TAB 2: Numerology ----
238
- with tab2:
239
- st.subheader("🔢 Numerology")
240
- st.write("*Discover your numbers*")
241
-
242
- col1, col2 = st.columns(2)
243
- with col1:
244
- birth_date = st.date_input("你的生日")
245
- if st.button("計算生命靈數"):
246
- if birth_date:
247
- s = birth_date.strftime("%Y%m%d")
248
- total = sum(int(d) for d in s)
249
- while total > 9 and total not in (11, 22, 33):
250
- total = sum(int(d) for d in str(total))
251
- st.success(f"你的生命靈數是:{total}")
252
- with st.spinner("查詢靈數意義…"):
253
- q = f"生命靈數 {total} 的核心特質與建議是什麼?"
254
- ans, refs = rag_answer(q, domain="Numerology", k=rag_k)
255
- st.markdown(f'<div class="result">{ans.replace(chr(10), "<br>")}</div>', unsafe_allow_html=True)
256
- with st.expander("參考段落"):
257
  for i, r in enumerate(refs, 1):
258
- tag = r.get("number") or "N/A"
259
- st.markdown(f"**#{i} Number {tag}** · score: {r['score']:.3f}")
260
  st.write(r["text"])
261
  st.markdown("---")
262
- else:
263
- st.warning("請先選擇生日")
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  with col2:
266
- name = st.text_input("你的全名(可選)")
267
- if st.button("簡易名字數字"):
268
  if name.strip():
269
  n = (len(name.replace(" ", "")) % 9) + 1
270
- st.info(f"名字數字(簡式):{n}")
271
- q = f"名字數字 {n} 的人在人際與自我表達上的建議?"
272
- ans, _ = rag_answer(q, domain="Numerology", k=rag_k)
273
  st.write(ans)
274
- else:
275
- st.warning("請輸入名字")
276
 
277
- # ---- TAB 3: Journal ----
278
  with tab3:
279
  st.subheader("📖 Soul Journal")
280
- mood = st.slider("今天的心情(1-10)", 1, 10, 7)
281
- emotion = st.selectbox("主要情緒", ["😊 開心", "😔 失落", "😰 焦慮", "😌 平靜", "🤗 感恩"])
282
- entry = st.text_area("寫下今天的想法", height=140,
283
- placeholder="你最近在思考什麼?今天最感謝的一件事是?")
284
-
285
- if st.button("💾 儲存日記"):
286
- if entry.strip():
287
- st.session_state.journal.append({
288
- "time": datetime.now().strftime("%Y-%m-%d %H:%M"),
289
- "mood": mood,
290
- "emotion": emotion,
291
- "text": entry.strip()
292
- })
293
- st.success("已儲存 🌟")
294
- else:
295
- st.warning("請先寫點內容")
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
- if st.session_state.journal:
298
- st.markdown("#### 最近的紀錄")
299
- for j in reversed(st.session_state.journal[-10:]):
300
- st.markdown(f"**{j['time']} · {j['emotion']} · 心情 {j['mood']}**")
301
- st.markdown(f"<div class='card' style='margin:.4rem 0'>{j['text']}</div>", unsafe_allow_html=True)
302
 
303
- # ---- TAB 4: AI Guide ----
 
 
 
 
 
 
 
 
 
 
 
 
304
  with tab4:
305
- st.subheader("🤖 AI Guide")
306
- st.write("*Your 24/7 companion (not a substitute for professional help)*")
307
- topic = st.selectbox("主題", ["General Support", "Stress & Anxiety", "Relationships", "Life Goals", "Personal Growth"])
308
- msg = st.text_area("想說什麼?", height=110, placeholder="把心裡話告訴我…")
309
- if st.button("💫 取得支持"):
310
- if not msg.strip():
311
- st.warning("請先輸入訊息")
 
 
 
 
 
312
  else:
313
- seed_q = f"主題:{topic};訊息:{msg}。請以溫柔、務實、具行動建議的方式回應。"
314
- with st.spinner("AI 思考中…"):
315
- ans, _ = rag_answer(seed_q, domain="Tarot", k=2) # 混搭少量塔羅語料,提升靈性語氣
316
- st.markdown(f'<div class="result">{ans.replace(chr(10), "<br>")}</div>', unsafe_allow_html=True)
317
- st.caption("若遇到嚴重困擾,請尋求專業協助。")
 
 
 
 
318
 
319
- # ========= Footer =========
320
  st.markdown("<hr>", unsafe_allow_html=True)
321
- st.markdown(
322
- "<div style='text-align:center' class='small'>🧭 <b>SoulCompass</b> · Made with ❤️ · RAG + LLM powered</div>",
323
- unsafe_allow_html=True
324
- )
 
1
  # app.py
2
+ import os, json, uuid, random
 
 
3
  from datetime import datetime
 
4
  import streamlit as st
5
 
6
+ # ========== 基本設定 ==========
7
+ st.set_page_config(page_title="SoulCompass", page_icon="🧭", layout="wide")
 
 
 
 
 
 
 
 
 
 
8
 
9
+ # 使用者暫時 UID(未登入先用本機隨機 ID)
10
+ if "uid" not in st.session_state:
11
+ st.session_state.uid = str(uuid.uuid4())[:8]
12
+ UID = st.session_state.uid
13
+ USER_DIR = os.path.join("data", "users", UID)
14
+ os.makedirs(USER_DIR, exist_ok=True)
15
+ JOURNAL_PATH = os.path.join(USER_DIR, "journal.jsonl")
 
 
 
 
 
 
16
 
 
17
  st.markdown("""
18
+ <div style="text-align:center;background:linear-gradient(135deg,#6366F1,#C084FC);color:white;padding:1.4rem;border-radius:14px;margin-bottom:12px;">
19
+ <h2 style="margin:0;">🧭 SoulCompass — Tarot • Numerology • Journal • Guide</h2>
 
 
 
 
 
 
 
20
  </div>
21
  """, unsafe_allow_html=True)
22
 
23
+ # ========== 讀取 Tarot / Numerology 資料 ==========
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  @st.cache_resource
25
  def load_tarot_names():
 
26
  path = os.path.join("data", "tarot_data_full.json")
27
  with open(path, "r", encoding="utf-8") as f:
28
  data = json.load(f)
29
  names = []
30
+ for c in data:
31
  nm = c.get("name") or c.get("card_name")
32
  if nm:
33
  names.append(nm)
 
37
 
38
  @st.cache_resource
39
  def load_numerology_data():
 
40
  path = os.path.join("data", "numerology_data_full.json")
41
  with open(path, "r", encoding="utf-8") as f:
42
  data = json.load(f)
43
+ by_num, nums = {}, []
44
+ for r in data:
45
+ if "number" not in r:
 
46
  continue
47
+ n = int(r["number"])
48
+ nums.append(n)
49
+ by_num[n] = {
50
+ "name": r.get("name",""),
51
+ "keywords": r.get("keywords",[]),
52
+ "description": r.get("description",""),
53
+ "advice": r.get("advice","")
54
  }
55
+ nums = sorted(set(nums))
56
+ if not nums:
57
  raise ValueError("No numerology numbers found in data/numerology_data_full.json")
58
+ return nums, by_num
59
 
60
  def life_path_number_from_date(dt):
 
61
  s = dt.strftime("%Y%m%d")
62
  total = sum(int(d) for d in s)
63
  while total > 9 and total not in (11, 22, 33):
64
  total = sum(int(d) for d in str(total))
65
  return total
66
 
67
+ TAROT_NAMES = load_tarot_names()
68
+ NUMBERS, NUM_MAP = load_numerology_data()
69
+
70
+ # ========== Sidebar:模型與 RAG 參數 ==========
71
+ st.sidebar.header("⚙️ Settings")
72
+ model_choice = st.sidebar.selectbox(
73
+ "LLM Model",
74
+ ["cerebras/btlm-3b-8k-base", "mistralai/Mistral-7B-Instruct-v0.2"],
75
+ help="BTLM 輕量、CPU 可跑;Mistral 口條更好但較吃資源。"
76
+ )
77
+ rag_k = st.sidebar.slider("RAG Top-K", 1, 5, 3)
78
+ temperature = st.sidebar.slider("Temperature", 0.2, 1.2, 0.7, 0.1)
79
+
80
+ # ========== RAG + LLM Loader ==========
81
  @st.cache_resource
82
  def load_rag():
83
  try:
 
88
  return None
89
 
90
  @st.cache_resource
91
+ def load_llm(model_id: str):
92
  import torch
93
  from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
 
94
  tok = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
95
  model = AutoModelForCausalLM.from_pretrained(
96
  model_id,
97
  trust_remote_code=True,
98
+ torch_dtype=torch.float32, # 免費 CPU 最穩
99
  device_map="auto",
100
  low_cpu_mem_usage=True,
101
+ offload_folder="/tmp",
102
  )
103
+ return pipeline(
104
  "text-generation",
105
  model=model,
106
  tokenizer=tok,
107
+ max_new_tokens=360,
108
+ temperature=temperature,
109
  top_p=0.9,
110
  repetition_penalty=1.08,
111
  do_sample=True,
112
  )
 
113
 
114
  rag = load_rag()
115
  llm = load_llm(model_choice)
116
 
117
  def rag_answer(query: str, domain: str = "Tarot", k: int = 3):
 
118
  if rag is None:
119
  return "RAG 模組未載入,請檢查 rag_utils.py。", []
 
120
  hits = rag.search_tarot(query, k) if domain == "Tarot" else rag.search_numerology(query, k)
121
  context = "\n\n".join([h["text"] for h in hits]) if hits else "(無檢索結果)"
 
122
  prompt = f"""You are SoulCompass, a compassionate spiritual guide.
123
  Use ONLY the CONTEXT to answer the USER in Traditional Chinese with empathy and clarity.
124
  If info is missing, admit briefly and suggest a next step.
 
133
  answer = out[len(prompt):].strip()
134
  return answer, hits
135
 
136
+ # ========== Journal 長期保存(JSONL)==========
137
+ def load_journal(path=JOURNAL_PATH):
138
+ entries = []
139
+ if os.path.exists(path):
140
+ with open(path, "r", encoding="utf-8") as f:
141
+ for line in f:
142
+ line = line.strip()
143
+ if not line:
144
+ continue
145
+ try:
146
+ entries.append(json.loads(line))
147
+ except Exception:
148
+ pass
149
+ return entries
150
+
151
+ def append_journal(entry: dict, path=JOURNAL_PATH):
152
+ os.makedirs(os.path.dirname(path), exist_ok=True)
153
+ with open(path, "a", encoding="utf-8") as f:
154
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
155
+
156
+ def save_all_journal(entries, path=JOURNAL_PATH):
157
+ os.makedirs(os.path.dirname(path), exist_ok=True)
158
+ with open(path, "w", encoding="utf-8") as f:
159
+ for e in entries:
160
+ f.write(json.dumps(e, ensure_ascii=False) + "\n")
161
+
162
+ def export_journal_csv(path=JOURNAL_PATH):
163
  if not os.path.exists(path):
164
+ return None
165
+ rows = load_journal(path)
166
+ out_csv = os.path.join(USER_DIR, "journal_export.csv")
167
+ import csv
168
+ with open(out_csv, "w", encoding="utf-8", newline="") as f:
169
+ w = csv.writer(f)
170
+ w.writerow(["time", "mood", "emotion", "text"])
171
+ for r in rows:
172
+ w.writerow([r.get("time",""), r.get("mood",""), r.get("emotion",""), r.get("text","")])
173
+ return out_csv
174
 
175
+ if "journal" not in st.session_state:
176
+ st.session_state.journal = load_journal()
177
+
178
+ # ========== AI 心靈導師(安全護欄+生成)==========
179
+ CRISIS_KEYWORDS = [
180
+ "自殺","想死","結束生命","活不下去","傷害自己","割腕",
181
+ "殺了我","想不開","無法活","輕生","自殘","不如死了"
182
+ ]
183
+
184
+ def is_crisis(text: str) -> bool:
185
+ if not text:
186
+ return False
187
+ # 中文大小寫無差;直接檢索原文關鍵字
188
+ return any(k in text for k in CRISIS_KEYWORDS)
189
+
190
+ def guide_answer(user_msg: str, topic: str = "General"):
191
+ if is_crisis(user_msg):
192
+ crisis_reply = (
193
+ "我聽見你現在非常難受,謝謝你願意說出來。你的感受很重要,也值得被好好照顧。\n\n"
194
+ "目前這些想法可能代表你需要更即時、面對面的支持。請立刻聯絡你所在地的緊急服務,"
195
+ "或尋求可信任的人陪伴(家人、朋友、學校輔導/公司 EAP)。\n\n"
196
+ "你也可以尋找在地的心理諮商與危機支援資源(醫院身心科、社福單位、24 小時關懷專線)。\n\n"
197
+ "現在先做三件小事:1) 找一個安全的空間,2) 打給一位你信任的人,3) 緩慢做 5 次深呼吸(吸4、停2、吐6)。\n\n"
198
+ "我在這裡,願意繼續聽你說。"
199
+ )
200
+ return crisis_reply, []
201
+
202
+ # 可選:混入少量 RAG 提示(用 Numerology 當中性語料)
203
+ try:
204
+ ctx_hits = rag.search_numerology("自我照顧 情緒 調節 建議", k=2) if rag else []
205
+ context = "\n\n".join([h["text"] for h in ctx_hits]) if ctx_hits else "(無檢索)"
206
+ except Exception:
207
+ ctx_hits, context = [], "(無檢索)"
208
+
209
+ prompt = f"""You are SoulCompass, a supportive, non-clinical companion.
210
+ - Respond in Traditional Chinese.
211
+ - Be empathetic, specific, and action-oriented.
212
+ - Do NOT diagnose conditions or offer medical advice.
213
+ - Offer 2~4 small, doable next steps, tailored to the user's message.
214
+ - Keep it under 10 sentences.
215
+
216
+ OPTIONAL CONTEXT (self-care ideas):
217
+ {context}
218
 
219
+ TOPIC: {topic}
220
+ USER: {user_msg}
221
 
222
+ 請用溫柔、務實、去評判的語氣回覆,先同理、再聚焦、最後給 2~4 個可行的小步驟。"""
223
+ out = llm(prompt)[0]["generated_text"]
224
+ return out[len(prompt):].strip(), ctx_hits
225
+
226
+ # ========== Tabs ==========
227
+ tab1, tab2, tab3, tab4 = st.tabs(["🔮 Tarot", "🔢 Numerology", "📖 Journal", "🤖 AI 心靈導師"])
228
+
229
+ # ---- Tarot ----
230
  with tab1:
231
  st.subheader("🔮 Tarot Reading")
232
+ q = st.text_input("想詢問什麼?(例:我適合換工作嗎?)", key="tarot_q")
233
+ spread = st.radio("抽牌陣列", ["Single (1)", "Three (3)", "Five (5)"], horizontal=True, key="tarot_spread")
234
+ if st.button("抽牌並解讀", key="tarot_go"):
235
+ if not q.strip():
236
+ st.warning("請先輸入問題")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  else:
238
+ n = 1 if "1" in spread else (3 if "3" in spread else 5)
239
+ cards = random.sample(TAROT_NAMES, k=min(n, len(TAROT_NAMES)))
240
+ st.success("你抽到:" + ", ".join(cards))
241
+ query = f"問題:{q};抽到的牌:{', '.join(cards)}。請用塔羅牌義做整體解讀。"
242
+ with st.spinner("RAG 檢索 + 生成中…"):
243
+ ans, refs = rag_answer(query, domain="Tarot", k=rag_k)
244
+ st.markdown(ans)
245
+ if refs:
246
+ with st.expander("參考段落(RAG 來源)"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  for i, r in enumerate(refs, 1):
248
+ tag = r.get("card_name") or r.get("name") or "Card"
249
+ st.markdown(f"**#{i} {tag}** · score {r['score']:.3f}")
250
  st.write(r["text"])
251
  st.markdown("---")
 
 
252
 
253
+ # ---- Numerology ----
254
+ with tab2:
255
+ st.subheader("🔢 Numerology")
256
+ b = st.date_input("你的生日", key="dob")
257
+ col1, col2 = st.columns(2)
258
+ with col1:
259
+ if st.button("計算生命靈數", key="lp_go"):
260
+ if b:
261
+ lp = life_path_number_from_date(b)
262
+ st.success(f"你的生命靈數:{lp}({NUM_MAP.get(lp,{}).get('name','')})")
263
+ qn = f"生命靈數 {lp} 的核心特質、盲點與行動建議?"
264
+ with st.spinner("RAG 檢索 + 生成中…"):
265
+ ans, refs = rag_answer(qn, domain="Numerology", k=rag_k)
266
+ st.markdown(ans)
267
  with col2:
268
+ name = st.text_input("你的姓名(可選)", key="name_num")
269
+ if st.button("名字數字", key="name_go"):
270
  if name.strip():
271
  n = (len(name.replace(" ", "")) % 9) + 1
272
+ st.info(f"名字數字:{n}({NUM_MAP.get(n,{}).get('name','')})")
273
+ qn = f"名字數字 {n} 的人在人際與自我表達上的建議?"
274
+ ans, _ = rag_answer(qn, domain="Numerology", k=rag_k)
275
  st.write(ans)
 
 
276
 
277
+ # ---- Journal ----
278
  with tab3:
279
  st.subheader("📖 Soul Journal")
280
+ st.caption(f"本機使用者 ID:{UID}")
281
+ mood = st.slider("今天的心情(1-10)", 1, 10, 7, key="mood")
282
+ emotion = st.selectbox("主要情緒", ["😊 開心","😔 失落","😰 焦慮","😌 平靜","🤗 感恩"], key="emotion")
283
+ entry = st.text_area("寫下今天的想法", height=140, placeholder="你最近在思考什麼?今天最感謝的一件事是?", key="entry")
284
+
285
+ c1, c2, c3 = st.columns(3)
286
+ with c1:
287
+ if st.button("💾 儲存日記", key="save_journal"):
288
+ if entry.strip():
289
+ item = {
290
+ "time": datetime.now().strftime("%Y-%m-%d %H:%M"),
291
+ "mood": st.session_state.mood,
292
+ "emotion": st.session_state.emotion,
293
+ "text": entry.strip()
294
+ }
295
+ st.session_state.journal.append(item)
296
+ append_journal(item)
297
+ st.success("已儲存 🌟")
298
+ else:
299
+ st.warning("請先寫點內容")
300
+
301
+ with c2:
302
+ if st.button("⬇️ 匯出 CSV", key="export_journal"):
303
+ out_csv = export_journal_csv()
304
+ if out_csv and os.path.exists(out_csv):
305
+ with open(out_csv, "rb") as f:
306
+ st.download_button("下載日記 CSV", f, file_name="journal_export.csv")
307
+ else:
308
+ st.warning("目前沒有日記可匯出")
309
 
310
+ with c3:
311
+ if st.button("🗑️ 清空全部日記", key="clear_journal"):
312
+ save_all_journal([])
313
+ st.session_state.journal = []
314
+ st.success("全部日記已清空")
315
 
316
+ if st.session_state.journal:
317
+ st.markdown("---")
318
+ st.markdown("**最近紀錄(新→舊)**")
319
+ for j in reversed(st.session_state.journal[-50:]):
320
+ st.markdown(f"- **{j['time']} · {j['emotion']} · 心情 {j['mood']}** \n{j['text']}")
321
+
322
+ if st.button("🧠 生成 7 日回顧建議", key="review7"):
323
+ recent = "\n".join([j["text"] for j in st.session_state.journal[-20:]])
324
+ qj = f"這是我最近的日記重點:\n{recent}\n請用溫柔務實的語氣,總結 3 點觀察,並給 3 個可行的小建議。"
325
+ ans, _ = rag_answer(qj, domain="Numerology", k=2) # 用較中性的語料
326
+ st.markdown(ans)
327
+
328
+ # ---- AI 心靈導師 ----
329
  with tab4:
330
+ st.subheader("🤖 AI 心靈導師")
331
+ st.caption("此功能提供一般性支持與自我照顧建議,非醫療或心理治療之替代。若有危機,請立即尋求在地緊急支援。")
332
+ topic = st.selectbox(
333
+ "主題",
334
+ ["General", "Stress & Anxiety", "Relationships", "Work & Burnout", "Self-esteem", "Grief & Loss"],
335
+ index=0,
336
+ key="guide_topic"
337
+ )
338
+ user_msg = st.text_area("想跟我說點什麼?", height=140, placeholder="把現在的感受、困擾或情境說給我聽…", key="guide_msg")
339
+ if st.button("💬 取得支持", key="guide_go"):
340
+ if not user_msg.strip():
341
+ st.warning("請先輸入一段想說的話")
342
  else:
343
+ with st.spinner("AI 正在整理回應…"):
344
+ reply, refs = guide_answer(user_msg, topic)
345
+ st.markdown(reply)
346
+ if refs:
347
+ with st.expander("參考段落(RAG 來源)"):
348
+ for i, r in enumerate(refs, 1):
349
+ st.markdown(f"**#{i}** · score {r['score']:.3f}")
350
+ st.write(r["text"])
351
+ st.markdown("---")
352
 
353
+ # ========== Footer ==========
354
  st.markdown("<hr>", unsafe_allow_html=True)
355
+ st.caption("🧭 SoulCompass · demo MVP · RAG + LLM powered")