File size: 15,935 Bytes
dad9dd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857cc94
a9fb660
857cc94
 
 
 
 
 
 
b5053ef
 
 
 
 
 
 
060f9d4
 
 
 
 
a9fb660
 
 
 
 
 
857cc94
 
a9fb660
 
 
 
dad9dd8
 
 
 
 
07a362e
 
 
dad9dd8
 
b63edd6
 
 
 
 
dad9dd8
 
 
 
 
 
 
 
 
 
 
 
 
 
857cc94
dad9dd8
 
 
 
 
 
 
 
 
 
 
9332e61
 
dad9dd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6829d9
 
 
dad9dd8
 
 
 
 
 
 
 
060f9d4
 
67ccb38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
060f9d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dad9dd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
060f9d4
 
 
 
 
 
 
 
 
 
 
dad9dd8
 
 
 
 
 
 
 
 
 
 
 
857cc94
dad9dd8
 
 
 
 
 
 
 
060f9d4
 
 
dad9dd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
#!/usr/bin/env python3
"""
Answer a Dhamma question using retrieved context + Claude Sonnet.

Usage (CLI):
  python3 answer.py "what is the first stage of enlightenment?"
  python3 answer.py "explain kamma" --n 8
  python3 answer.py "what is jhana?" --type scripture

Importable:
  from answer import answer
  result = answer("what is stream-entry?")
  print(result["answer"])
  for s in result["sources"]: print(s["source_label"])
"""

import argparse
import hashlib
import json
import os
import sys
from pathlib import Path

try:
    import anthropic
except ImportError:
    sys.exit("pip install anthropic")

from retrieval import retrieve


def _load_api_key() -> str:
    """Return ANTHROPIC_API_KEY from env or ~/.bash_profile fallback."""
    key = os.environ.get("ANTHROPIC_API_KEY", "")
    if key:
        return key
    profile = Path.home() / ".bash_profile"
    if profile.exists():
        for line in profile.read_text().splitlines():
            line = line.strip()
            if line.startswith("export ANTHROPIC_API_KEY="):
                return line.split("=", 1)[1].strip().strip('"').strip("'")
    return ""


MODEL      = "claude-sonnet-4-6"
CACHE_FILE = Path("data/answer_cache.json")

SYSTEM_PROMPT = """\
You are an assistant that answers questions strictly from the provided source passages \
of David Roylance, a Theravada Buddhist teacher. Write in the same voice and style \
as David Roylance uses in those passages.

Answer shape (MOST IMPORTANT β€” direct AND thorough):
- Lead with the answer. The FIRST sentence must directly and plainly answer the \
  question asked. State the conclusion first, then develop it.
- Be thorough. After the direct opening, fully develop the answer: explain the \
  reasoning, cover the relevant aspects, distinctions, and supporting points the \
  passages provide, and include illustrative scripture where it strengthens the \
  explanation. Aim for a complete, well-rounded answer β€” do not cut it short. \
  Detailed is good; padding and filler are not. Every sentence should add real \
  content from the passages, never restate the question or hedge.
- When the question asks about a concept or teaching (however it is phrased β€” \
  "what is X", "define X", "explain X", "tell me about X", or just "X"), first \
  identify which of these the retrieved passages actually support, then cover each \
  that they do: the definition; the concept's CAUSE/ORIGIN (how it arises); its \
  CESSATION (how it is eliminated or resolved); and any key distinction the question \
  invites. A definition alone is incomplete when the passages also explain the cause \
  and the cessation β€” weave those in. \
- When the question asks how to overcome, resolve, get rid of, or deal with a \
  problem, name the SPECIFIC practice or antidote the passages prescribe for THAT \
  problem β€” by its name, if the passages name one β€” rather than only general advice \
  like "train the mind" or "meditate". If the passages prescribe a particular \
  practice as the direct remedy for the problem, lead the how-to with it. \
- NEVER open with throat-clearing. Banned openers include: "This is an important \
  question", "The passages address this carefully", "perhaps not in the way one might \
  expect", "It's worth noting", "Let us explore", and any sentence about what the \
  passages do or don't do. Just answer.
- Do not be coy, clever, or indirect (e.g. never answer "how do you know you are \
  Enlightened?" with "you wouldn't be asking"). Give the actual substantive answer.
- Structure a long answer for readability: flowing prose by default, and section \
  sub-headers when the answer has several distinct parts.
- Never both answer AND disclaim. If you can answer from the passages, just answer β€” \
  do not hedge that the material is incomplete. Only use the exact decline sentence \
  (see Decline) when you genuinely cannot answer at all.

Content rules (non-negotiable):
- Answer ONLY from the source passages. Do not add outside knowledge or context.
- Cite every claim with a [Source N] marker.
- Every named concept, category, or term you use MUST appear word-for-word in the cited passage.
  Do NOT invent category labels, headings, or named groupings not present in the text.
- Do NOT extract adjectives or fragments from compound terms to create new terms.
  For example: if the passage says "The Three Unwholesome Roots", do not write "the unwholesome"
  or "roots of the unwholesome" β€” use the full phrase as written.
- If the passages do not contain enough to answer, say so directly.
- Do not speculate, summarize beyond what is stated, or editorialize.
- Source preference: always prefer David Roylance's own commentary and explanatory passages \
  over direct Pali Canon scripture when answering conceptual questions. Use scripture \
  (blockquote format) only when the question specifically asks for The Buddha's words, \
  or when no commentary passage covers the topic. If both are available, build the answer \
  from commentary and reference scripture only as supporting illustration.

Style (mirror the teacher's voice from the passages):
- Plain, direct English β€” clear and accessible, not academic or flowery.
- These words are ALWAYS capitalised, no exceptions: Teachings, Enlightenment, The Buddha, \
The Practitioner, The Path, Kamma, Dhamma, Nibbana, The Natural Law of Kamma. \
Never write "teachings", "enlightenment", or "the path" in lower case. \
All other terms β€” the mind, craving, clinging, anger, aversion, ignorance, wisdom, virtue, \
concentration, suffering, liberation β€” use standard lower case mid-sentence.
- Preserve the teacher's exact punctuation within phrases, including slash notation \
  (e.g. write "craving/desire/attachment", never "craving, desire, attachment"). \
  Slashes in the source text are intentional β€” they signal that the terms are one concept \
  seen from different angles, not a list of separate things.
- Use the teacher's characteristic phrases where they appear in the sources, \
  e.g. "learning, reflecting, and practicing", "the mind", "discontentedness".
- Warm but precise β€” explain to a student fully and patiently, but get to the point first.
- Do not write in first person as the teacher. Explain what the passages say.

Scripture formatting:
- When quoting The Buddha's words directly from the Pali Canon, wrap the quote in a \
  markdown blockquote (lines starting with "> "). On the last line of the blockquote, \
  add the sutta reference prefixed with "β€” ". Look for a "(Reference: ...)" line in the \
  source passage β€” use that exact reference (e.g. if the passage says \
  "(Reference: SN 35.240)", write "β€” SN 35.240"). If no such line appears, use the \
  collection abbreviation only (e.g. "β€” SN"). \
  Only use blockquotes for direct canonical scripture; do not use them for \
  David Roylance's commentary.
- Do NOT paraphrase scripture passages. If you use content from a scripture source, \
  quote it directly in a blockquote. Never restate The Buddha's words in your own phrasing.

Decline:
- If the source passages genuinely cannot support an answer, respond with exactly this \
  sentence and nothing else: \
  "The source passages here don't cover that. Try rephrasing, or narrow to a specific \
  volume or topic using the filters above." """


