""" 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 Grid Qwen3.5 · 9B LoRA Fine-tuned Nepali · English 5-Agent Pipeline Build 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( '' ) # Right — memory book with gr.Column(elem_classes=["ls-book"]): gr.HTML('
Your memory book
') page_display = gr.HTML(build_page_html("", 1)) def export_memory(state): if not state["chapter"]: return gr.update(visible=False) content = "# My Memory Book\n\n" content += f"## Chapter {state['chapter_num']}\n\n" content += state["chapter"] + "\n\n---\n\n" content += "### Memory fragments\n\n" content += "\n".join(f"- {n}" for n in state["all_notes"]) import tempfile, pathlib path = pathlib.Path(tempfile.gettempdir()) / "legacyscribe_memory.txt" path.write_text(content, encoding="utf-8") return gr.update(visible=True, value=str(path)) dl_btn = gr.Button("↓ Download memory book", elem_classes=["ls-dl-btn"]) dl_output = gr.File(visible=False, label="Your memory book") dl_btn.click(fn=export_memory, inputs=[state], outputs=[dl_output]) # ── Footer ── gr.HTML(""" """) # ── Handlers ── def on_submit(user_text, state): if not user_text.strip(): return (state, build_chat_html(state["turns"]), "", build_page_html(state["chapter"], state["chapter_num"]), "") new_state, question, arc, chapter, notes = process_turn(user_text, state) return (new_state, build_chat_html(new_state["turns"]), build_notes_html(notes), build_page_html(chapter, new_state["chapter_num"]), "") def on_clear(): s = init_state() return s, build_chat_html([]), "", build_page_html("", 1), "" submit_btn.click( fn=on_submit, inputs=[user_input, state], outputs=[state, chat_display, notes_display, page_display, user_input], ) user_input.submit( fn=on_submit, inputs=[user_input, state], outputs=[state, chat_display, notes_display, page_display, user_input], ) clear_btn.click( fn=on_clear, inputs=[], outputs=[state, chat_display, notes_display, page_display, user_input], ) print("=== Launching Gradio demo ===") if __name__ == "__main__": demo.launch( server_name="0.0.0.0", server_port=7860, show_error=True, ssr_mode=False, )