"""
LegacyScribe — Family Memory Agent
Gradio Space entry point
"""
import gradio as gr
import json
import os
from pathlib import Path
from gradio_client import Client
# # ── Model loading ─────────────────────────────────────────────────────────────
# MODEL_REPO = os.environ.get("MODEL_REPO", "build-small-hackathon/legacyscribe-Qwen3.5-9B.Q4_K_M")
# MODEL_FILE = os.environ.get("MODEL_FILE", "Qwen3.5-9B.Q4_K_M.gguf")
# N_CTX = 4096
# N_GPU_LAYERS = int(os.environ.get("N_GPU_LAYERS", "0"))
BACKEND_URL = os.environ.get("BACKEND_URL", "https://safalnarshing-legacyscribe-backend.hf.space")
# print(f"Loading model from {MODEL_REPO}/{MODEL_FILE}...")
# model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
# llm = Llama(model_path=model_path, n_ctx=N_CTX, n_gpu_layers=N_GPU_LAYERS, verbose=False)
# print("Model ready.")
_client = None
def get_client():
global _client
if _client is None:
_client = Client(BACKEND_URL)
return _client
# ── System prompts ────────────────────────────────────────────────────────────
SYSTEM_PROMPTS = {
"questioner": (
"You are a gentle memory guide helping an elderly person tell their life story. "
"Ask exactly one warm, open follow-up question. Never ask more than one question. "
"Be patient, kind, and culturally sensitive to Nepali and South Asian contexts."
),
"extractor": (
"You are an extractor agent. Given a memory fragment, extract structured information "
"as JSON with keys relevant to the content (who, when, where, what, emotion). "
"Output only valid JSON, nothing else."
),
"arcdetector": (
"You are an arc detector agent. Given a memory fragment, identify the narrative stage. "
"Output one word only: setup, tension, turn, or meaning."
),
"publisher": (
"You are a publisher agent. Given memory notes, synthesize them into a single warm, "
"narrative paragraph suitable for a family memory book. Write in first person. "
"Use natural, unhurried language. Output only the paragraph, nothing else."
),
}
def call_agent(agent: str, user_text: str, max_tokens: int = -1, temp: float = -1.0) -> str:
try:
return get_client().predict(
agent,
user_text,
float(max_tokens),
float(temp),
api_name="/predict",
)
except Exception as e:
print(f"Backend error: {e}")
return ""
# ── Session state ─────────────────────────────────────────────────────────────
def init_state():
return {"turns": [], "all_notes": [], "chapter": "", "chapter_num": 1}
def process_turn(user_input: str, state: dict):
if not user_input.strip():
return state, "", "", "", []
arc = call_agent("arcdetector", user_input, max_tokens=10, temp=0.1)
arc = arc.lower().strip()
if arc not in ("setup", "tension", "turn", "meaning"):
arc = "setup"
try:
raw = call_agent("extractor", user_input, max_tokens=200, temp=0.1)
notes = json.loads(raw)
note_lines = [f"{k}: {v}" for k, v in notes.items() if v]
except Exception:
note_lines = [user_input[:120]]
state["all_notes"].extend(note_lines)
history_ctx = "\n".join(f"Memory: {t['user']}" for t in state["turns"][-3:])
question_ctx = f"{history_ctx}\nMemory: {user_input}" if history_ctx else f"Memory: {user_input}"
question = call_agent("questioner", question_ctx, max_tokens=80, temp=0.7)
chapter = state["chapter"]
prev_chapter = chapter
if len(state["all_notes"]) >= 3:
notes_text = "\n".join(f"{i+1}. {n}" for i, n in enumerate(state["all_notes"][-6:]))
chapter = call_agent("publisher", f"Notes:\n{notes_text}", max_tokens=400, temp=0.4)
if chapter != prev_chapter and prev_chapter:
state["chapter_num"] += 1
state["chapter"] = chapter
state["turns"].append({"user": user_input, "question": question, "arc": arc, "notes": note_lines})
return state, question, arc, chapter, note_lines
# ── HTML builders ─────────────────────────────────────────────────────────────
ARC_ICONS = {"setup": "◎", "tension": "◈", "turn": "◉", "meaning": "✦"}
ARC_LABELS = {"setup": "Setting the scene", "tension": "Something changed", "turn": "A new direction", "meaning": "What it meant"}
def build_chat_html(turns):
if not turns:
return '''
📖
Tell me a memory — anything at all. A meal, a person, a festival, a place. I am here to listen.
'''
html = '
'
for t in turns:
arc = t.get("arc", "setup")
icon = ARC_ICONS.get(arc, "◎")
label = ARC_LABELS.get(arc, arc)
html += f'''
{t["user"]}
{icon}{label}
{t["question"]}
'''
html += '
'
html += ''
return html
def build_notes_html(notes):
if not notes:
return ''
html = '
Memory fragments extracted
'
for n in notes[-5:]:
parts = n.split(": ", 1)
if len(parts) == 2:
html += f'
{parts[0]}{parts[1]}
'
else:
html += f'
{n}
'
html += '
'
return html
LINE_H = 28 # px per ruled line
def build_page_html(chapter, chapter_num):
if not chapter:
return '''
— waiting for your story —
Chapter One
Your memories are gathering. Share a few more and I will begin to write.
'''
words = chapter.split()
lines, current = [], []
chars = 0
for w in words:
if chars + len(w) + 1 > 52:
lines.append(" ".join(current))
current, chars = [w], len(w)
else:
current.append(w)
chars += len(w) + 1
if current:
lines.append(" ".join(current))
text_html = ""
for line in lines:
text_html += f'
{line}
'
return f'''
Chapter {chapter_num}
{text_html}
'''
# ── CSS / HEAD ─────────────────────────────────────────────────────────────────
HEAD = """
"""
INTRO_JS = """
() => {
/* Pure animation — no interaction. Just remove overlay from DOM after CSS is done. */
/* Total: 3.5s delay + 0.9s fade = 4.4s. Add small buffer. */
setTimeout(function() {
var el = document.getElementById('ls-intro');
if (el) el.style.display = 'none';
}, 4600);
}
"""
# ── Gradio app ─────────────────────────────────────────────────────────────────
with gr.Blocks() as demo:
state = gr.State(init_state())
# ── Book intro overlay — pure CSS animation, no interaction ─────────────────
gr.HTML("""
LegacyScribe
Every family has a story worth keeping
✦ · ✦
LegacyScribe
A Family Memory Journal
· 2025 ·
Family Memory Agent
""")
# ── Header ──
gr.HTML("""
LegacyScribe.
Every family has a story worth keeping
Off the GridQwen3.5 · 9BLoRA Fine-tunedNepali · English5-Agent PipelineBuild Small 2025
""")
# ── Main two-column layout ──
with gr.Row(elem_classes=["ls-main"]):
# Left — conversation
with gr.Column(elem_classes=["ls-conversation"]):
gr.HTML('
Your memory
')
chat_display = gr.HTML(build_chat_html([]))
notes_display = gr.HTML("")
with gr.Column(elem_classes=["ls-input-wrap"]):
user_input = gr.Textbox(
placeholder="Tell me about a person, a festival, a meal, a place — anything you remember…",
lines=3,
show_label=False,
container=False,
)
with gr.Row(elem_classes=["ls-btn-row"]):
submit_btn = gr.Button("Share this memory →", elem_classes=["ls-btn", "ls-btn-primary"])
clear_btn = gr.Button("Start over", elem_classes=["ls-btn", "ls-btn-secondary"])
thinking = gr.HTML(
'
'
''
' Listening and writing…
'
)
# Right — memory book
with gr.Column(elem_classes=["ls-book"]):
gr.HTML('