File size: 15,860 Bytes
f2049f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4823877
d09b89a
 
 
4823877
 
f2049f1
1a629bb
 
 
 
 
f2049f1
1a629bb
 
f2049f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d09b89a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a629bb
f2049f1
 
d09b89a
f2049f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Grounded question answering: retrieve passages, then let Gemini answer ONLY from them, with citations.

Setup: put the key for the provider you use in FinalProject.env (git-ignored):
    GEMINI_API_KEY=...        pip install google-genai        --provider gemini    (default)
    ANTHROPIC_API_KEY=...     pip install anthropic           --provider anthropic
    OPENAI_API_KEY=...        pip install openai              --provider openai

    python rag.py "ကျေးလက်ဒေသ ဆေးရုံများတွင် ဆရာဝန် မလုံလောက်မှုကို အစိုးရက ဘယ်လိုဖြေရှင်းမလဲ"
    python rag.py --provider anthropic --batch data/pilot_questions.jsonl --out data/pilot_answers.jsonl
    python rag.py --no-retrieval "..."       # ablation: same model, no passages (for the faithfulness comparison)
    python rag.py --embed sealion --mode hybrid --per-speech 2 --batch data/eval_questions.jsonl --out data/eval_answers.jsonl
    python rag.py --provider anthropic --list-models / --probe

