Guide: build an internal chatbot on your SOPs
Retrieve the right SOPs with full-text search, turn their cards into clean text, and ground a language model's answer in them with links back to the app.
On this page
This guide builds the retrieval half of a chatbot that answers questions from your SOPs, for Slack, Teams or an internal page. It uses a read only key and works with any language model.
Note: SOPCompass already has Ask, an AI assistant inside the app that answers from your SOPs. Build your own when you want answers somewhere else, such as a Slack channel or your support tool.
The flow
- Search. Send the user’s question to
GET /searchand take the top few SOPs. - Read. Fetch each with
GET /sops/{id}and turn its cards and steps into plain text. - Answer. Give that text to your model with instructions to answer only from it, and cite the SOPs by
app_url.
Step 1: Search
Full-text search matches words, not meaning, so strip the question down to its content words and loosen it with or when an exact search finds nothing.
import os
import re
import requests
BASE = "https://app.sopcompass.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SOPCOMPASS_API_KEY']}"
def search(question, limit=5):
words = re.findall(r"[\w']+", question.lower())
strict = " ".join(words)[:200]
res = session.get(f"{BASE}/search", params={"q": strict, "limit": limit}, timeout=30).json()
if res.get("data"):
return res["data"]
# Nothing matched every word: accept any of them.
loose = " or ".join(w for w in words if len(w) > 3)[:200]
if not loose:
return []
return session.get(f"{BASE}/search", params={"q": loose, "limit": limit}, timeout=30).json().get("data", [])Each result has the SOP (with id, title and app_url), a rank and a plain-text snippet.
Step 2: Read the SOPs in full
import html
def to_text(fragment):
text = re.sub(r"<(br|/p|/li|/h\d)>", "\n", fragment or "")
text = re.sub(r"<[^>]+>", "", text)
return html.unescape(text).strip()
def sop_as_text(sop_id):
sop = session.get(f"{BASE}/sops/{sop_id}", timeout=30).json()["data"]
parts = [f"# {sop['title']}"]
if sop["description"]:
parts.append(sop["description"])
for card in sop["cards"]:
parts.append(f"## {card['title']}")
parts.append(to_text(card["body_html"]))
for n, step in enumerate(card["steps"], start=1):
parts.append(f"{n}. {step['title']}\n{to_text(step['html'])}".strip())
return sop, "\n\n".join(p for p in parts if p)Stripping tags here is fine because the text goes to a model, not onto a web page.
Step 3: Ground the answer
def build_prompt(question):
sources = []
for result in search(question, limit=3):
sop, text = sop_as_text(result["sop"]["id"])
sources.append(f"SOURCE [{sop['title']}]({sop['app_url']})\n{text[:12000]}")
if not sources:
return None
return (
"Answer the question using only the SOPs below. If they don't answer it, say so. "
"Cite the SOP you used by its link.\n\n"
+ "\n\n---\n\n".join(sources)
+ f"\n\nQuestion: {question}"
)
prompt = build_prompt("How do we degrease the kitchen hood filters?")
# Send `prompt` to the language model of your choice and post its reply.When build_prompt returns None, reply that no SOP covers the question instead of letting the model guess.
Make it fast and cheap
- Cache full SOPs by
idandupdated_at. Search results includeupdated_at, so you only needGET /sops/{id}when the SOP changed since you cached it. Or keep a full local copy with the sync guide and only callGET /searchlive. - Mind the rate limit. One question costs up to 1 or 2 searches plus one read per SOP. At 60 requests a minute per key, cache aggressively for a busy channel. See Rate limits.
- Skip archived SOPs. Search already leaves them out.
Keep it safe
- The bot sees everything the key’s workspace can see, including every library. Anyone who can message the bot can get an answer from any SOP. If some SOPs are for managers only, don’t expose a bot to everyone, or filter results by
library_idordepartmentsbefore building the prompt. - Run the bot on a server. The key must never reach the chat client or a browser.
- Links in answers use
app_url, which only works for people signed in to the workspace.