chatbot-1 / faq_loader.py
stevafernandes's picture
Upload 2 files
56e88de verified
Raw History Blame Contribute Delete
6.15 kB
"""
faq_loader.py — the clickable FAQ list, read from the Word document.
FAQ_Vetted-Sources_relapsed_refractory_myeloma.docx holds the questions and
answers as pairs of consecutive paragraphs:
<question>? - <source description> - <hyperlinked source title>
Chatbot Answer: <answer text>
The document is read once at startup. A .docx is a zip of XML, so this uses
the standard library only — no new package is needed on the Space. Nothing is
generated here: the question shown on each button, the answer shown in the
chat, and the source link under it are taken from the document as written.
"""
import os
import re
import zipfile
import xml.etree.ElementTree as ET
from collections import namedtuple
# The document is uploaded to the Space repo root, which is the app's working
# directory. FAQ_DOCX can point somewhere else if it is ever moved.
FAQ_DOCX = os.environ.get(
"FAQ_DOCX", "FAQ_Vetted-Sources_relapsed_refractory_myeloma.docx")
_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
# The answer paragraphs open with "Chatbot Answer:" (one says "Chatbox").
# That label is a document artefact, not part of the answer, so it is removed.
_ANSWER_PREFIX = re.compile(r"^\s*chat(?:bot|box)\s+answer\s*:\s*", re.IGNORECASE)
# label — what the participant sees and clicks: the question, or, for the
# two identically worded questions, "According to the <source>,
# <question>" so each can be told from the other
# question — the question text alone, ending at its question mark
# source — the source description that follows the question, if any
# links — [(link text, url), ...] from the hyperlinks in the question line
# answer — the answer text, without the "Chatbot Answer:" label
FAQ = namedtuple("FAQ", "label question source links answer")
def _paragraph_text(p):
parts = []
for el in p.iter():
if el.tag == _W + "t":
parts.append(el.text or "")
elif el.tag == _W + "tab":
parts.append(" ")
elif el.tag == _W + "br":
parts.append("\n")
return "".join(parts)
def _paragraph_links(p, rels):
links = []
for h in p.iter(_W + "hyperlink"):
text = "".join((t.text or "") for t in h.iter(_W + "t")).strip()
url = rels.get(h.get(_R + "id"))
if text and url:
links.append((text, url))
return links
def _squash(text):
return re.sub(r"\s+", " ", text or "").strip()
def _lower_first(text):
"""'What are ...' -> 'what are ...' (left alone if it starts with an acronym)."""
if len(text) > 1 and text[0].isupper() and text[1].islower():
return text[0].lower() + text[1:]
return text
def _read_paragraphs(path):
"""Yield (text, links) for every paragraph in the document body."""
with zipfile.ZipFile(path) as z:
rels = {}
try:
for rel in ET.fromstring(z.read("word/_rels/document.xml.rels")):
rels[rel.get("Id")] = rel.get("Target")
except KeyError:
pass
body = ET.fromstring(z.read("word/document.xml")).find(_W + "body")
if body is None:
return
for p in body.iter(_W + "p"):
yield _paragraph_text(p), _paragraph_links(p, rels)
def _split_question(text, links):
"""'<question>? - <description> - <link text>' -> (question, description)."""
q_end = text.find("?")
if q_end < 0:
return None, ""
question = _squash(text[: q_end + 1])
rest = text[q_end + 1:]
for link_text, _url in links:
rest = rest.replace(link_text, " ")
description = _squash(rest).strip(" -–—|").strip()
return question, description
def parse_faqs(path):
"""Return the FAQ list in document order. Raises if the file is unreadable."""
pairs = []
pending = None
for text, links in _read_paragraphs(path):
if not text.strip():
continue
if _ANSWER_PREFIX.match(text):
if pending is not None:
answer = _ANSWER_PREFIX.sub("", text, count=1).strip()
if answer:
pairs.append((pending[0], pending[1], pending[2], answer))
pending = None
continue
question, description = _split_question(text, links)
if question:
# A question without an answer paragraph is dropped, not shown.
pending = (question, description, links)
# Two entries ask the same question of different sources, so those two
# are shown as "According to the <source>, <question>".
counts = {}
for question, _d, _l, _a in pairs:
counts[question.lower()] = counts.get(question.lower(), 0) + 1
faqs = []
for question, description, links, answer in pairs:
label = question
if counts[question.lower()] > 1:
source = description or (links[0][0] if links else "")
if source:
label = f"According to the {source}, {_lower_first(question)}"
faqs.append(FAQ(label=label, question=question, source=description,
links=links, answer=answer))
return faqs
def load_faqs(path=FAQ_DOCX, log=print):
"""The FAQ list, or [] if the document is missing or unreadable.
Never raises: without the document the app simply shows no FAQ buttons.
"""
if not path or not os.path.isfile(path):
log(f"[FAQ] {path!r} not found; FAQ buttons will not be shown.")
return []
try:
faqs = parse_faqs(path)
except Exception as e:
log(f"[FAQ] Could not read {path!r}: {e}; FAQ buttons will not be shown.")
return []
log(f"[FAQ] Loaded {len(faqs)} questions from {os.path.basename(path)}.")
return faqs
def source_line(faq):
"""The document's source link(s) for a question, as a Markdown line to show
under the answer. Empty if the question line carried no hyperlink."""
if not faq.links:
return ""
sources = "; ".join(f"[{t}]({u})" for t, u in faq.links)
return f"\n\n_Source: {sources}_"