Spaces:
Running on Zero
Running on Zero
Download faq_loader.py from stevafernandes/chatbot-1: direct link, hf CLI and curl.
- Browser
- Download file 6.15 kB
-
https://huggingface.co/spaces/stevafernandes/chatbot-1/resolve/main/faq_loader.py
- Command line
-
hf download hf://spaces/stevafernandes/chatbot-1/faq_loader.py
-
curl -L -o faq_loader.py https://huggingface.co/spaces/stevafernandes/chatbot-1/resolve/main/faq_loader.py
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}_" | |