Spaces:
Running
Running
# app.py — minimal for LLM + RAG (no MCP) | |
import os, json, random, uuid | |
from datetime import datetime | |
import streamlit as st | |
import rag_utils # 你已經有的:提供 search_tarot / search_numerology / ensure_indexes | |
from data import load_tarot_data_full, numerology_data_full | |
TAROT_DATA = load_tarot_data_full() # List[Dict] | |
NUMEROLOGY_DATA = numerology_data_full() # List[Dict] | |
# 若你需要只要牌名/數字,可自行萃取: | |
TAROT_NAMES = [c.get("name") or c.get("card_name") for c in TAROT_DATA if (c.get("name") or c.get("card_name"))] | |
# ========== 基本設定 ========== | |
st.set_page_config(page_title="SoulCompass", page_icon="🧭", layout="wide") | |
# 使用者暫時 UID(未登入先用本機隨機 ID) | |
if "uid" not in st.session_state: | |
st.session_state.uid = str(uuid.uuid4())[:8] | |
UID = st.session_state.uid | |
USER_DIR = os.path.join("data", "users", UID) | |
os.makedirs(USER_DIR, exist_ok=True) | |
JOURNAL_PATH = os.path.join(USER_DIR, "journal.jsonl") | |
st.markdown(""" | |
<div style="text-align:center;background:linear-gradient(135deg,#6366F1,#C084FC);color:white;padding:1.4rem;border-radius:14px;margin-bottom:12px;"> | |
<h2 style="margin:0;">🧭 SoulCompass — Tarot • Numerology • Journal • Guide</h2> | |
</div> | |
""", unsafe_allow_html=True) | |
# ========== 讀取 Tarot / Numerology 資料 ========== | |
def load_tarot_names(): | |
path = os.path.join("data", "tarot_data_full.json") | |
with open(path, "r", encoding="utf-8") as f: | |
data = json.load(f) | |
names = [] | |
for c in data: | |
nm = c.get("name") or c.get("card_name") | |
if nm: | |
names.append(nm) | |
if not names: | |
raise ValueError("No card names found in data/tarot_data_full.json") | |
return names | |
def load_numerology_data(): | |
path = os.path.join("data", "numerology_data_full.json") | |
with open(path, "r", encoding="utf-8") as f: | |
data = json.load(f) | |
by_num, nums = {}, [] | |
for r in data: | |
if "number" not in r: | |
continue | |
n = int(r["number"]) | |
nums.append(n) | |
by_num[n] = { | |
"name": r.get("name",""), | |
"keywords": r.get("keywords",[]), | |
"description": r.get("description",""), | |
"advice": r.get("advice","") | |
} | |
nums = sorted(set(nums)) | |
if not nums: | |
raise ValueError("No numerology numbers found in data/numerology_data_full.json") | |
return nums, by_num | |
def life_path_number_from_date(dt): | |
s = dt.strftime("%Y%m%d") | |
total = sum(int(d) for d in s) | |
while total > 9 and total not in (11, 22, 33): | |
total = sum(int(d) for d in str(total)) | |
return total | |
TAROT_NAMES = load_tarot_data_full() | |
NUMBERS, NUM_MAP = load_numerology_data_full() | |
# ========== Sidebar:模型與 RAG 參數 ========== | |
st.sidebar.header("⚙️ Settings") | |
model_choice = st.sidebar.selectbox( | |
"LLM Model", | |
["cerebras/btlm-3b-8k-base", "mistralai/Mistral-7B-Instruct-v0.2"], | |
help="BTLM 輕量、CPU 可跑;Mistral 口條更好但較吃資源。" | |
) | |
rag_k = st.sidebar.slider("RAG Top-K", 1, 5, 3) | |
temperature = st.sidebar.slider("Temperature", 0.2, 1.2, 0.7, 0.1) | |
# ========== RAG + LLM Loader ========== | |
def load_rag(): | |
try: | |
import rag_utils | |
return rag_utils | |
except Exception as e: | |
st.warning("無法載入 rag_utils,請確認檔案存在於專案根目錄。") | |
return None | |
def load_llm(model_id: str): | |
import torch | |
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline | |
tok = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) | |
model = AutoModelForCausalLM.from_pretrained( | |
model_id, | |
trust_remote_code=True, | |
torch_dtype=torch.float32, # 免費 CPU 最穩 | |
device_map="auto", | |
low_cpu_mem_usage=True, | |
offload_folder="/tmp", | |
) | |
return pipeline( | |
"text-generation", | |
model=model, | |
tokenizer=tok, | |
max_new_tokens=360, | |
temperature=temperature, | |
top_p=0.9, | |
repetition_penalty=1.08, | |
do_sample=True, | |
) | |
rag = load_rag() | |
llm = load_llm(model_choice) | |
def rag_answer(query: str, domain: str = "Tarot", k: int = 3): | |
if rag is None: | |
return "RAG 模組未載入,請檢查 rag_utils.py。", [] | |
hits = rag.search_tarot(query, k) if domain == "Tarot" else rag.search_numerology(query, k) | |
context = "\n\n".join([h["text"] for h in hits]) if hits else "(無檢索結果)" | |
prompt = f"""You are SoulCompass, a compassionate spiritual guide. | |
Use ONLY the CONTEXT to answer the USER in Traditional Chinese with empathy and clarity. | |
If info is missing, admit briefly and suggest a next step. | |
CONTEXT: | |
{context} | |
USER QUESTION: {query} | |
請用 5~8 句繁體中文回覆,語氣溫柔、務實,最後給出 1 句可行的小建議。""" | |
out = llm(prompt)[0]["generated_text"] | |
answer = out[len(prompt):].strip() | |
return answer, hits | |
# ========== Journal 長期保存(JSONL)========== | |
def load_journal(path=JOURNAL_PATH): | |
entries = [] | |
if os.path.exists(path): | |
with open(path, "r", encoding="utf-8") as f: | |
for line in f: | |
line = line.strip() | |
if not line: | |
continue | |
try: | |
entries.append(json.loads(line)) | |
except Exception: | |
pass | |
return entries | |
def append_journal(entry: dict, path=JOURNAL_PATH): | |
os.makedirs(os.path.dirname(path), exist_ok=True) | |
with open(path, "a", encoding="utf-8") as f: | |
f.write(json.dumps(entry, ensure_ascii=False) + "\n") | |
def save_all_journal(entries, path=JOURNAL_PATH): | |
os.makedirs(os.path.dirname(path), exist_ok=True) | |
with open(path, "w", encoding="utf-8") as f: | |
for e in entries: | |
f.write(json.dumps(e, ensure_ascii=False) + "\n") | |
def export_journal_csv(path=JOURNAL_PATH): | |
if not os.path.exists(path): | |
return None | |
rows = load_journal(path) | |
out_csv = os.path.join(USER_DIR, "journal_export.csv") | |
import csv | |
with open(out_csv, "w", encoding="utf-8", newline="") as f: | |
w = csv.writer(f) | |
w.writerow(["time", "mood", "emotion", "text"]) | |
for r in rows: | |
w.writerow([r.get("time",""), r.get("mood",""), r.get("emotion",""), r.get("text","")]) | |
return out_csv | |
if "journal" not in st.session_state: | |
st.session_state.journal = load_journal() | |
# ========== AI 心靈導師(安全護欄+生成)========== | |
CRISIS_KEYWORDS = [ | |
"自殺","想死","結束生命","活不下去","傷害自己","割腕", | |
"殺了我","想不開","無法活","輕生","自殘","不如死了" | |
] | |
def is_crisis(text: str) -> bool: | |
if not text: | |
return False | |
# 中文大小寫無差;直接檢索原文關鍵字 | |
return any(k in text for k in CRISIS_KEYWORDS) | |
def guide_answer(user_msg: str, topic: str = "General"): | |
if is_crisis(user_msg): | |
crisis_reply = ( | |
"我聽見你現在非常難受,謝謝你願意說出來。你的感受很重要,也值得被好好照顧。\n\n" | |
"目前這些想法可能代表你需要更即時、面對面的支持。請立刻聯絡你所在地的緊急服務," | |
"或尋求可信任的人陪伴(家人、朋友、學校輔導/公司 EAP)。\n\n" | |
"你也可以尋找在地的心理諮商與危機支援資源(醫院身心科、社福單位、24 小時關懷專線)。\n\n" | |
"現在先做三件小事:1) 找一個安全的空間,2) 打給一位你信任的人,3) 緩慢做 5 次深呼吸(吸4、停2、吐6)。\n\n" | |
"我在這裡,願意繼續聽你說。" | |
) | |
return crisis_reply, [] | |
# 可選:混入少量 RAG 提示(用 Numerology 當中性語料) | |
try: | |
ctx_hits = rag.search_numerology("自我照顧 情緒 調節 建議", k=2) if rag else [] | |
context = "\n\n".join([h["text"] for h in ctx_hits]) if ctx_hits else "(無檢索)" | |
except Exception: | |
ctx_hits, context = [], "(無檢索)" | |
prompt = f"""You are SoulCompass, a supportive, non-clinical companion. | |
- Respond in Traditional Chinese. | |
- Be empathetic, specific, and action-oriented. | |
- Do NOT diagnose conditions or offer medical advice. | |
- Offer 2~4 small, doable next steps, tailored to the user's message. | |
- Keep it under 10 sentences. | |
OPTIONAL CONTEXT (self-care ideas): | |
{context} | |
TOPIC: {topic} | |
USER: {user_msg} | |
請用溫柔、務實、去評判的語氣回覆,先同理、再聚焦、最後給 2~4 個可行的小步驟。""" | |
out = llm(prompt)[0]["generated_text"] | |
return out[len(prompt):].strip(), ctx_hits | |
# ========== Tabs ========== | |
tab1, tab2, tab3, tab4 = st.tabs(["🔮 Tarot", "🔢 Numerology", "📖 Journal", "🤖 AI 心靈導師"]) | |
# ---- Tarot ---- | |
with tab1: | |
st.subheader("🔮 Tarot Reading") | |
q = st.text_input("想詢問什麼?(例:我適合換工作嗎?)", key="tarot_q") | |
spread = st.radio("抽牌陣列", ["Single (1)", "Three (3)", "Five (5)"], horizontal=True, key="tarot_spread") | |
if st.button("抽牌並解讀", key="tarot_go"): | |
if not q.strip(): | |
st.warning("請先輸入問題") | |
else: | |
n = 1 if "1" in spread else (3 if "3" in spread else 5) | |
cards = random.sample(TAROT_NAMES, k=min(n, len(TAROT_NAMES))) | |
st.success("你抽到:" + ", ".join(cards)) | |
query = f"問題:{q};抽到的牌:{', '.join(cards)}。請用塔羅牌義做整體解讀。" | |
with st.spinner("RAG 檢索 + 生成中…"): | |
ans, refs = rag_answer(query, domain="Tarot", k=rag_k) | |
st.markdown(ans) | |
if refs: | |
with st.expander("參考段落(RAG 來源)"): | |
for i, r in enumerate(refs, 1): | |
tag = r.get("card_name") or r.get("name") or "Card" | |
st.markdown(f"**#{i} {tag}** · score {r['score']:.3f}") | |
st.write(r["text"]) | |
st.markdown("---") | |
# ---- Numerology ---- | |
with tab2: | |
st.subheader("🔢 Numerology") | |
b = st.date_input("你的生日", key="dob") | |
col1, col2 = st.columns(2) | |
with col1: | |
if st.button("計算生命靈數", key="lp_go"): | |
if b: | |
lp = life_path_number_from_date(b) | |
st.success(f"你的生命靈數:{lp}({NUM_MAP.get(lp,{}).get('name','')})") | |
qn = f"生命靈數 {lp} 的核心特質、盲點與行動建議?" | |
with st.spinner("RAG 檢索 + 生成中…"): | |
ans, refs = rag_answer(qn, domain="Numerology", k=rag_k) | |
st.markdown(ans) | |
with col2: | |
name = st.text_input("你的姓名(可選)", key="name_num") | |
if st.button("名字數字", key="name_go"): | |
if name.strip(): | |
n = (len(name.replace(" ", "")) % 9) + 1 | |
st.info(f"名字數字:{n}({NUM_MAP.get(n,{}).get('name','')})") | |
qn = f"名字數字 {n} 的人在人際與自我表達上的建議?" | |
ans, _ = rag_answer(qn, domain="Numerology", k=rag_k) | |
st.write(ans) | |
# ---- Journal ---- | |
with tab3: | |
st.subheader("📖 Soul Journal") | |
st.caption(f"本機使用者 ID:{UID}") | |
mood = st.slider("今天的心情(1-10)", 1, 10, 7, key="mood") | |
emotion = st.selectbox("主要情緒", ["😊 開心","😔 失落","😰 焦慮","😌 平靜","🤗 感恩"], key="emotion") | |
entry = st.text_area("寫下今天的想法", height=140, placeholder="你最近在思考什麼?今天最感謝的一件事是?", key="entry") | |
c1, c2, c3 = st.columns(3) | |
with c1: | |
if st.button("💾 儲存日記", key="save_journal"): | |
if entry.strip(): | |
item = { | |
"time": datetime.now().strftime("%Y-%m-%d %H:%M"), | |
"mood": st.session_state.mood, | |
"emotion": st.session_state.emotion, | |
"text": entry.strip() | |
} | |
st.session_state.journal.append(item) | |
append_journal(item) | |
st.success("已儲存 🌟") | |
else: | |
st.warning("請先寫點內容") | |
with c2: | |
if st.button("⬇️ 匯出 CSV", key="export_journal"): | |
out_csv = export_journal_csv() | |
if out_csv and os.path.exists(out_csv): | |
with open(out_csv, "rb") as f: | |
st.download_button("下載日記 CSV", f, file_name="journal_export.csv") | |
else: | |
st.warning("目前沒有日記可匯出") | |
with c3: | |
if st.button("🗑️ 清空全部日記", key="clear_journal"): | |
save_all_journal([]) | |
st.session_state.journal = [] | |
st.success("全部日記已清空") | |
if st.session_state.journal: | |
st.markdown("---") | |
st.markdown("**最近紀錄(新→舊)**") | |
for j in reversed(st.session_state.journal[-50:]): | |
st.markdown(f"- **{j['time']} · {j['emotion']} · 心情 {j['mood']}** \n{j['text']}") | |
if st.button("🧠 生成 7 日回顧建議", key="review7"): | |
recent = "\n".join([j["text"] for j in st.session_state.journal[-20:]]) | |
qj = f"這是我最近的日記重點:\n{recent}\n請用溫柔務實的語氣,總結 3 點觀察,並給 3 個可行的小建議。" | |
ans, _ = rag_answer(qj, domain="Numerology", k=2) # 用較中性的語料 | |
st.markdown(ans) | |
# ---- AI 心靈導師 ---- | |
with tab4: | |
st.subheader("🤖 AI 心靈導師") | |
st.caption("此功能提供一般性支持與自我照顧建議,非醫療或心理治療之替代。若有危機,請立即尋求在地緊急支援。") | |
topic = st.selectbox( | |
"主題", | |
["General", "Stress & Anxiety", "Relationships", "Work & Burnout", "Self-esteem", "Grief & Loss"], | |
index=0, | |
key="guide_topic" | |
) | |
user_msg = st.text_area("想跟我說點什麼?", height=140, placeholder="把現在的感受、困擾或情境說給我聽…", key="guide_msg") | |
if st.button("💬 取得支持", key="guide_go"): | |
if not user_msg.strip(): | |
st.warning("請先輸入一段想說的話") | |
else: | |
with st.spinner("AI 正在整理回應…"): | |
reply, refs = guide_answer(user_msg, topic) | |
st.markdown(reply) | |
if refs: | |
with st.expander("參考段落(RAG 來源)"): | |
for i, r in enumerate(refs, 1): | |
st.markdown(f"**#{i}** · score {r['score']:.3f}") | |
st.write(r["text"]) | |
st.markdown("---") | |
# ========== Footer ========== | |
st.markdown("<hr>", unsafe_allow_html=True) | |
st.caption("🧭 SoulCompass · demo MVP · RAG + LLM powered") |