# ── cache ─────────────────────────────────────────────────────────────────────

def _cache_key(question: str, n_results: int, content_type: str | None, volume: int | None) -> str:
    normalized = " ".join(question.lower().split())
    payload = json.dumps([normalized, n_results, content_type, volume], sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()[:20]


def _load_cache() -> dict:
    if CACHE_FILE.exists():
        try:
            return json.loads(CACHE_FILE.read_text())
        except Exception:
            return {}
    return {}


def _save_cache(cache: dict) -> None:
    CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2))


# ── context building ──────────────────────────────────────────────────────────

_REF_RE = __import__("re").compile(r"\(Reference:\s*([^)]+)\)")

def _best_ref(chunks: list[dict]) -> dict:
    """
    Build a map of (volume, chapter) β†’ most-specific sutta ref found across all
    retrieved chunks. A ref with a number (e.g. "SN 35.240") beats a bare
    collection name (e.g. "SN").
    """
    best: dict[tuple, str] = {}
    for c in chunks:
        key = (c.get("vol_num", 0), c.get("ch_num", 0))
        m = _REF_RE.search(c["text"])
        candidate = m.group(1).strip() if m else ", ".join(c.get("sutta_refs") or [])
        if not candidate:
            continue
        existing = best.get(key, "")
        # Prefer the ref that contains a space (i.e. has a number like "SN 35.240")
        if " " in candidate and " " not in existing:
            best[key] = candidate
        elif not existing:
            best[key] = candidate
    return best

def _build_context(chunks: list[dict]) -> tuple[str, list[dict]]:
    """Format all retrieved chunks into a numbered context block."""
    ref_map = _best_ref(chunks)
    lines = []
    sources = []
    for i, c in enumerate(chunks, 1):
        ct = c.get("content_type", "")
        type_tag = " [SCRIPTURE β€” must blockquote, do not paraphrase]" if ct == "scripture" else ""
        header = f"[Source {i}]{type_tag} {c['source_label']}"
        key = (c.get("vol_num", 0), c.get("ch_num", 0))
        ref = ref_map.get(key, "") or ", ".join(c.get("sutta_refs") or [])
        refs = f"  ({ref})" if ref else ""
        lines.append(f"{header}{refs}\n{c['text']}")
        sources.append({**c, "index": i})
    return "\n\n---\n\n".join(lines), sources


# ── query understanding (NotebookLM-style contextualization) ───────────────────

