1"""Second annotator for the match-validation sample: Claude (claude-opus-5-5) labels each candidate pair as2SAME (same product, same package size), SIZE_DIFF (same product, different size), DIFFERENT, or UNSURE.3Human adjudication (`label_human`) remains the reference; LLM labels are an auxiliary signal. Logged: model, tokens, cost.4"""5import json, os, time, pandas as pd6import anthropic7from dotenv import load_dotenv8from src.common import ROOT, path, get_logger, ledger910log = get_logger("llm_annotate")11MODEL = "claude-opus-5-5"; PRICE_IN, PRICE_OUT = 4.0, 20.0 # $/MTok1213SYSTEM = """You are an expert grocery product data annotator for Quebec (French/English product names).14For each candidate pair of listings from two different retailers, decide whether they are the SAME product15(identical brand, product, variant/flavour/fat content, AND package size/quantity). Labels:16SAME = same product and same size; SIZE_DIFF = same product but different package size or count;17DIFFERENT = different product, brand, variant, flavour or fat content; UNSURE = cannot tell from the text.18Be strict: 'Lait 2%' vs 'Lait 3.25%' is DIFFERENT; 'Yogourt vanille' vs 'Yogourt fraise' is DIFFERENT.19Missing brand on one side is acceptable if the name clearly contains the brand.20Return ONLY a JSON array of objects {"sample_id": int, "label": str, "reason": str (<= 12 words)}."""2122def main():23 load_dotenv(ROOT / ".env")24 f = path("processed") / "match_validation_sample.csv"25 s = pd.read_csv(f)26 client = anthropic.Anthropic(max_retries=3, timeout=300)27 tot_in = tot_out = 0; labels = {}28 for i in range(0, len(s), 20):29 chunk = s.iloc[i:i + 20]30 lines = []31 for _, r in chunk.iterrows():32 lines.append(f'{{"sample_id": {r.sample_id}, "A": "{r.source_a_txt}: brand={r.brand_a_txt!s} | {r.name_a_txt} | size={r.size_label_a_txt!s}", '33 f'"B": "{r.source_b_txt}: brand={r.brand_b_txt!s} | {r.name_b_txt} | size={r.size_label_b_txt!s}"}}')34 msg = "Candidate pairs:\n" + "\n".join(lines)35 with client.messages.stream(model=MODEL, max_tokens=8000, system=SYSTEM, messages=[{"role": "user", "content": msg}]) as st:36 resp = st.get_final_message()37 if resp.stop_reason == "refusal":38 log.warning("refusal on chunk %d", i); continue39 text = "".join(b.text for b in resp.content if b.type == "text").strip()40 text = text[text.find("["): text.rfind("]") + 1]41 for o in json.loads(text):42 labels[int(o["sample_id"])] = (o["label"], o.get("reason", ""))43 tot_in += resp.usage.input_tokens; tot_out += resp.usage.output_tokens44 time.sleep(0.5)45 s["label_llm"] = s.sample_id.map(lambda k: labels.get(k, ("", ""))[0])46 s["llm_reason"] = s.sample_id.map(lambda k: labels.get(k, ("", ""))[1])47 s.to_csv(f, index=False)48 cost = tot_in / 1e6 * PRICE_IN + tot_out / 1e6 * PRICE_OUT49 log.info("annotated %d/%d pairs with %s ; tokens in=%d out=%d ; cost=$%.3f", s.label_llm.ne("").sum(), len(s), MODEL, tot_in, tot_out, cost)50 log.info("LLM labels by tier:\n%s", pd.crosstab(s.tier, s.label_llm).to_string())51 ledger("1", "llm_annotate", f"{MODEL}: {len(s)} pairs, in={tot_in}, out={tot_out}, cost=${cost:.3f}")5253if __name__ == "__main__":54 main()55