File size: 7,536 Bytes
79ecbd3 | 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 | """Run the 75-item project eval set through the RAG pipeline (--mode rag)
or the bare generator (--mode norag). Preambles and scoring match the
Check-In 4 notebook. PSL s141 is excluded from the corpus in code
(transmission-only; see the sources.csv notes), so the Qwen3-4B arms were
rerun with this script rather than reusing the Check-In 4 numbers.
Usage: python run_own_eval.py --mode rag --k 5
"""
import argparse, os, json
from bench_common import (Retriever, generate, save_outputs, cleanup,
GEN_MODEL, MODEL_TAG)
DATA_DIR = os.environ.get("DATA_DIR", "RAG Data")
SCRATCH = os.environ.get("SCRATCH", ".") # results/ and emb/ land here
EVAL_PATH = os.environ.get("EVAL_PATH", "eval_set_v1.jsonl")
# Verbatim from the Check-In 4 notebook (cell: own-eval definitions).
SYSTEM_PREAMBLE = """You are a regulatory assistant for early-stage solar development in New York State. Answer the developer's question using ONLY the context passages below. Cite the supporting document and section for every claim in the form [Document, Section]. If the context does not contain the answer, reply: "The provided sources do not answer this question" and briefly say what source likely would."""
NORAG_PREAMBLE = """You are a regulatory assistant for early-stage solar development in New York State. Answer the developer's question from your own knowledge. Cite the governing document and section for every claim in the form [Document, Section]. If you do not know, say so."""
DECLINE_MARKERS = ['do not answer', 'does not answer', 'cannot answer',
'not contain', 'do not know', 'no information']
# Docs kept out of the index at notebook level (D-09) plus the Q-11 fix.
EXCLUDE_FILES = [
'NYSERDA Solar Guidebook for Local Governments (effective November 2025).pdf',
]
EXCLUDE_SECTION_FILES = ['psl_a8_s141.txt'] # Q-11: transmission twin of s143
def load_corpus(data_dir=DATA_DIR):
"""Load sources.csv rows with ingest == true, minus EXCLUDE_FILES."""
import pandas as pd
from pypdf import PdfReader
manifest = pd.read_csv(os.path.join(data_dir, 'sources.csv'))
m = manifest[manifest['ingest'].astype(str).str.lower() == 'true']
m = m[~m['filename'].isin(EXCLUDE_FILES)]
docs = []
for _, row in m.iterrows():
path = os.path.join(data_dir, row['filename'])
if row['filename'].endswith('/'):
for fname in sorted(os.listdir(path)):
if not fname.endswith('.txt'):
continue
if fname in EXCLUDE_SECTION_FILES:
print(f"NOTE: skipping {fname} (Q-11: transmission-only "
f"section; excluded in code)")
continue
with open(os.path.join(path, fname), encoding='utf-8') as fh:
docs.append({'text': fh.read(), 'source': fname,
'family': row['document_family']})
elif path.endswith('.md') or path.endswith('.txt'):
with open(path, encoding='utf-8') as fh:
docs.append({'text': fh.read(), 'source': row['filename'],
'family': row['document_family']})
else:
reader = PdfReader(path)
text = '\n'.join(p.extract_text() or '' for p in reader.pages)
docs.append({'text': text, 'source': row['filename'],
'family': row['document_family']})
n_words = sum(len(d['text'].split()) for d in docs)
print(f"{len(docs)} documents, {n_words:,} words")
return docs
def chunk_corpus(docs):
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1500, chunk_overlap=150,
separators=['\n## ', '\n\n', '\n', '. ', ' '])
chunks = []
for d in docs:
for c in splitter.split_text(d['text']):
chunks.append({'text': c, 'id': d['source'],
'family': d['family']})
print(f"{len(chunks)} chunks")
return chunks
def build_retriever(chunks):
# v2 cache name: chunk count changed when s141 was dropped, and the
# Retriever asserts on row count, so a stale cache fails loudly.
return Retriever(chunks, f"{SCRATCH}/emb/own_corpus_v2.npy")
def load_eval_set(path=EVAL_PATH):
with open(path, encoding='utf-8') as fh:
items = [json.loads(l) for l in fh if l.strip()]
from collections import Counter
print(f"{len(items)} items", Counter(i['stratum'] for i in items))
return items
def score_item(item, response):
resp = response.lower()
declined = any(m in resp for m in DECLINE_MARKERS)
cited_gold = any(tok.lower() in resp
for tok in item.get('gold_citation_keys', []))
if item['stratum'] == 'unanswerable':
return {'declined': declined, 'over_answer': not declined,
'over_decline': False, 'cited_gold': None}
return {'declined': declined, 'over_answer': False,
'over_decline': declined,
'cited_gold': cited_gold and not declined}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=["rag", "norag"], required=True)
ap.add_argument("--k", type=int, default=5)
args = ap.parse_args()
print(f"generator: {GEN_MODEL} tag: {MODEL_TAG or '(none)'}")
items = load_eval_set()
retriever = None
if args.mode == "rag":
retriever = build_retriever(chunk_corpus(load_corpus()))
samples = []
for i, item in enumerate(items):
if retriever is not None:
hits = retriever.search(item['question'], k=args.k)
context = '\n\n'.join(
f'[{j+1}] ({h["id"]}): "{h["text"]}"'
for j, (h, _) in enumerate(hits))
block = f"Context:\n{context}\n\nQuestion: {item['question']}"
resp = generate(block, SYSTEM_PREAMBLE)
sources = [h['id'] for h, _ in hits]
else:
resp = generate(f"Question: {item['question']}", NORAG_PREAMBLE)
sources = []
row = {'id': item['id'], 'stratum': item['stratum'],
'family': item['family'], 'question': item['question'],
'response': resp, 'retrieved': sources}
row.update(score_item(item, resp))
if sources and item.get('gold_sources'):
row['retrieval_hit'] = any(s in item['gold_sources'] for s in sources)
samples.append(row)
print(f" [{i+1}/{len(items)}] {item['id']} "
f"{'DECLINED' if row['declined'] else 'answered'}")
unans = [s for s in samples if s['stratum'] == 'unanswerable']
ans = [s for s in samples if s['stratum'] != 'unanswerable']
cited = [s['cited_gold'] for s in ans]
metrics = {
'over_answer_rate': (sum(s['over_answer'] for s in unans) / len(unans))
if unans else None,
'over_decline_rate': (sum(s['over_decline'] for s in ans) / len(ans))
if ans else None,
'gold_citation_rate': (sum(cited) / len(cited)) if cited else None,
}
hits = [s['retrieval_hit'] for s in samples if 'retrieval_hit' in s]
if hits:
metrics['retrieval_hit_rate'] = sum(hits) / len(hits)
save_outputs(f"{SCRATCH}/results", "own", args.mode, metrics, samples,
extra={'k': args.k, 'corpus': 'v2 (s141 excluded, Q-11)',
'eval_set': EVAL_PATH})
cleanup()
if __name__ == "__main__":
main()
|