def _taxonomy_text() -> str:
    """Compact text form of David's teaching taxonomy (data/taxonomy.yaml) to guide
    query expansion. Empty string if the file is missing/unreadable."""
    try:
        import yaml
        t = yaml.safe_load((Path("data/taxonomy.yaml")).read_text())
    except Exception:
        return ""
    parts = []
    if t.get("anger_rooted_feelings"):
        parts.append("Feelings rooted in ANGER β€” remedy is loving-kindness "
                     "meditation: " + ", ".join(t["anger_rooted_feelings"]) + ".")
    if t.get("craving_rooted_feelings"):
        parts.append("All other feelings (pleasant, most painful, and neither) are "
                     "rooted in CRAVING/DESIRE/ATTACHMENT β€” remedy is breathing "
                     "mindfulness meditation and generosity, to train the mind to let "
                     "go: " + ", ".join(t["craving_rooted_feelings"]) + ".")
    if t.get("roots"):
        parts.append("Roots -> antidotes: " + "; ".join(
            f"{k} -> {v.get('antidote')}" for k, v in t["roots"].items()) + ".")
    if t.get("practices"):
        parts.append("Key practices to name when relevant: " +
                     ", ".join(t["practices"]) + ".")
    return "\n".join(parts)


_EXPAND_SYSTEM = (
    "You turn a user's question into 2-4 short SEARCH QUERIES naming the underlying "
    "concepts in David Roylance's Theravada Buddhist Teachings needed to answer it "
    "fully. When the question asks how to overcome / get rid of / deal with "
    "something, classify the emotion/problem using the framework below and generate "
    "queries covering BOTH the cause AND the specific remedy/practice for THAT "
    "problem.\n\n"
    + _taxonomy_text() +
    "\n\nThese are retrieval queries only, NOT answers, and not authoritative. "
    "Output one query per line β€” no numbering, no preamble, nothing else."
)


def _expand_query(client, question: str) -> tuple[list[str], int]:
    """LLM contextualizes the question into retrieval sub-queries so the right
    concepts (e.g. the antidote, not just the symptom) surface. Returns
    (sub_queries, tokens_used). Best-effort: failure degrades to no expansion."""
    try:
        msg = client.messages.create(
            model=MODEL, max_tokens=200, system=_EXPAND_SYSTEM,
            messages=[{"role": "user", "content": question}],
        )
        lines = [l.strip("-β€’* \t").strip() for l in msg.content[0].text.splitlines()]
        subs = [l for l in lines if l][:4]
        return subs, msg.usage.input_tokens + msg.usage.output_tokens
    except Exception:
        return [], 0


# ── main answer function ───────────────────────────────────────────────────────

def answer(
    question: str,
    n_results: int = 8,
    content_type: str | None = None,
    volume: int | None = None,
) -> dict:
    """
    Retrieve context and generate a grounded answer.
    Results are cached by (question, n_results, content_type, volume).

    Returns:
      {
        "question": str,
        "answer":   str,
        "sources":  list[dict],
        "usage":    {"input": int, "output": int},
        "cached":   bool,
      }
    """
    key   = _cache_key(question, n_results, content_type, volume)
    cache = _load_cache()
    if key in cache:
        return {**cache[key], "cached": True}

    client = anthropic.Anthropic(api_key=_load_api_key())

    # Contextualize the question into retrieval sub-queries (skip on type-filtered
    # browsing). The expansion guides RETRIEVAL only β€” it is never shown to the
    # answer model, which must ground every claim in the retrieved passages.
    extra, exp_tokens = ([], 0)
    if not content_type:
        extra, exp_tokens = _expand_query(client, question)

    chunks  = retrieve(question, n_results=n_results, content_type=content_type,
                       volume=volume, extra_queries=extra)
    context, sources = _build_context(chunks)

    user_message = f"""Source passages:

{context}

---

Question: {question}"""

    msg = client.messages.create(
        model=MODEL,
        max_tokens=2048,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": user_message}],
    )

    result = {
        "question": question,
        "answer":   msg.content[0].text,
        "sources":  sources,
        "expanded_queries": extra,   # for transparency/debug; not seen by answer model
        "usage":    {"input": msg.usage.input_tokens + exp_tokens,
                     "output": msg.usage.output_tokens},
    }

    cache[key] = result
    _save_cache(cache)

    return {**result, "cached": False}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("question")
    ap.add_argument("--n", type=int, default=8, dest="n_results")
    ap.add_argument("--type", dest="content_type", default=None)
    ap.add_argument("--vol", type=int, default=None, dest="volume")
    args = ap.parse_args()

    result = answer(args.question, n_results=args.n_results,
                    content_type=args.content_type, volume=args.volume)

    cached_tag = "  [cached]" if result.get("cached") else ""
    print(f"\nQ: {result['question']}{cached_tag}\n")
    print(result["answer"])
    print(f"\n── Sources ({'|'.join(str(s['index']) for s in result['sources'])})")
    for s in result["sources"]:
        ct = s["content_type"]
        refs = f"  [{', '.join(s['sutta_refs'])}]" if s["sutta_refs"] else ""
        print(f"  [{s['index']}] score={s['score']}  {ct}{refs}")
        print(f"       {s['source_label']}")
    u = result["usage"]
    print(f"\n── Tokens: {u['input']} in / {u['output']} out{cached_tag}")


if __name__ == "__main__":
    main()