Spaces:
Paused
Paused
| # 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( | |
| '<div style="background:linear-gradient(135deg,#6b0f0f,#a01a1a);padding:24px 28px;border-radius:12px;margin-bottom:12px;border:2px solid #c8a84b;">' | |
| '<h1 style="margin:0;color:#f5c842;font-size:1.6rem;font-family:Georgia,serif;font-weight:700;">Church Planting Training</h1>' | |
| '<h2 style="margin:4px 0 0;color:#ffffff;font-size:1.05rem;font-weight:400;">植堂建立培训 | Treinamento em Plantação de Igrejas</h2>' | |
| '<p style="margin:8px 0 0;color:#ffffff;font-size:0.9rem;font-style:italic;">8 modules · EN / ZH / PT · AI-powered missionary formation</p>' | |
| '</div>' | |
| ) | |
| with gr.Tabs(): | |
| # ── TAB 1: TRAINING ── | |
| with gr.Tab("Training / 培训 / Treinamento"): | |
| with gr.Row(): | |
| # Left panel | |
| with gr.Column(scale=1, min_width=240): | |
| gr.Markdown("### Module / 单元 / Módulo") | |
| module_radio = gr.Radio( | |
| choices=[f"{k}. {v['title']}" for k, v in CURRICULUM.items()], | |
| value="1. Biblical Foundation for Church Planting", | |
| label="Select Module / 选择单元 / Selecionar Módulo", | |
| interactive=True, | |
| ) | |
| gr.Markdown("---") | |
| lang_radio = gr.Radio( | |
| choices=["English", "中文", "Português"], | |
| value="English", | |
| label="Language / 语言 / Idioma", | |
| interactive=True, | |
| ) | |
| gr.Markdown("---") | |
| gr.Markdown( | |
| "*Commands / 命令 / Comandos:*\n\n" | |
| "▶ **teach me** / 教我 / ensina\n\n" | |
| "▶ **ask me** / 问我 / me pergunta\n\n" | |
| "▶ **next module** / 下一个 / próximo\n\n" | |
| "*Ask freely to explore any topic.*" | |
| ) | |
| # Right panel | |
| with gr.Column(scale=3): | |
| module_display = gr.Markdown( | |
| value="**Module 1:** Biblical Foundation for Church Planting", | |
| elem_id="mode-display", | |
| ) | |
| chatbot = gr.Chatbot( | |
| value=WELCOME_EN, | |
| height=520, | |
| show_label=False, | |
| ) | |
| msg = gr.Textbox( | |
| placeholder="Type 'teach me' to start · 输入'教我'开始 · Digite 'ensina' para começar", | |
| label="Your Input / 你的输入 / Sua Entrada", | |
| lines=2, | |
| ) | |
| with gr.Row(): | |
| btn_clear = gr.Button("Reset / 重置 / Reiniciar", variant="secondary") | |
| btn_send = gr.Button("Send / 发送 / Enviar", variant="primary") | |
| # ── TAB 2: CURRICULUM ── | |
| with gr.Tab("Curriculum / 课程 / Currículo"): | |
| gr.HTML( | |
| '<div style="background:#0f0505;padding:28px;border-radius:12px;border:2px solid #c8a84b;">' | |
| '<h2 style="color:#f5c842;margin:0 0 20px;font-family:Georgia,serif;">Church Planting — 8-Module Curriculum</h2>' | |
| + "".join([ | |
| f'<div style="margin-bottom:18px;padding:14px;background:#1a0505;border-radius:8px;border-left:3px solid #c8a84b;">' | |
| f'<p style="color:#f5c842;font-weight:700;margin:0 0 6px;font-size:1rem;">Module {k}: {v["title"]} / {v["title_zh"]}</p>' | |
| + "".join([f'<p style="color:#e0e0e0;margin:2px 0 2px 12px;font-size:0.88rem;">✓ {o}</p>' for o in v["objectives"]]) | |
| + '</div>' | |
| for k, v in CURRICULUM.items() | |
| ]) | |
| + '</div>' | |
| ) | |
| # ── TAB 3: KNOWLEDGE BASE ── | |
| with gr.Tab("Knowledge Base / 知识库"): | |
| gr.HTML( | |
| '<div style="background:#6b0f0f;padding:16px;border-radius:10px;border:2px solid #c8a84b;margin-bottom:12px;">' | |
| '<h3 style="color:#f5c842;margin:0;">KNOWLEDGE BASE — Church Planting PDFs</h3>' | |
| '<p style="color:#ffffff;margin:4px 0 0;font-size:0.9rem;">Add PDFs to: docs/21_planting_church/</p>' | |
| '</div>' | |
| ) | |
| kb_status = gr.Textbox( | |
| value=get_kb_status(), | |
| lines=20, interactive=False, | |
| label="Index Status" | |
| ) | |
| with gr.Row(): | |
| btn_refresh_kb = gr.Button("Refresh / 刷新", variant="secondary") | |
| btn_reindex = gr.Button("Reindex All / 重新索引", variant="secondary") | |
| # ── TAB 4: GUARDRAIL ── | |
| with gr.Tab("Guardrail / 神学守护"): | |
| gr.HTML( | |
| '<div style="background:#0f0505;padding:28px;border-radius:12px;border:2px solid #c8a84b;line-height:1.8;">' | |
| '<h2 style="color:#f5c842;margin:0 0 8px;font-family:Georgia,serif;">THEOLOGICAL GUARDRAIL / 神学守护</h2>' | |
| '<p style="color:#f5c842;font-weight:600;margin:0 0 16px;">Evangelical Faith Declaration — Church Planting Focus</p>' | |
| '<hr style="border:none;border-top:2px solid #c8a84b;margin:0 0 16px;">' | |
| '<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px 32px;margin-bottom:16px;">' | |
| '<span style="color:#f5c842;font-weight:700;">I. Scripture / 圣经</span>' | |
| '<span style="color:#f5c842;font-weight:700;">II. Trinity / 三一神</span>' | |
| '<span style="color:#f5c842;font-weight:700;">III. Jesus Christ / 耶稣基督</span>' | |
| '<span style="color:#f5c842;font-weight:700;">IV. Salvation / 救恩</span>' | |
| '<span style="color:#f5c842;font-weight:700;">V. The Church / 教会</span>' | |
| '<span style="color:#f5c842;font-weight:700;">VI. Great Commission / 大使命</span>' | |
| '</div>' | |
| '<hr style="border:none;border-top:2px solid #c8a84b;margin:0 0 16px;">' | |
| '<p style="color:#f5c842;font-weight:700;font-size:1rem;margin:0 0 8px;">Church Planting Guardrails</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ Plant BIBLICAL churches — not just cultural gatherings.</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ No syncretism with Confucianism, Buddhism, Islam, Taoism, or folk religion.</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ Contextualize the FORM — never the SUBSTANCE of the Gospel.</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ Gospel always includes: sin, judgment, cross, resurrection, repentance, faith.</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ CPM/DMM methods must be evaluated biblically — not just pragmatically.</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ Local leadership: biblical qualifications (1 Timothy 3, Titus 1) are non-negotiable.</p>' | |
| '<p style="color:#e8e8e8;margin:3px 0 3px 16px;font-size:0.9rem;">▹ In persecution: do not compromise truth for safety or cultural acceptance.</p>' | |
| '</div>' | |
| ) | |
| # ========================= | |
| # EVENT HANDLERS | |
| # ========================= | |
| def change_module(choice): | |
| num = choice.split(".")[0].strip() | |
| mod = CURRICULUM.get(num, CURRICULUM["1"]) | |
| return num, f"**Module {num}:** {mod['title']} / {mod['title_zh']}" | |
| def change_lang(choice): | |
| if choice == "中文": | |
| return WELCOME_ZH, "zh" | |
| if choice == "Português": | |
| return WELCOME_PT, "pt" | |
| return WELCOME_EN, "en" | |
| module_radio.change( | |
| fn=change_module, | |
| inputs=[module_radio], | |
| outputs=[current_module_state, module_display], | |
| ) | |
| lang_radio.change( | |
| fn=change_lang, | |
| inputs=[lang_radio], | |
| outputs=[chatbot, lang_state], | |
| ) | |
| def send(user_input, history, module, lang): | |
| for h, m, mod in chat_fn(user_input, history, module, lang): | |
| yield h, m, mod | |
| btn_send.click( | |
| fn=send, | |
| inputs=[msg, chatbot, current_module_state, lang_state], | |
| outputs=[chatbot, msg, current_module_state], | |
| ) | |
| msg.submit( | |
| fn=send, | |
| inputs=[msg, chatbot, current_module_state, lang_state], | |
| outputs=[chatbot, msg, current_module_state], | |
| ) | |
| def reset_chat(lang): | |
| if lang == "zh": | |
| return WELCOME_ZH, "1" | |
| if lang == "pt": | |
| return WELCOME_PT, "1" | |
| return WELCOME_EN, "1" | |
| btn_clear.click(fn=reset_chat, inputs=[lang_state], outputs=[chatbot, current_module_state]) | |
| btn_refresh_kb.click(fn=get_kb_status, inputs=None, outputs=kb_status) | |
| def do_reindex(): | |
| result = index_church_planting(force=True) | |
| return get_kb_status() + f"\n\nReindex result: {result}" | |
| btn_reindex.click(fn=do_reindex, inputs=None, outputs=kb_status) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| ssr_mode=False, | |
| show_error=True, | |
| css=custom_css, | |
| ) |