File size: 5,057 Bytes
b74c6ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95412b7
b74c6ae
95412b7
 
 
 
 
b74c6ae
 
 
 
 
e637380
 
 
 
 
 
 
b74c6ae
 
 
e637380
 
 
 
66de9c8
b74c6ae
 
 
 
6e43a65
 
 
b74c6ae
 
 
 
 
6e43a65
0649f1b
b74c6ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"""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