Retrieval configuration: --embed <model key> (embed_models.py), --mode dense|bm25|hybrid, --k 5, --per-speech N
(at most N passages from the same speech — the pilot's top-k was filled by one long speech).
"""
import argparse, json, logging, os, sys, time
from dotenv import load_dotenv
logging.getLogger("google_genai").setLevel(logging.ERROR)   # silence the harmless AFC warning
from common import citation_of
from retrieval import Retriever

PROVIDERS = {   # provider: (env var, default model, fallback models tried in order when the default is overloaded)
    # probe 30 Sep 2026: 3.8/3.7/3.5-flash all 503 "high demand" on the free tier; flash-latest, 3.5-flash-lite and gemma-4 answered
    "gemini":    ("GEMINI_API_KEY",    "gemini-3.5-flash-lite", ["gemma-4-26b-a4b-it", "gemini-flash-latest", "gemini-3.8-flash"]),
    "anthropic": ("ANTHROPIC_API_KEY", "claude-haiku-5-5", []),   # grader: Haiku ≈ 1 cent per answer (Sonnet was ≈ 6 cents)      # run --provider anthropic --list-models to see current names
    "openai":    ("OPENAI_API_KEY",    "gpt-5-mini",        []),
}
DEFAULT_PROVIDER = "gemini"
DEFAULT_MODEL = None    # None = the provider's default
K = 5
EMBED = "sealion"; MODE = "hybrid"; PER_SPEECH = 2      # SQ1 winner (2 Nov 2026); BGE-M3 was the pilot model

SYSTEM = """You are an assistant that explains what was said in Myanmar's parliament (Hluttaw) between 2011 and 2020.
You will be given a question and numbered passages. The passages are official parliamentary transcripts written in
Burmese; each begins with a header giving the house, date, agenda section, item and speaker.
Rules:
1. Answer ONLY from the passages. Do not add outside knowledge or general background.
2. Answer in the language of the QUESTION: a Burmese question gets a Burmese answer; an English question gets an
   English answer that reports what the Burmese passages say (translate the content; keep names as in the passages).
   Never refuse or switch language because the passages are in Burmese — read them and report them.
3. Write 3–8 plain sentences a non-expert can follow. End every sentence that states a fact with the number(s) of
   the passage(s) it comes from, like [2] or [1][3]. A sentence without a citation must not contain a fact.
4. Say who said it (the MP, minister or ministry, as named in the passage header) and on what date.
5. Copy numbers, dates, place names and amounts exactly as they appear in the passages. Do not round, convert or
   infer numbers that are not there.
6. If the passages do not answer the question, say so in one sentence (in the question's language) and do not guess.
   If they answer only part of it, answer that part and say what is missing.
7. If the question asks for an overview of what a person, party or house discussed "most" or "overall", say in one
   sentence that the answer rests only on the retrieved passages, then report what those passages show.
8. Speaker headers give each MP's constituency and, where known, party (e.g. "· အမျိုးသားဒီမိုကရေစီအဖွဲ့ချုပ် (NLD)").
   For a question about a party or group, use those labels to attribute statements; if no passage carries that label,
   say the retrieved record does not identify speakers from that party, then still report what the passages say on the topic."""

LANG_RULE = {"my": "ANSWER LANGUAGE: Burmese. Write the whole answer in Burmese, whatever language the question is in.",
             "en": "ANSWER LANGUAGE: English. Write the whole answer in English, reporting what the Burmese passages say "
                   "(translate the content; keep personal names as in the passages, with a Latin transliteration in brackets if helpful)."}

def build_prompt(question, hits, lang=None):
    ctx = "\n\n".join(f"[{i}] {citation_of(m)}\n{m['text']}" for i, (s, m) in enumerate(hits, 1))
    rule = ("\n" + LANG_RULE[lang]) if lang in LANG_RULE else ""        # None / "auto": rule 2 of SYSTEM applies
    return f"{SYSTEM}{rule}\n\nPASSAGES:\n{ctx}\n\nQUESTION: {question}\n\nANSWER:"

def build_prompt_no_retrieval(question):
    return (f"Answer the following question about what was said in Myanmar's parliament (Hluttaw) between 2011 and 2020. "
            f"Answer in the language of the question, in 3–8 plain sentences, naming who said what and when as precisely as you can.\n\n"
            f"QUESTION: {question}\n\nANSWER:")

class Generator:
    def __init__(self, provider=DEFAULT_PROVIDER, model=DEFAULT_MODEL, no_fallback=False):
        load_dotenv("FinalProject.env")
        env, default, fallbacks = PROVIDERS[provider]
        if no_fallback: fallbacks = []
        key = os.environ.get(env)
        if not key: sys.exit(f"{env} missing — add it to FinalProject.env")
        self.provider, self.model = provider, (model or default)
        self.fallbacks = [m for m in fallbacks if m != self.model]
        if provider == "gemini":
            from google import genai; self.client = genai.Client(api_key=key)
        elif provider == "anthropic":
            import anthropic; self.client = anthropic.Anthropic(api_key=key)
        elif provider == "openai":
            import openai; self.client = openai.OpenAI(api_key=key)

    def _generate(self, model, prompt):
        """One call to the provider; returns the answer text."""
        if self.provider == "gemini":
            return self.client.models.generate_content(model=model, contents=prompt).text or ""
        if self.provider == "anthropic":
            r = self.client.messages.create(model=model, max_tokens=1500, messages=[{"role": "user", "content": prompt}])
            return "".join(b.text for b in r.content if getattr(b, "type", "") == "text")
        if self.provider == "openai":
            r = self.client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}])
            return r.choices[0].message.content or ""

    def probe(self, candidates):
        """Try each model once with a tiny prompt; report which ones respond."""
        ok = []
        for m in candidates:
            t = time.time()
            try:
                text = self._generate(m, "Reply with the single word OK.")
                print(f"  {m:32} OK   ({time.time()-t:.1f}s)  {text.strip()[:40]!r}"); ok.append(m)
            except Exception as e:
                msg = str(e); code = getattr(e, "code", None) or getattr(e, "status_code", None)
                short = "overloaded (503)" if "UNAVAILABLE" in msg else "rate-limited (429)" if "RESOURCE_EXHAUSTED" in msg else f"error {code}: {msg[:600]}"
                print(f"  {m:32} FAIL {short}")
        print("\nworking:", ", ".join(ok) if ok else "none — wait a while and rerun, or try another --provider")

    def list_models(self):
        print(f"models available to this {self.provider} key:")
        if self.provider == "gemini":
            for m in self.client.models.list():
                if "generateContent" in (getattr(m, "supported_actions", None) or []): print("  ", m.name.replace("models/", ""))
        elif self.provider == "anthropic":
            for m in self.client.models.list(limit=100): print("  ", m.id)
        elif self.provider == "openai":
            for m in sorted(x.id for x in self.client.models.list()): print("  ", m)

    def __call__(self, prompt):
        """Returns (text, model_used). Retries on rate limits / overload; after 3 overload failures switches to the next fallback model."""
        while True:
            for attempt in range(3):
                try:
                    return self._generate(self.model, prompt), self.model
                except Exception as e:
                    msg = str(e)[:300]
                    code = getattr(e, "code", None) or getattr(e, "status_code", None)
                    retryable = code in (429, 500, 502, 503, 504, 529) or "RESOURCE_EXHAUSTED" in msg or "UNAVAILABLE" in msg or "overloaded" in msg.lower()
                    if "exceeded your current quota" in msg or "quota" in msg.lower() and code == 429:
                        print(f"  daily quota of {self.model} used up — switching model or stop and rerun with --resume after the reset (midnight Pacific = 2 pm Thailand)", file=sys.stderr)
                        break
                    print(f"  [{self.model}] error: {msg}", file=sys.stderr)
                    if not retryable:
                        if code == 404 or "NOT_FOUND" in msg:
                            print(f"  model '{self.model}' not found for this key — run  python rag.py --list-models  and pass --model <name>", file=sys.stderr)
                        sys.exit(1)
                    wait = 10 * (attempt + 1); print(f"  retry in {wait}s", file=sys.stderr); time.sleep(wait)
            if not self.fallbacks:
                return "[generation failed]", self.model
            nxt = self.fallbacks.pop(0)
            print(f"  {self.model} still unavailable — switching to {nxt}", file=sys.stderr)
            self.model = nxt

def is_burmese(text):
    return any("\u1000" <= ch <= "\u109f" for ch in text)

def translate_to_burmese(question, gen):
    """Query translation for cross-lingual retrieval: a non-Burmese question is rendered in Burmese so that the
    keyword index (and the dense model, in-language) can match names, places and terms in the record."""
    try:
        text, _ = gen(f"Translate the following question into natural Burmese as a Myanmar citizen would ask it about "
                      f"parliament. Keep personal names in their usual Burmese form (e.g. Aung San Suu Kyi → ဒေါ်အောင်ဆန်းစုကြည်). "
                      f"Output only the Burmese translation, nothing else.\n\n{question}")
        text = text.strip().splitlines()[0].strip() if text.strip() else ""
        return text if is_burmese(text) else ""
    except Exception:
        return ""

def answer(question, ret, gen, k=K, no_retrieval=False, per_speech=PER_SPEECH, lang=None, translate=True):
    """lang: None/"auto" = language of the question (detected, then stated explicitly to the model);
    "my" or "en" = force the answer language. translate: for non-Burmese questions, also retrieve with a Burmese translation."""
    if lang in (None, "auto"): lang = "my" if is_burmese(question) else "en"
    query_my = ""
    if not no_retrieval and translate and not is_burmese(question):
        query_my = translate_to_burmese(question, gen)
    hits = [] if no_retrieval else ret.search(question, k=k, per_speech=per_speech, extra_queries=[query_my] if query_my else ())
    prompt = build_prompt_no_retrieval(question) if no_retrieval else build_prompt(question, hits, lang)
    t = time.time(); text, used = gen(prompt); dt = time.time() - t
    return {"question": question, "answer": text.strip(), "provider": gen.provider, "model": used, "retrieval": not no_retrieval,
            "retriever": None if no_retrieval else ret.name, "k": k, "per_speech": per_speech, "lang": lang, "query_my": query_my or None, "gen_seconds": round(dt, 2),
            "passages": [{"n": i, "score": round(s, 4), "chunk_id": m["chunk_id"], "speech_id": m["speech_id"], "citation": citation_of(m), "text": m["text"]}
                         for i, (s, m) in enumerate(hits, 1)]}

def show(res):
    print(f"\nQ: {res['question']}\n\nA: {res['answer']}\n")
    for p in res["passages"]: print(f"[{p['n']}] ({p['score']:.3f}) {p['citation']}")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("question", nargs="?")
    ap.add_argument("--batch", help="jsonl with a 'question' field per line")
    ap.add_argument("--out", default=None)
    ap.add_argument("--k", type=int, default=K)
    ap.add_argument("--provider", default=DEFAULT_PROVIDER, choices=list(PROVIDERS))
    ap.add_argument("--model", default=DEFAULT_MODEL, help="model name for the provider (default: provider's default)")
    ap.add_argument("--no-retrieval", action="store_true")
    ap.add_argument("--resume", action="store_true", help="continue a batch: keep answers already in --out, skip their ids")
    ap.add_argument("--no-fallback", action="store_true", help="never switch model; stop the batch instead (use for the SQ2 runs)")
    ap.add_argument("--embed", default=EMBED, help="embedding model key (embed_models.py)")
    ap.add_argument("--mode", default=MODE, choices=["dense", "bm25", "hybrid"])
    ap.add_argument("--per-speech", type=int, default=PER_SPEECH, help="max passages per speech in the top-k (0 = no cap)")
    ap.add_argument("--list-models", action="store_true", help="show the models this API key can call, then exit")
    ap.add_argument("--probe", action="store_true", help="try each candidate model once and report which respond, then exit")
    args = ap.parse_args()
    gen = Generator(args.provider, args.model, args.no_fallback)
    if args.list_models: gen.list_models(); return
    if args.probe:
        extra = ["gemini-3.7-flash", "gemini-3.5-flash"] if args.provider == "gemini" else []
        gen.probe([gen.model] + gen.fallbacks + extra); return
    idx = None if args.no_retrieval else Retriever(model=args.embed, mode=args.mode)
    ps = args.per_speech or None
    if args.batch:
        qs = [json.loads(l) for l in open(args.batch, encoding="utf-8") if l.strip()]
        out_path = args.out or args.batch.replace(".jsonl", "_answers.jsonl")
        done = set()
        if args.resume and os.path.exists(out_path):
            done = {json.loads(l)["id"] for l in open(out_path, encoding="utf-8") if l.strip() and "[generation failed]" not in l}
            print(f"resuming: {len(done)} answers already in {out_path}")
        out = open(out_path, "a" if args.resume else "w", encoding="utf-8")
        for i, q in enumerate(qs, 1):
            qid = q.get("id", i)
            if qid in done: continue
            res = answer(q["question"], idx, gen, args.k, args.no_retrieval, ps); res["id"] = qid
            if res["answer"] == "[generation failed]":
                print(f"{i}/{len(qs)} {qid}: generation failed — stopping; rerun with --resume later", flush=True); break
            out.write(json.dumps(res, ensure_ascii=False) + "\n"); out.flush()
            print(f"{i}/{len(qs)} done ({res['gen_seconds']}s)", flush=True)
        print(f"→ {out_path}")
    elif args.question:
        show(answer(args.question, idx, gen, args.k, args.no_retrieval, ps))
    else:
        print("Ask a question (empty line to quit)")
        while True:
            try: q = input("\n> ").strip()
            except (EOFError, KeyboardInterrupt): break
            if not q: break
            show(answer(q, idx, gen, args.k, args.no_retrieval, ps))

if __name__ == "__main__":
    main()