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()
|