Spaces:
Configuration error
Configuration error
| import hmac | |
| import json | |
| import os | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| from fastapi import FastAPI, HTTPException, Query, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| from .app import answer_question, search_archives, stream_answer_question | |
| BASE_DIR = Path(__file__).resolve().parent | |
| WEB_DIR = BASE_DIR / "web" | |
| ANSWER_MODES = {"Strict Mode", "Study Mode", "Summary Mode"} | |
| APP_PASSWORD = os.getenv("APP_PASSWORD", "") | |
| api = FastAPI(title="The 7th Handle API", version="1.0.0") | |
| api.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| class AnswerRequest(BaseModel): | |
| question: str = Field(..., min_length=1, max_length=4000) | |
| answer_mode: str = Field("Strict Mode") | |
| sermon_scope: str | None = Field(None, max_length=20) | |
| conversation_mode: bool = Field(False) | |
| history: list[dict[str, str]] = Field(default_factory=list) | |
| def require_password(request: Request) -> None: | |
| if not APP_PASSWORD: | |
| return | |
| provided = request.headers.get("X-App-Password", "") | |
| if not hmac.compare_digest(provided, APP_PASSWORD): | |
| raise HTTPException(status_code=401, detail="Invalid app password.") | |
| def normalize_mode(answer_mode: str) -> str: | |
| return answer_mode if answer_mode in ANSWER_MODES else "Strict Mode" | |
| def sanitize_history(history: list[dict[str, str]]) -> list[dict[str, str]]: | |
| clean = [] | |
| for item in history[-8:]: | |
| role = str(item.get("role", "")).strip().lower() | |
| content = " ".join(str(item.get("content", "")).split()) | |
| if role not in {"user", "assistant"} or not content: | |
| continue | |
| clean.append({"role": role, "content": content[:1200]}) | |
| return clean | |
| def messagehub_link(filename: str) -> str: | |
| if not filename: | |
| return "#" | |
| code = filename.replace(".pdf", "").replace(".PDF", "").strip().split()[0] | |
| return f"https://www.messagehub.info/en/read.do?ref_num={code}" | |
| def serialize_doc(doc) -> dict[str, Any]: | |
| source = doc.metadata.get("source", "") | |
| paragraph_text = doc.page_content or "" | |
| return { | |
| "source": source, | |
| "paragraph": doc.metadata.get("paragraph", ""), | |
| "content": paragraph_text, | |
| "full_text": paragraph_text, | |
| "retrieval_origin": doc.metadata.get("retrieval_origin", "unknown"), | |
| "retrieval_rank": doc.metadata.get("retrieval_rank", ""), | |
| "messagehub_url": messagehub_link(source), | |
| } | |
| def serialize_result(result: dict[str, Any], question: str, answer_mode: str) -> dict[str, Any]: | |
| sources = [serialize_doc(doc) for doc in result.get("source_documents", [])] | |
| return { | |
| "answer": result.get("answer", ""), | |
| "sources": sources, | |
| "grounding_status": result.get("grounding_status", "unknown"), | |
| "diagnostics": result.get("diagnostics", {}), | |
| "question": question, | |
| "answer_mode": answer_mode, | |
| } | |
| def serialize_stream_data(data: dict[str, Any], question: str, answer_mode: str) -> dict[str, Any]: | |
| payload = dict(data) | |
| if "source_documents" in payload: | |
| payload["sources"] = [serialize_doc(doc) for doc in payload.pop("source_documents")] | |
| if "sources" in payload: | |
| payload["sources"] = [ | |
| serialize_doc(doc) if hasattr(doc, "metadata") else doc | |
| for doc in payload["sources"] | |
| ] | |
| payload.setdefault("question", question) | |
| payload.setdefault("answer_mode", answer_mode) | |
| return payload | |
| def apply_sermon_scope(question: str, sermon_scope: str | None) -> str: | |
| scope = (sermon_scope or "").strip() | |
| if not scope: | |
| return question.strip() | |
| if scope.upper() in question.upper(): | |
| return question.strip() | |
| return f"{scope} {question.strip()}" | |
| def sse(event: str, data: dict[str, Any]) -> str: | |
| return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=True)}\n\n" | |
| def health() -> dict[str, Any]: | |
| return {"status": "ok", "password_required": bool(APP_PASSWORD)} | |
| def answer(request: Request, payload: AnswerRequest) -> dict[str, Any]: | |
| require_password(request) | |
| mode = normalize_mode(payload.answer_mode) | |
| question = apply_sermon_scope(payload.question, payload.sermon_scope) | |
| try: | |
| result = answer_question( | |
| question, | |
| answer_mode=mode, | |
| history=sanitize_history(payload.history), | |
| conversational=payload.conversation_mode, | |
| ) | |
| except ValueError as exc: | |
| raise HTTPException(status_code=400, detail=str(exc)) from exc | |
| return serialize_result(result, question, mode) | |
| def answer_stream(request: Request, payload: AnswerRequest) -> StreamingResponse: | |
| require_password(request) | |
| mode = normalize_mode(payload.answer_mode) | |
| question = apply_sermon_scope(payload.question, payload.sermon_scope) | |
| history = sanitize_history(payload.history) | |
| def events() -> Iterable[str]: | |
| try: | |
| for event in stream_answer_question( | |
| question, | |
| answer_mode=mode, | |
| history=history, | |
| conversational=payload.conversation_mode, | |
| ): | |
| yield sse( | |
| event["event"], | |
| serialize_stream_data(event["data"], question, mode), | |
| ) | |
| except Exception as exc: | |
| yield sse("error", {"detail": str(exc), "question": question, "answer_mode": mode}) | |
| return StreamingResponse(events(), media_type="text/event-stream") | |
| def search( | |
| request: Request, | |
| q: str = Query(..., min_length=1, max_length=4000), | |
| ) -> dict[str, Any]: | |
| require_password(request) | |
| docs, debug_log = search_archives(q) | |
| return { | |
| "query": q, | |
| "results": [serialize_doc(doc) for doc in docs], | |
| "debug_log": debug_log, | |
| } | |
| if (WEB_DIR / "assets").exists(): | |
| api.mount("/assets", StaticFiles(directory=WEB_DIR / "assets"), name="assets") | |
| def index() -> FileResponse: | |
| return FileResponse(WEB_DIR / "index.html") | |
| def spa_fallback(path: str) -> FileResponse: | |
| if path.startswith("api/"): | |
| raise HTTPException(status_code=404, detail="Not found") | |
| return FileResponse(WEB_DIR / "index.html") | |