File size: 5,793 Bytes
fe3dd99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1de4ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe3dd99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""
Probe tests for the chatbot's knowledge base.

Runs real questions through app.respond() — the same code path the Space serves —
so the system prompt, the knowledge base and the answer are exactly what a user
would get. send_email_if_needed is stubbed out, so no unanswered-question mail is
sent to the support alias; instead, a probe FAILS when the real code path *would*
have sent one. That is the signal worth testing: every question the bot cannot
answer becomes an email to rishi@, support@ and ubaid@.

A probe WARNs when the bot answers but never mentions a term a correct answer has
to contain — that catches an answer about the wrong feature.

    pip install -r requirements.txt
    HF_TOKEN=... python test_knowledge_base.py

Exit code is non-zero if anything failed or warned.
"""
import os
import sys

REPO = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, REPO)

if not os.environ.get("HF_TOKEN"):
    env = os.path.join(REPO, ".env")
    if os.path.exists(env):
        with open(env) as handle:
            for line in handle:
                if line.startswith("HF_TOKEN"):
                    os.environ["HF_TOKEN"] = line.split("=", 1)[1].strip().strip('"').strip("'")
if not os.environ.get("HF_TOKEN"):
    sys.exit("HF_TOKEN not set (environment or .env)")

import app  # noqa: E402 — imported after HF_TOKEN so the client picks it up

# Never mail the support alias from a test run — but keep the REAL decision
# logic. send_email_if_needed runs after every answer and decides internally
# whether to send, so stubbing that function would record every question rather
# than every alert. Stub the SES client underneath it instead: what lands here
# is exactly what production would have mailed.
would_have_emailed = []


class _RecordingSES:
    def send_email(self, **kwargs):
        would_have_emailed.append(kwargs)


app.ses = _RecordingSES()

# (question, a substring a correct answer must contain)
PROBES = [
    # --- file types (KB 5.7) ---
    ("Can I email an iPhone photo of an invoice?", "yes"),
    ("Why was my HEIC photo rejected when I dragged it into the web app?", "email"),
    ("Can I send an invoice as an Excel file?", "pdf"),
    ("What file types can Beiing Human read?", "png"),

    # --- pay control (KB 6.2, 6.5) ---
    ("What is the Pay Control column?", "erp"),
    ("Why can't I see the Pay Control column?", "foundation"),
    ("How do I set the pay control on several invoices at once?", "assign"),
    ("Can I bulk assign pay control on Vista?", "foundation"),
    ("A pay control name is missing from the list. Why?", "sync"),

    # --- annotation (KB 7.8) ---
    ("How do I put a TAX EXEMPT stamp on an invoice?", "annotate"),
    ("What stamps can I use on a document?", "void"),
    ("Can I write a job number on the invoice image?", "note"),
    ("How do I remove a stamp I already saved?", "again"),

    # --- mobile ---
    ("Is there a mobile app?", "android"),
    ("Where do I download the Beiing Human app?", "play"),
    ("Why can't I log into the mobile app?", "accountant"),
    ("Can our AP admin use the phone app?", "owner"),
    ("How do I upload a receipt from my phone?", "upload file"),
    ("I don't see the Create PO button on my phone, why?", "sign"),
    ("Why is there no approve button on the To Review list in the app?", "details"),
    ("The app says fix 3 issues before approving. What do I do?", "validation"),
    ("Can I reject an invoice on mobile without leaving a comment?", "comment"),
    ("Does the mobile app send push notifications?", "not"),
    ("How do I get out of the Update Required screen?", "store"),
    ("Can I edit an exported document on my phone?", "read-only"),
    ("How do I answer a question assigned to me in the app?", "reply"),
    ("Can I search for a specific invoice in the mobile app?", "web"),
    ("Can I match an invoice to a PO on my phone?", "web"),
    ("Why did my app log me out?", "session"),
    # --- web app regressions ---
    ("What does the INYA report show?", "approv"),
    ("How do I use the pull back feature?", "pull back"),
    ("What is a Sub Admin and what can they do?", "admin"),
    ("How do I attach a delivery ticket to an invoice?", "match"),
    ("What's the difference between Approved and Verified statuses?", "api"),
    ("How do I trigger the document splitter?", "split"),
    ("Why is a vendor missing from the COI tracker?", "active job"),
    ("Can I bulk delete documents?", "does not"),
]


def ask(question):
    """Drive the Space's own respond() generator and return the final answer."""
    answer = ""
    for chunk in app.respond(question, [], "", 2000, 0.3, 0.95, None):
        answer = chunk
    return answer


def main():
    print(f"Knowledge base: {len(app.doc_context):,} chars\n")
    if len(app.doc_context) < 2000:
        sys.exit("Knowledge base looks empty or failed to load")

    problems = 0
    for question, expect in PROBES:
        before = len(would_have_emailed)
        try:
            answer = ask(question)
        except Exception as e:
            print(f"ERROR  {question}\n       {e}")
            problems += 1
            continue

        emailed = len(would_have_emailed) > before
        if emailed:
            verdict, note = "FAIL", "unanswered -> would have emailed the support alias"
        elif expect not in answer.lower():
            verdict, note = "WARN", f"answered but never mentions '{expect}'"
        else:
            verdict, note = "PASS", ""

        if verdict != "PASS":
            problems += 1
        print(f"{verdict}  {question}")
        if note:
            print(f"       {note}")

    print(f"\n{len(PROBES) - problems}/{len(PROBES)} clean")
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())