# app.py - Missionary Training Agent: Church Planting # Pedagogical training agent - teaches, questions, evaluates, guides # Vector search: TF-IDF (scikit-learn) import os import re import json import logging import sys import io import pickle import threading from pathlib import Path from typing import List, Dict, Tuple, Generator import requests import pdfplumber from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import numpy as np import gradio as gr sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8") logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) GROQ_API_KEY = os.getenv("GROQ_API_KEY") if not GROQ_API_KEY: raise ValueError("Set GROQ_API_KEY in Hugging Face Secrets.") GROQ_CHAT_URL = "https://api.groq.com/openai/v1/chat/completions" GROQ_MODEL = "llama-3.3-70b-versatile" BASE_DIR = Path(".") _PREBUILT_INDEX = Path("/app/index_chinese_leadership") _RUNTIME_INDEX = Path("/tmp/index_chinese_leadership") if _PREBUILT_INDEX.exists() and any(_PREBUILT_INDEX.glob("*.pkl")): INDEX_DIR = _PREBUILT_INDEX logger.info(f"Using pre-built index: {INDEX_DIR}") else: INDEX_DIR = _RUNTIME_INDEX INDEX_DIR.mkdir(exist_ok=True) logger.info(f"Using runtime index: {INDEX_DIR}") # ========================= # CHURCH PLANTING CURRICULUM # 8 training modules with learning objectives # ========================= DISCIPLINE = { "key": "21_planting_church", "name_en": "Church Planting", "name_zh": "植堂建立", "name_pt": "Plantação de Igrejas", } CURRICULUM = { "1": { "title": "Biblical Foundation for Church Planting", "title_zh": "教会植立的圣经基础", "objectives": [ "Identify the biblical mandate for church planting from the New Testament", "Explain how the Great Commission (Matthew 28:18-20) and Acts 1:8 form the foundation", "Distinguish between a church planting movement and a mission station approach", ], "key_questions": [ "What is the biblical basis for planting new churches rather than growing existing ones?", "How does the book of Acts model church planting strategy?", "What makes a group of believers a 'church' in the New Testament sense?", ] }, "2": { "title": "Theology of the Church", "title_zh": "教会神学", "objectives": [ "Define the marks of a true church (Word, Sacraments, Discipline)", "Explain the difference between the universal and local church", "Articulate why each unreached community needs its own contextualized church", ], "key_questions": [ "What are the essential marks of a biblical church?", "Why can't one large church serve an entire city or region?", "How does the body of Christ metaphor shape church planting philosophy?", ] }, "3": { "title": "Understanding the Context", "title_zh": "了解处境", "objectives": [ "Conduct a basic community exegesis before planting", "Identify spiritual, social, and cultural barriers to the Gospel", "Use the 10/40 Window and people group data for strategic planning", ], "key_questions": [ "How do you research and understand a community before planting a church?", "What are the key questions to ask about worldview, religion, and social structure?", "How does context shape the form (not the substance) of the church?", ] }, "4": { "title": "Evangelism and Disciple-Making", "title_zh": "传福音与门徒训练", "objectives": [ "Distinguish between evangelism, discipleship, and church planting", "Explain the relationship between personal conversion and community formation", "Apply Discovery Bible Study (DBS) as a church planting tool", ], "key_questions": [ "What comes first — evangelism, discipleship, or church planting?", "How do you move from a Bible study group to a planted church?", "What does it mean to make disciples 'of all nations' not just individuals?", ] }, "5": { "title": "Leadership Development", "title_zh": "领袖培养", "objectives": [ "Identify the qualifications for church elders and deacons (1 Timothy 3, Titus 1)", "Explain the Timothy model of leadership multiplication", "Contrast foreign-led and locally-led church planting", ], "key_questions": [ "What are the biblical qualifications for church leadership?", "How do you identify and train local leaders from within the planting context?", "When should a church planter hand over leadership to local elders?", ] }, "6": { "title": "Contextualization Without Compromise", "title_zh": "处境化而不妥协", "objectives": [ "Define contextualization and distinguish it from syncretism", "Apply the form/substance distinction to worship, preaching, and community", "Evaluate insider movements critically from a biblical perspective", ], "key_questions": [ "What is the difference between contextualization and syncretism?", "How do you plant a church that is both culturally rooted and biblically faithful?", "What are the limits of cultural adaptation in church planting?", ] }, "7": { "title": "Church Planting Movements (CPM/DMM)", "title_zh": "教会植立运动", "objectives": [ "Explain the principles behind Church Planting Movements (CPM)", "Evaluate Disciple Making Movements (DMM) theologically", "Identify the 10 universal elements of healthy CPMs", ], "key_questions": [ "What are the key characteristics of a Church Planting Movement?", "What are the theological strengths and weaknesses of DMM methodology?", "How do you plant a church that will reproduce other churches?", ] }, "8": { "title": "Persecution, Suffering, and Resilience", "title_zh": "逼迫、受苦与坚韧", "objectives": [ "Understand persecution as a biblical expectation not an exception", "Apply principles for planting and sustaining underground/house churches", "Develop a theology of suffering rooted in the cross", ], "key_questions": [ "How does the New Testament prepare church planters for persecution?", "What structures make a church resilient under persecution?", "How does suffering shape the identity and mission of a planted church?", ] }, } # ========================= # THEOLOGICAL GUARDRAIL # ========================= FAITH_GUARDRAIL = ( "THEOLOGICAL GUARDRAIL:\n" "- Scripture is the inspired, inerrant, sufficient Word of God.\n" "- Trinity: Father, Son, Holy Spirit — co-equal.\n" "- Jesus Christ: incarnate, crucified, resurrected, ascended. Only Savior.\n" "- Salvation: grace alone, faith alone, Christ alone.\n" "- Church planting must plant BIBLICAL churches — not just cultural gatherings.\n" "- No syncretism with Confucianism, Buddhism, Islam, Taoism, or folk religion.\n" "- Contextualize the FORM — never the SUBSTANCE of the Gospel.\n" "- Gospel always includes: sin, judgment, cross, resurrection, repentance, faith.\n" "- In persecution contexts: do not compromise truth for safety.\n" "- CPM/DMM methods must be evaluated biblically — not just pragmatically.\n" ) FAITH_TEXT = ( "Evangelical Faith Declaration. Scripture inspired inerrant sufficient. " "Trinity Father Son Holy Spirit. Jesus incarnate crucified resurrected ascended only mediator. " "Salvation grace through faith. Church planting biblical mandate Great Commission. " "No syncretism. Gospel sin judgment cross resurrection repentance faith. " "Contextualize form not substance. CPM DMM church planting movement disciple making. " "Persecution suffering resilience house church underground church. " "Local leadership elders deacons Timothy model multiplication." ) # ========================= # TF-IDF STORE # ========================= class TFIDFStore: def __init__(self, store_path: Path): self.store_path = store_path self.documents: List[str] = [] self.metadatas: List[Dict] = [] self.vectorizer = None self.matrix = None self._load() def _load(self): if self.store_path.exists(): try: with open(self.store_path, "rb") as f: data = pickle.load(f) self.documents = data.get("documents", []) self.metadatas = data.get("metadatas", []) self.vectorizer = data.get("vectorizer") self.matrix = data.get("matrix") logger.info(f"Loaded {len(self.documents)} chunks from {self.store_path.name}") except Exception as e: logger.warning(f"Could not load {self.store_path.name}: {e}") self.documents, self.metadatas, self.vectorizer, self.matrix = [], [], None, None def _save(self): with open(self.store_path, "wb") as f: pickle.dump({ "documents": self.documents, "metadatas": self.metadatas, "vectorizer": self.vectorizer, "matrix": self.matrix, }, f) def _rebuild(self): if not self.documents: self.vectorizer, self.matrix = None, None return self.vectorizer = TfidfVectorizer( max_features=20000, ngram_range=(1, 2), sublinear_tf=True, strip_accents="unicode", ) self.matrix = self.vectorizer.fit_transform(self.documents) def add(self, documents: List[str], metadatas: List[Dict]): self.documents.extend(documents) self.metadatas.extend(metadatas) self._rebuild() self._save() def count(self) -> int: return len(self.documents) def clear(self): self.documents, self.metadatas, self.vectorizer, self.matrix = [], [], None, None if self.store_path.exists(): self.store_path.unlink() def query(self, query_text: str, n_results: int = 6) -> Tuple[List[str], List[Dict]]: if not self.documents or self.vectorizer is None: return [], [] try: q_vec = self.vectorizer.transform([query_text]) scores = cosine_similarity(q_vec, self.matrix).flatten() top_idx = np.argsort(scores)[::-1][:n_results] docs = [self.documents[i] for i in top_idx if scores[i] > 0] metas = [self.metadatas[i] for i in top_idx if scores[i] > 0] return docs, metas except Exception as e: logger.warning(f"Query error: {e}") return [], [] def get_cp_store() -> TFIDFStore: return TFIDFStore(INDEX_DIR / "21_planting_church.pkl") faith_store = TFIDFStore(INDEX_DIR / "faith_guardrail.pkl") if faith_store.count() == 0: faith_store.add([FAITH_TEXT], [{"source": "faith_guardrail"}]) logger.info("Faith guardrail indexed.") # ========================= # TEXT PROCESSING # ========================= def clean_text(t: str) -> str: t = re.sub(r"[ \t]+", " ", t) t = re.sub(r"\n{3,}", "\n\n", t) return t.strip() def chunk_text(text: str, chunk_size: int = 1200, overlap: int = 200) -> List[str]: text = clean_text(text) if not text: return [] chunks, start = [], 0 while start < len(text): end = min(len(text), start + chunk_size) chunk = text[start:end].strip() if len(chunk) >= 250: chunks.append(chunk) start = end - overlap if end == len(text): break return chunks # ========================= # INDEXING # ========================= def index_church_planting(force: bool = False) -> str: store = get_cp_store() docs_dir = BASE_DIR / "docs" / "21_planting_church" if not docs_dir.exists(): docs_dir.mkdir(parents=True, exist_ok=True) return "Folder created. Add PDFs to docs/21_planting_church/" if not force and store.count() > 0: return f"Already indexed: {store.count()} chunks." if force: store.clear() store = get_cp_store() pdfs = sorted(docs_dir.glob("*.pdf")) if not pdfs: return "No PDFs found in docs/21_planting_church/" total = 0 all_chunks, all_metas = [], [] for pdf_path in pdfs: try: logger.info(f"Indexing: {pdf_path.name}") with pdfplumber.open(pdf_path) as doc: for i, page in enumerate(doc.pages[:80], start=1): txt = page.extract_text() or "" if len(txt.strip()) < 200: continue chunks = chunk_text(txt) if chunks: all_chunks.extend(chunks) all_metas.extend([ {"book": pdf_path.name, "page": i, "chunk": k+1} for k in range(len(chunks)) ]) total += len(chunks) except Exception as e: logger.error(f"Error indexing {pdf_path.name}: {e}") if all_chunks: store.add(all_chunks, all_metas) logger.info(f"Saved {total} chunks for church planting") return f"{total} chunks from {len(pdfs)} PDF(s)." # Background indexing _indexing_done = threading.Event() _index_status = "Indexing in progress..." def _background_index(): global _index_status try: store = get_cp_store() if store.count() > 0: _index_status = f"Ready: {store.count()} chunks indexed." logger.info(f"Index already present: {store.count()} chunks.") else: result = index_church_planting(force=False) _index_status = f"Indexed: {result}" logger.info(f"Background indexing: {result}") except Exception as e: _index_status = f"Indexing error: {e}" logger.error(f"Background index error: {e}") finally: _indexing_done.set() threading.Thread(target=_background_index, daemon=True).start() # ========================= # RETRIEVAL # ========================= def retrieve_context(query: str, n_results: int = 6) -> str: chunks = [] store = get_cp_store() if store.count() > 0: docs, _ = store.query(query, n_results=n_results) chunks.extend(docs) logger.info(f"Retrieved {len(docs)} chunks for: {query[:60]}") else: logger.warning("Church planting store is empty!") fd, _ = faith_store.query(query, n_results=1) if fd: chunks.append(f"[GUARDRAIL] {fd[0]}") return "\n\n---\n\n".join(chunks) # ========================= # LANGUAGE DETECTION # ========================= def detect_language(text: str) -> str: chinese = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') if chinese > 2: return "zh" pt = ["como", "que", "para", "nao", "uma", "igreja", "missao", "discipulo", "plantacao"] if sum(1 for w in pt if w in text.lower()) >= 2: return "pt" return "en" # ========================= # GROQ STREAMING # ========================= def groq_stream(messages: List[Dict], temperature: float = 0.4) -> Generator[str, None, None]: headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"} payload = { "model": GROQ_MODEL, "messages": messages, "temperature": temperature, "max_tokens": 4096, "stream": True, } try: with requests.post(GROQ_CHAT_URL, headers=headers, json=payload, stream=True, timeout=120) as r: if r.status_code != 200: yield f"\n\n[API Error {r.status_code}] {r.text}" return for line in r.iter_lines(decode_unicode=False): if not line: continue if isinstance(line, bytes): line = line.decode("utf-8", errors="replace") if line.startswith("data: "): data = line[6:].strip() if data == "[DONE]": break try: token = json.loads(data)["choices"][0].get("delta", {}).get("content", "") if token: yield token except Exception: continue except Exception as e: yield f"\n\n[Error] {str(e)}" # ========================= # TRAINING SYSTEM PROMPTS # ========================= def get_training_system_prompt(module_num: str, mode: str, lang: str) -> str: """ mode: 'teach' | 'question' | 'evaluate' | 'explore' """ module = CURRICULUM.get(module_num, CURRICULUM["1"]) objectives = "\n".join(f" - {o}" for o in module["objectives"]) if lang == "zh": role = f"你是一位经验丰富的宣教士培训导师,专门教授《植堂建立》。现在正在教授第{module_num}单元:{module['title_zh']}。" elif lang == "pt": role = f"Você é um treinador missionário experiente ensinando Plantação de Igrejas. Módulo {module_num}: {module['title']}." else: role = f"You are an experienced missionary trainer teaching Church Planting. Current module {module_num}: {module['title']}." base = f"""{role} THEOLOGICAL GUARDRAIL: {FAITH_GUARDRAIL} LEARNING OBJECTIVES FOR THIS MODULE: {objectives} KNOWLEDGE BASE: Use the retrieved content below as your primary teaching material. Do NOT mention PDF filenames or page numbers in your responses. Build your teaching from the retrieved content, supplemented by biblical references. """ if mode == "teach": if lang == "zh": instruction = """ 教学模式 - 你的任务: 1. 用清晰、结构化的方式教授这个模块的核心内容 2. 从圣经依据开始,然后讲解原则,最后给出实践应用 3. 使用具体的例子和故事来说明要点 4. 在结尾提出1-2个反思问题,帮助学员内化所学内容 5. 语气亲切但有权威,像一位有经验的宣教导师 6. 回答长度:600-1000字 """ elif lang == "pt": instruction = """ MODO ENSINO - Sua tarefa: 1. Ensine o conteúdo central deste módulo de forma clara e estruturada 2. Comece com o fundamento bíblico, depois os princípios, depois aplicação prática 3. Use exemplos concretos e histórias missionárias reais 4. Termine com 1-2 perguntas de reflexão para o aluno internalizar o conteúdo 5. Tom: caloroso mas com autoridade, como um mentor missionário experiente 6. Extensão: 600-1000 palavras """ else: instruction = """ TEACHING MODE - Your task: 1. Teach the core content of this module clearly and systematically 2. Start with biblical foundation, then principles, then practical application 3. Use concrete examples and real missionary stories to illustrate key points 4. End with 1-2 reflection questions to help the learner internalize the content 5. Tone: warm but authoritative, like an experienced missionary mentor 6. Length: 600-1000 words """ elif mode == "question": questions = "\n".join(f" {i+1}. {q}" for i, q in enumerate(module["key_questions"])) if lang == "zh": instruction = f""" 提问模式 - 你的任务: 1. 从以下关键问题中选择一个最适合当前学习进度的问题提问 2. 用简短的引导语介绍这个问题的重要性 3. 等待学员回答——不要直接给出答案 4. 本模块的关键问题: {questions} 提问后,等待学员回答。根据回答情况进行评估和反馈。 """ else: instruction = f""" QUESTIONING MODE - Your task: 1. Select one key question appropriate for the learner's current progress 2. Briefly introduce WHY this question matters for church planting ministry 3. Ask the question clearly and wait for the learner's response — do NOT give the answer yet 4. Key questions for this module: {questions} After asking, wait for the learner's answer. Then evaluate and provide feedback. """ elif mode == "evaluate": if lang == "zh": instruction = """ 评估模式 - 你的任务: 1. 评估学员的回答是否理解了学习目标 2. 具体指出回答中哪些地方是正确的(鼓励) 3. 温和地纠正任何神学偏差或不完整的理解 4. 补充学员遗漏的重要内容 5. 给出改进建议,帮助学员进一步思考 6. 结尾引导学员进入下一个学习步骤 """ else: instruction = """ EVALUATION MODE - Your task: 1. Evaluate whether the learner's response demonstrates understanding of the objectives 2. Specifically affirm what is correct in their answer (encouragement is crucial) 3. Gently correct any theological errors or incomplete understanding 4. Add important content the learner missed 5. Give constructive suggestions to deepen their thinking 6. Guide the learner toward the next learning step """ else: # explore if lang == "zh": instruction = """ 探索模式 - 你的任务: 1. 回答学员提出的具体问题,深入探讨他们感兴趣的方向 2. 将回答与本模块的学习目标联系起来 3. 鼓励批判性思维——不回避复杂问题 4. 在适当时候挑战学员更深入地思考 """ else: instruction = """ EXPLORATION MODE - Your task: 1. Answer the learner's specific question with depth and precision 2. Connect your answer back to the module's learning objectives 3. Encourage critical thinking — do not shy away from complex questions 4. Challenge the learner to think deeper where appropriate 5. Length: match the depth of the question (200-800 words) """ return base + instruction # ========================= # INTENT DETECTION # ========================= def detect_intent(text: str, history: List[Dict]) -> str: """Detect whether the user wants to learn, answer a question, or explore.""" t = text.lower().strip() # Explicit requests for teaching teach_triggers = [ "teach me", "explain", "what is", "tell me about", "start", "begin", "introduce", "overview", "lesson", "learn about", "ensina", "explica", "o que é", "começar", "introdução", "教我", "解释", "什么是", "开始", "介绍", ] question_triggers = [ "ask me", "test me", "quiz me", "question", "evaluate", "pergunta", "me avalia", "teste", "问我", "测试", "考我", ] next_triggers = [ "next", "continue", "next module", "next topic", "move on", "próximo", "continuar", "próxima", "下一个", "继续", "下一课", ] if any(t.startswith(w) or w in t for w in teach_triggers): return "teach" if any(w in t for w in question_triggers): return "question" if any(w in t for w in next_triggers): return "next_module" # If history has a question from the AI, this is likely an answer if history and history[-1]["role"] == "assistant": last_ai = history[-1]["content"].lower() if "?" in last_ai and len(t) > 20: return "evaluate" # Default: explore return "explore" # ========================= # KB STATUS # ========================= def get_kb_status() -> str: store = get_cp_store() docs_dir = BASE_DIR / "docs" / "21_planting_church" pdfs = list(docs_dir.glob("*.pdf")) if docs_dir.exists() else [] lines = [ "=== KNOWLEDGE BASE STATUS ===", f"Discipline: Church Planting / 植堂建立 / Plantação de Igrejas", f"Index path: {INDEX_DIR}", f"PDFs found: {len(pdfs)} file(s)", f"Chunks indexed: {store.count()}", f"Faith guardrail: {faith_store.count()} chunk(s)", "", ] if pdfs: lines.append("PDF files:") for p in pdfs: lines.append(f" - {p.name} ({p.stat().st_size // 1024} KB)") else: lines.append("No PDFs found. Add PDFs to docs/21_planting_church/") if not _indexing_done.is_set(): lines.append("\n[Indexing in progress...]") elif store.count() == 0: lines.append("\nWARNING: No chunks indexed. Click Reindex.") else: lines.append(f"\nStatus: READY - {store.count()} chunks available for training") return "\n".join(lines) # ========================= # MAIN CHAT FUNCTION # ========================= # Training state: track current module per session _session_module = {} def chat_fn(user_input, history, current_module, interface_lang): if history is None: history = [] user_input = (user_input or "").strip() if not user_input: yield history, "", current_module return lang = detect_language(user_input) intent = detect_intent(user_input, history) # Handle module navigation if intent == "next_module": next_num = str(int(current_module) + 1) if next_num in CURRICULUM: current_module = next_num mod = CURRICULUM[current_module] if lang == "zh": nav_msg = f"进入第{current_module}单元:**{mod['title_zh']}**\n\n准备好开始了吗?输入'教我'开始学习,或者输入'问我问题'进行自测。" elif lang == "pt": nav_msg = f"Avançando para o Módulo {current_module}: **{mod['title']}**\n\nDigite 'ensina' para começar a aula ou 'me pergunta' para ser avaliado." else: nav_msg = f"Moving to Module {current_module}: **{mod['title']}**\n\nType 'teach me' to start the lesson or 'ask me a question' to be tested." history = history + [ {"role": "user", "content": user_input}, {"role": "assistant", "content": nav_msg} ] yield history, "", current_module return else: if lang == "zh": completion = "恭喜!你已完成《植堂建立》课程的所有8个单元!愿上帝使用你去建立能繁殖的教会,直到祂再来。" elif lang == "pt": completion = "Parabéns! Você completou todos os 8 módulos de Plantação de Igrejas! Que Deus use você para plantar igrejas reprodutoras até Ele voltar." else: completion = "Congratulations! You have completed all 8 modules of the Church Planting training! May God use you to plant reproducing churches until He returns." history = history + [ {"role": "user", "content": user_input}, {"role": "assistant", "content": completion} ] yield history, "", current_module return # Retrieve context from knowledge base context = retrieve_context(user_input + " " + CURRICULUM[current_module]["title"]) # Build messages system_prompt = get_training_system_prompt(current_module, intent, lang) messages = [ {"role": "system", "content": system_prompt}, ] # Include last 6 turns of history for continuity for turn in history[-6:]: messages.append({"role": turn["role"], "content": turn["content"]}) # Add context + current input if context: user_msg = ( f"RETRIEVED KNOWLEDGE BASE CONTENT:\n{context}\n\n" f"---\n\nLEARNER INPUT: {user_input}" ) else: user_msg = f"LEARNER INPUT: {user_input}\n\n(Note: Knowledge base is empty — respond from theological training knowledge)" messages.append({"role": "user", "content": user_msg}) history = history + [ {"role": "user", "content": user_input}, {"role": "assistant", "content": ""} ] partial = "" for token in groq_stream(messages, temperature=0.4): partial += token history[-1]["content"] = partial yield history, "", current_module # ========================= # CSS # ========================= custom_css = """ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700&family=Source+Sans+3:wght@400;600;700&display=swap'); body, .gradio-container { background: #f5f0e8 !important; font-family: 'Source Sans 3', sans-serif !important; } /* Tabs */ .tab-nav { background: #6b0f0f !important; border-bottom: 3px solid #c8a84b !important; padding: 0 8px !important; } .tab-nav button { color: #f0e6cc !important; background: transparent !important; font-weight: 600 !important; font-size: 0.92rem !important; padding: 11px 16px !important; border: none !important; border-bottom: 3px solid transparent !important; margin-bottom: -3px !important; } .tab-nav button:hover { color: #f5c842 !important; background: rgba(255,255,255,0.08) !important; } .tab-nav button.selected { color: #f5c842 !important; border-bottom: 3px solid #f5c842 !important; font-weight: 700 !important; } /* Form labels */ label > span, .label-wrap span, .block > label > span { color: #2c1a0e !important; font-weight: 700 !important; font-size: 0.95rem !important; } /* Module selector */ .radio-group label { background: #ffffff !important; color: #2c1a0e !important; font-size: 0.88rem !important; font-weight: 500 !important; border: 1px solid #d4a86a !important; border-radius: 6px !important; padding: 5px 10px !important; margin: 2px 0 !important; } .radio-group label:hover { background: #fef3d8 !important; border-color: #8b0000 !important; } .radio-group input:checked ~ span { color: #6b0f0f !important; font-weight: 700 !important; } /* Markdown */ .prose h3, .md h3 { color: #6b0f0f !important; font-family: 'Cinzel', serif !important; font-size: 0.95rem !important; border-bottom: 1px solid #c8a84b !important; padding-bottom: 4px !important; } .prose p, .md p, .prose em, .md em { color: #5a3a1a !important; font-size: 0.82rem !important; } /* Training mode display */ #mode-display { background: #6b0f0f !important; padding: 8px 14px !important; border-radius: 8px !important; border-left: 4px solid #c8a84b !important; } #mode-display * { color: #f5c842 !important; font-size: 0.9rem !important; } /* Chatbot: white bg, dark text */ .chatbot { background: #ffffff !important; border: 2px solid #c8a84b !important; border-radius: 12px !important; } .message.user, [data-testid="user-message"] { background: #fef3d8 !important; color: #2c1a0e !important; border-radius: 10px !important; padding: 10px 14px !important; font-size: 0.95rem !important; line-height: 1.6 !important; border-left: 3px solid #c8a84b !important; } .message.bot, [data-testid="bot-message"] { background: #ffffff !important; color: #1a0a0a !important; border-radius: 10px !important; padding: 10px 14px !important; font-size: 0.95rem !important; line-height: 1.8 !important; } .chatbot .message, .chatbot p, .chatbot span, .chatbot li, .chatbot strong { color: #1a0a0a !important; } /* Textbox */ textarea, input[type=text] { background: #ffffff !important; color: #1a0a0a !important; border: 2px solid #c8a84b !important; border-radius: 8px !important; font-size: 0.95rem !important; } textarea:focus { border-color: #8b0000 !important; box-shadow: 0 0 0 3px rgba(139,0,0,0.12) !important; } textarea::placeholder { color: #999 !important; } /* Buttons */ button.primary, button[variant=primary] { background: linear-gradient(135deg, #8b0000, #c0392b) !important; color: #ffffff !important; font-weight: 700 !important; border: none !important; border-radius: 8px !important; } button.secondary, button[variant=secondary] { background: #ffffff !important; color: #6b0f0f !important; font-weight: 600 !important; border: 2px solid #c8a84b !important; border-radius: 8px !important; } button.secondary:hover { background: #fef3d8 !important; } /* KB status */ .block textarea[readonly] { background: #0f0505 !important; color: #7fff7f !important; font-family: 'Courier New', monospace !important; font-size: 0.82rem !important; border: 1px solid #3a1a1a !important; } """ # ========================= # INITIAL MESSAGES # ========================= WELCOME_EN = [{"role": "assistant", "content": ( "Welcome to the **Church Planting Training Program**!\n\n" "This course has **8 modules** covering the full arc of church planting ministry:\n" "Biblical foundation → Theology → Context → Evangelism → Leadership → " "Contextualization → CPM/DMM → Persecution & Resilience\n\n" "**How to use this trainer:**\n" "- Type **'teach me'** to receive instruction on the current module\n" "- Type **'ask me a question'** to be tested on what you've learned\n" "- Type **'next module'** to advance to the next topic\n" "- Ask any question to explore the topic freely\n\n" "You are currently on **Module 1: Biblical Foundation for Church Planting**.\n\n" "Ready to begin? Type **'teach me'** to start!" )}] WELCOME_ZH = [{"role": "assistant", "content": ( "欢迎来到**植堂建立培训课程**!\n\n" "本课程共有**8个单元**,涵盖植堂事工的完整内容:\n" "圣经基础 → 教会神学 → 了解处境 → 传福音与门徒训练 → " "领袖培养 → 处境化 → CPM/DMM → 逼迫与坚韧\n\n" "**如何使用本培训系统:**\n" "- 输入**'教我'**接受当前单元的教学\n" "- 输入**'问我问题'**接受测试\n" "- 输入**'下一个单元'**进入下一课\n" "- 直接提问可自由探索主题\n\n" "您目前在**第1单元:教会植立的圣经基础**。\n\n" "准备好了吗?输入**'教我'**开始!" )}] WELCOME_PT = [{"role": "assistant", "content": ( "Bem-vindo ao **Programa de Treinamento em Plantação de Igrejas**!\n\n" "Este curso tem **8 módulos** cobrindo toda a jornada missionária:\n" "Fundamento bíblico → Teologia → Contexto → Evangelismo → Liderança → " "Contextualização → CPM/DMM → Perseguição e Resiliência\n\n" "**Como usar este treinador:**\n" "- Digite **'ensina'** para receber instrução no módulo atual\n" "- Digite **'me pergunta'** para ser avaliado\n" "- Digite **'próximo módulo'** para avançar\n" "- Faça qualquer pergunta para explorar livremente\n\n" "Você está no **Módulo 1: Fundamento Bíblico para Plantação de Igrejas**.\n\n" "Pronto? Digite **'ensina'** para começar!" )}] # ========================= # UI # ========================= with gr.Blocks(title="Church Planting Training") as demo: current_module_state = gr.State(value="1") lang_state = gr.State(value="English") # ── Header ── gr.HTML( '
8 modules · EN / ZH / PT · AI-powered missionary formation
' 'Module {k}: {v["title"]} / {v["title_zh"]}
' + "".join([f'✓ {o}
' for o in v["objectives"]]) + 'Add PDFs to: docs/21_planting_church/
' 'Evangelical Faith Declaration — Church Planting Focus
' 'Church Planting Guardrails
' '▹ Plant BIBLICAL churches — not just cultural gatherings.
' '▹ No syncretism with Confucianism, Buddhism, Islam, Taoism, or folk religion.
' '▹ Contextualize the FORM — never the SUBSTANCE of the Gospel.
' '▹ Gospel always includes: sin, judgment, cross, resurrection, repentance, faith.
' '▹ CPM/DMM methods must be evaluated biblically — not just pragmatically.
' '▹ Local leadership: biblical qualifications (1 Timothy 3, Titus 1) are non-negotiable.
' '▹ In persecution: do not compromise truth for safety or cultural acceptance.
' '