HumeraR's picture
refactor: improve chat sorting logic in NotebookView component for better handling of timestamps
e637380
Raw History Blame Contribute Delete
5.06 kB
"""The help corpus: markdown files in support/docs, one topic each, loaded once per process.
Each file starts with a small frontmatter block (title, route, keywords, questions, related). The bot answers only
from these files, so a feature is documented here before the bot can talk about it.
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger("notestack.support")
DOCS_DIR = Path(__file__).parent / "docs"
_FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
@dataclass(frozen=True)
class Doc:
id: str
title: str
route: str
keywords: tuple[str, ...]
questions: tuple[str, ...]
related_paths: tuple[str, ...]
headings: tuple[str, ...]
body: str
@property
def high_signal_text(self) -> str:
return "\n".join([self.title, *self.keywords, *self.headings, *self.questions])
def _parse(path: Path) -> Doc:
raw = path.read_text(encoding="utf-8")
match = _FRONTMATTER.match(raw)
meta: dict[str, list[str] | str] = {}
body = raw
if match:
body = raw[match.end():]
key = ""
for line in match.group(1).splitlines():
if line.startswith(" - ") and key:
meta.setdefault(key, []).append(line[4:].strip()) # type: ignore[union-attr]
elif ":" in line:
key, _, value = line.partition(":")
key, value = key.strip(), value.strip()
meta[key] = value if value else []
as_list = lambda k: tuple(meta.get(k) or ()) # noqa: E731
return Doc(
id=path.stem,
title=str(meta.get("title") or path.stem.replace("-", " ").title()),
route=str(meta.get("route") or ""),
keywords=as_list("keywords"),
questions=as_list("questions"),
related_paths=as_list("related"),
headings=tuple(re.findall(r"^#{1,3}\s+(.+)$", body, re.M)),
body=body.strip(),
)
def _plans_doc() -> Doc:
"""Built from services/plans.py so prices and limits can never drift from what the app enforces."""
from app.services.plans import PLANS, annual_prices, plan_order
order = plan_order() # the plans table's (editable without a deploy), so the names and count come from it too
names = [PLANS[pid].name for pid in order]
listed = ", ".join(names[:-1]) + f" and {names[-1]}" if len(names) > 1 else "".join(names)
lines = ["# Plans", f"Notestack has {len(names)} plans: {listed}. Prices are in US dollars."]
for pid in order:
p = PLANS[pid]
price = "free" if not p.price_monthly_usd else f"${p.price_monthly_usd:g} a month"
if p.price_monthly_usd:
monthly, yearly = annual_prices(p)
price += f", or ${monthly:g} a month billed yearly (${yearly:g} a year)"
per = "in total, never renewing," if p.lifetime else "a month"
def count(n: int) -> str:
return "unlimited" if n < 0 else str(n)
audio = f"{count(p.audio_overviews)} audio overviews in total, any length" if p.audio_overviews >= 0 \
else f"{p.audio_minutes} minutes of audio a month"
lines += [
f"## {p.name}",
f"{p.tagline}. Price: {price}.",
f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts across every source, {audio}, "
f"{p.videos} videos {'a month' if p.videos_monthly else 'in total, never renewing'}, "
f"{count(p.launch_kits)} Launch Kits {per}, {count(p.infographics)} infographics {per}, "
f"{count(p.reports)} reports a month. "
f"Voice cloning: {'yes' if p.voice_cloning else 'no'}.",
"Includes: " + "; ".join(p.features) + ".",
]
lines += [
"## Billing",
"Upgrade, change plan or manage billing from Settings under Plan and usage. "
"Annual billing is cheaper than monthly. Checkout may be switched off while billing is being finished, "
"in which case every workspace gets Studio limits.",
"Refunds and billing problems are handled by the team: use the Talk to a human button in this chat.",
]
body = "\n".join(lines)
return Doc(
id="plans-and-billing", title="Plans, pricing and billing", route="/pricing",
keywords=("price", "pricing", "plan", "plans", "free", "writer", "studio", "cost", "billing", "upgrade",
"limits", "annual", "monthly", "subscription", "refund", "cancel", "usage", "reports", "infographics"),
questions=("How much does Notestack cost?", "What is in the free plan?", "What are the plan limits?",
"How do I upgrade?", "Is there annual billing?"),
related_paths=("/app/settings",), headings=tuple(re.findall(r"^#{1,3}\s+(.+)$", body, re.M)), body=body,
)
_docs: list[Doc] | None = None
def get_corpus() -> list[Doc]:
global _docs
if _docs is None:
_docs = [_parse(p) for p in sorted(DOCS_DIR.glob("*.md"))] + [_plans_doc()]
log.info("support corpus: %d docs", len(_docs))
return _docs