diff --git a/labagent/nl.py b/labagent/nl.py index ebd24d1..5259525 100644 --- a/labagent/nl.py +++ b/labagent/nl.py @@ -83,6 +83,8 @@ def extract_args(text: str, pack: Any) -> Dict[str, Any]: m = re.search(rf"(? str: + return f"{x:,.2f}" + + +def case_facts(t: Tables, case: Dict[str, Any]) -> Dict[str, Any]: + a, g = case["alert"], case["truth"] + tx = {x["id"]: x for x in t["transactions"]} + keys = [tx[k] for k in g["key_transactions"].split(";") if k] + acct = {x["id"]: x for x in t["accounts"]}[a["account"]] + cust = {c["id"]: c for c in t["customers"]}[acct["customer_id"]] + total = sum(float(x["amount"]) for x in keys) + parties = {p for x in keys for p in (x["from_account"], x["to_account"])} - {a["account"]} + days = sorted(x["timestamp"][:10] for x in keys) + return {"alert": a, "truth": g, "customer": cust, "keys": keys, "total": total, "parties": len(parties), "first": days[0], "last": days[-1], + "count": len(keys), "currency": keys[0]["currency"], "expected": int(cust["expected_monthly_activity"]), + "ratio": total / max(float(cust["expected_monthly_activity"]), 1.0)} + + +def _first(cases: List[Dict[str, Any]], pred) -> Optional[Dict[str, Any]]: + return next((c for c in cases if pred(c["truth"])), None) + + +def build_exercises(t: Tables, cases: List[Dict[str, Any]], jurisdiction: str, cohort: str) -> List[Dict[str, Any]]: + j = "us" if jurisdiction == "us" else "ca" + f = FACTS[j] + report = f["suspicious"] + home = "USD" if j == "us" else "CAD" + year = min(x["timestamp"][:4] for x in t["transactions"]) + ex: List[Dict[str, Any]] = [] + + # ---- Policies ----------------------------------------------------------------------------------------------------------------- + ex.append(dict(id="A1", section="Policies", title="Summarize the policy", + steps=["Open the file Policies > AML Policy Manual in Word.", "Ask Copilot: Summarize this document in five bullet points.", + "Read each bullet. Find the section in the manual that supports it."], + check=["Every bullet should match a numbered section. Write down any bullet you cannot find."], + expected=["Five bullets that cover: who owns the program, knowing the customer, monitoring alerts, reporting suspicious activity, and keeping records.", + "Each bullet maps to a section of the manual (sections 2, 3, 5, 8 and 9)."])) + ex.append(dict(id="A2", section="Policies", title="Find the cash rule", + steps=["Stay in the AML Policy Manual.", "Ask Copilot: Which section covers cash deposits just under 10,000?", + "Open the section it names and read it."], + check=["Does the section Copilot names really talk about cash deposits near the limit?"], + expected=["Section 6, Cash and structuring.", f"It says to file a {f['cash']} when the credit union {f['cash_rule']}, and to treat several deposits just under 10,000 as a red flag."])) + ex.append(dict(id="A3", section="Policies", title="Catch the AI's mistake: policy summary", + steps=["Read the draft below. An AI wrote it. It has two mistakes.", "Check each sentence against the AML Policy Manual.", "Write down the two mistakes and the section that proves each one."], + check=["Look at numbers and time periods first. They are the easiest things for an AI to get wrong."], + draft=("The program owner reports to the Board every month. Staff must verify the identity of every new customer before the first transaction. " + "Records must be kept for three years. A second investigator reviews every decision to escalate."), + expected=["Mistake 1: the owner reports to the Board each quarter, not every month (section 2).", + f"Mistake 2: records are kept for {f['records_time']}, not three years (section 9)."])) + + # ---- Regulations -------------------------------------------------------------------------------------------------------------- + ex.append(dict(id="B1", section="Regulations", title="Explain the cash report rule", + steps=["Open the file in Regulations that covers cash reports.", "Ask Copilot: Explain in plain words when a financial institution must report a cash transaction, and by when.", + "Compare its answer with the exact words in the file."], + check=["Does Copilot give the amount, the word that goes with it (more than, or at least), and the deadline?"], + expected=[f"The rule applies when the institution {f['cash_rule']}. The report is due {f['cash_time']}.", f"Source: {f['cash_cite']} and {f['cash_time_cite']}."])) + more = "$10,000 or more" if j == "ca" else "more than $10,000" + wrong = "more than $10,000" if j == "ca" else "$10,000 or more" + ex.append(dict(id="B2", section="Regulations", title="Catch the AI's mistake: the cash rule", + steps=["Read the draft below. An AI wrote it. It has two mistakes.", "Check it against the file in Regulations.", "Write down both mistakes and the exact words from the file."], + check=["One mistake is a small word that changes the rule. The other is a number of days."], + draft=f"A report is needed when the institution handles {wrong} in cash. The report must be sent within 30 days.", + expected=[f"Mistake 1: the amount is {more}, not {wrong}.", f"Mistake 2: the report is due {f['cash_time']}, not within 30 days.", f"Source: {f['cash_cite']}, {f['cash_time_cite']}."])) + + # ---- Cases -------------------------------------------------------------------------------------------------------------------- + graph = _first(cases, lambda g: g["label"] == "escalate" and g["pattern"] not in ("sanctions_match", "disguised_payroll", "disguised_supplies")) + other = _first(cases, lambda g: g["label"] == "escalate" and g["pattern"] in ("disguised_payroll", "disguised_supplies")) or graph + sanc = _first(cases, lambda g: g["pattern"] == "sanctions_match" and g["label"] == "escalate") + look = _first(cases, lambda g: g["label"] == "monitor" and g["pattern"].startswith("possible_")) + if graph: + n = case_facts(t, graph) + ex.append(dict(id="C1", section="Cases", title="Summarize a case and list the red flags", case=graph["number"], + steps=[f"Open the workbook for Case {graph['number']:02d} in the Cases folder.", f"Ask Copilot: Summarize this case and list the red flags.", + "Open the Transactions tab and check each red flag against the rows."], + check=["A red flag must point to rows you can see. If Copilot names one you cannot find, mark it as unproven."], + expected=[graph["truth"]["reason"], f"Rows to find on the Transactions tab: {', '.join(x['id'] for x in n['keys'][:8])}{' and more' if len(n['keys']) > 8 else ''}.", + f"The customer profile expects about {n['expected']:,} {n['currency']} a month. The listed transfers add up to {money(n['total'])}."])) + if other: + n = case_facts(t, other) + ex.append(dict(id="C2", section="Cases", title=f"Draft a {report} narrative and check it", case=other["number"], + steps=[f"Open the workbook and the Word file for Case {other['number']:02d}.", f"Ask Copilot: Draft a {report} narrative for this case. Say who, what, when, where, why and how.", + "Check every fact in the draft against the Transactions tab. Fix any fact that is wrong."], + check=["Every amount, date and transaction ID in the narrative must match a row. Add up the totals yourself."], + expected=[f"Customer: {n['customer']['name']}, account {other['alert']['account']}.", f"Dates: {n['first']} to {n['last']}. Transfers listed: {n['count']}. They add up to {money(n['total'])} {n['currency']}. Money that passes through an account is counted each time it moves.", + f"Profile: the customer is expected to handle about {n['expected']:,} {n['currency']} a month.", f"Why suspicious: {other['truth']['reason']}", + f"Decision: {other['truth']['label']}. Policy section 8. Rule: {f['suspicious_cite']}."])) + if sanc: + n = case_facts(t, sanc) + ex.append(dict(id="C3", section="Cases", title="Find the name match", case=sanc["number"], + steps=["Open the Cases folder. Select up to five case workbooks.", "Ask Copilot: Which of these customers has a name that matches a sanctions list entry?", + "Open the workbook it names and read the Summary and KYC tabs."], + check=["Copilot can only look at five files at a time. If the match is not in your five, pick five others."], + expected=[f"Case {sanc['number']:02d}: {n['customer']['name'].rstrip('.')}.", sanc["truth"]["reason"]])) + if look: + n = case_facts(t, look) + job = OTHER_JOB.get(n["customer"]["occupation"], "student") + draft = (f"Case {look['number']:02d} is about {n['customer']['name']}, who works as a {job}. Between {n['first']} and {n['last']} the account had {n['count'] + 2} transfers " + f"totalling {money(n['total'] * 2)} {n['currency']}. The case should be escalated.") + ex.append(dict(id="C4", section="Cases", title="Catch the AI's mistake: case summary", case=look["number"], + steps=[f"Read the draft below. An AI wrote it. It has three mistakes.", f"Open the workbook for Case {look['number']:02d} and check each fact.", "Write down the three mistakes with the right facts."], + check=["Check the occupation on the KYC tab, then count and add up the rows on the Transactions tab."], + draft=draft, + expected=[f"Mistake 1: the customer is described in the file as: {n['customer']['occupation']}.", f"Mistake 2: the case covers {n['count']} transfers, not {n['count'] + 2}.", + f"Mistake 3: the total is {money(n['total'])} {n['currency']}, not {money(n['total'] * 2)}.", f"The decision in the answer key is {look['truth']['label']}, not escalate."])) + + # ---- Data --------------------------------------------------------------------------------------------------------------------- + internal = {a["id"] for a in t["accounts"]} + owner = {a["id"]: a["customer_id"] for a in t["accounts"]} + names = {c["id"]: c["name"] for c in t["customers"]} + rows = sorted(t["transactions"], key=lambda x: (x["timestamp"], x["id"])) + seen, new_payee = set(), [] + for x in rows: + pair = (x["from_account"], x["to_account"]) + if x["from_account"] in internal and x["timestamp"].startswith(f"{year}-03") and float(x["amount"]) > 5000 and pair not in seen: + new_payee.append(x) + seen.add(pair) + ex.append(dict(id="D1", section="Data", title="Find large transfers to new payees", steps=[ + "Open Data > Transactions in Excel. Turn on AutoSave.", "Ask Copilot: Find all transfers over 5,000 to new payees in March. A new payee is an account the sender has not paid before in this file.", + "Check the list. Does Copilot say how it decided a payee was new?"], check=["To call a payee new, you must look at earlier months too. If the file is too big for Copilot, use the file for one month in Data > Transactions by month and say which payees are new within that month only."], + expected=[f"{len(new_payee)} transfers over 5,000 went to a payee the sender had not paid earlier in the data.", f"First five: {', '.join(x['id'] for x in new_payee[:5])}." + ])) + cash: Dict[str, float] = {} + for x in rows: + if x["channel"] == "cash" and x["to_account"] in internal and x["currency"] == home: + cash[names[owner[x["to_account"]]]] = cash.get(names[owner[x["to_account"]]], 0.0) + float(x["amount"]) + top = sorted(cash.items(), key=lambda kv: (-kv[1], kv[0]))[:5] + ex.append(dict(id="D2", section="Data", title="Rank customers by cash deposits", steps=[ + "Open Data > Transactions in Excel. Turn on AutoSave.", f"Ask Copilot: Which five customers received the most cash deposits in {home}? Show the total for each.", + "Check one total yourself with a filter on the Channel column."], check=["Only count rows where Channel is cash and the money went to a customer account."], + expected=[f"{i}. {n}: {money(v)} {home}" for i, (n, v) in enumerate(top, 1)])) + by_channel: Dict[str, int] = {} + by_month: Dict[str, int] = {} + for x in rows: + by_channel[x["channel"]] = by_channel.get(x["channel"], 0) + 1 + by_month[x["timestamp"][:7]] = by_month.get(x["timestamp"][:7], 0) + 1 + busiest = max(sorted(by_month), key=lambda m: by_month[m]) + ex.append(dict(id="D3", section="Data", title="Chart activity by channel", steps=[ + "Open Data > Transactions in Excel. Turn on AutoSave.", "Ask Copilot: Make a chart of the number of transfers each month, split by channel.", "Check that the chart has twelve months and five channels."], + check=["Hover over one bar and compare its number with a count you make using a filter."], + expected=["Transfers by channel: " + ", ".join(f"{k} {v:,}" for k, v in sorted(by_channel.items(), key=lambda kv: -kv[1])) + ".", f"Busiest month: {busiest} with {by_month[busiest]:,} transfers."])) + et = [float(x["amount"]) for x in rows if x["channel"] == "e-transfer" and x["currency"] == home] + avg = sum(et) / len(et) + ex.append(dict(id="D4", section="Data", title="Catch the AI's mistake: the numbers", steps=[ + "Read the draft below. An AI wrote it. It has two mistakes.", f"In Data > Transactions, filter Channel to e-transfer and Currency to {home}.", "Work out the real count and the real average. Write down both."], + check=["Select the Amount column and read the Count and Average at the bottom of Excel. Then try the AVERAGE formula."], + draft=f"There are {len(et) + 1_000:,} e-transfers in {home}, and the average e-transfer is {money(avg * 1.35)} {home}.", + expected=[f"Mistake 1: the count is {len(et):,}, not {len(et) + 1000:,}.", f"Mistake 2: the average is {money(avg)} {home}, not {money(avg * 1.35)}."])) + return ex diff --git a/labagent/packs/aml/copilot/guidance.py b/labagent/packs/aml/copilot/guidance.py new file mode 100644 index 0000000..dc55441 --- /dev/null +++ b/labagent/packs/aml/copilot/guidance.py @@ -0,0 +1,73 @@ +"""FINTRAC guidance, fetched on the instructor's machine at launch and written into the cohort folder. Never committed to the repository +and never put in a snapshot: its terms have not been reviewed for redistribution (see licences.py).""" +from __future__ import annotations + +import re +from datetime import date +from html.parser import HTMLParser +from typing import Callable, Dict, List, Tuple + +BASE = "https://fintrac-canafe.canada.ca/guidance-directives/transaction-operation" +PAGES = { + "FINTRAC indicators for financial entities": f"{BASE}/indicators-indicateurs/fin_mltf-eng", + "FINTRAC guidance on reporting suspicious transactions": f"{BASE}/str-dod/str-dod-eng", +} +SKIP = {"script", "style", "nav", "header", "footer", "noscript", "form"} +BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "table", "section", "ul", "ol"} + + +class _Main(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.out: List[str] = [] + self.skip = 0 + self.in_main = 0 + self.saw_main = False + + def handle_starttag(self, tag, attrs): + if tag == "main": + self.in_main += 1 + self.saw_main = True + if tag in SKIP: + self.skip += 1 + if tag in BLOCK and not self.skip: + self.out.append("\n") + if tag == "li" and not self.skip: + self.out.append("- ") + + def handle_endtag(self, tag): + if tag == "main" and self.in_main: + self.in_main -= 1 + if tag in SKIP and self.skip: + self.skip -= 1 + if tag in BLOCK and not self.skip: + self.out.append("\n") + + def handle_data(self, data): + if not self.skip and (self.in_main or not self.saw_main): + self.out.append(data) + + +def html_to_text(html: str) -> str: + p = _Main() + p.feed(html) + text = re.sub(r"[ \t]+", " ", "".join(p.out)) + return re.sub(r"\n\s*\n+", "\n\n", text).strip() + + +def fetch_guidance(http: Callable[[str], bytes]) -> Tuple[Dict[str, str], List[str]]: + """Returns ({title: text}, [problems]). A page that cannot be fetched is reported, not fatal.""" + out: Dict[str, str] = {} + problems: List[str] = [] + for title, url in PAGES.items(): + try: + text = html_to_text(http(url).decode("utf-8", "replace")) + except Exception as e: # a guidance page is a bonus: the lab works without it + problems.append(f"{title}: {type(e).__name__}") + continue + if len(text) < 500: + problems.append(f"{title}: the page had no readable text") + continue + out[title] = (f"{title}\n\nSource: {url}\nDownloaded {date.today().isoformat()} by the instructor from the FINTRAC website. " + f"FINTRAC owns this text. Check its terms of use before sharing it outside your cohort.\n\n{text}\n") + return out, problems diff --git a/labagent/packs/aml/copilot/office.py b/labagent/packs/aml/copilot/office.py new file mode 100644 index 0000000..d1c924f --- /dev/null +++ b/labagent/packs/aml/copilot/office.py @@ -0,0 +1,138 @@ +"""Write Word and Excel files. Excel data goes in real Excel tables, because Copilot in Excel works on tables or clean ranges: +one header row, unique headers, no merged cells, no blank rows.""" +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from docx import Document +from docx.shared import Pt +from openpyxl import Workbook +from openpyxl.styles import Font +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.table import Table, TableStyleInfo + +from ..corpus.documents import Doc + +FIXED = datetime(2026, 1, 1) +NUM = re.compile(r"^-?\d+(\.\d+)?$") + + +def _new_docx(title: str) -> Document: + d = Document() + d.styles["Normal"].font.name = "Calibri" + d.styles["Normal"].font.size = Pt(11) + d.core_properties.author = "Maple Ridge Credit Union (invented)" + d.core_properties.title = title + d.core_properties.created = d.core_properties.modified = FIXED + return d + + +def _table(d: Document, lines: Sequence[str]) -> None: + head = [c.strip() for c in lines[0].split(" | ")] + t = d.add_table(rows=1, cols=len(head)) + t.style = "Light Grid Accent 1" + for i, h in enumerate(head): + t.rows[0].cells[i].text = h + for ln in lines[1:]: + cells = ln.split(" | ") + row = t.add_row().cells + for i in range(len(head)): + row[i].text = cells[i].strip() if i < len(cells) else "" + + +def write_docx(doc: Doc, path: Path) -> Path: + """A document with numbered headings. Lines that start with 'Date | ID' become a real table.""" + d = _new_docx(doc.title) + d.add_heading(doc.title, 0) + for s in doc.sections: + d.add_heading(f"{s.id}. {s.heading}" if doc.kind != "case" else s.heading, 1) + buf: List[str] = [] + for p in s.paragraphs: + if " | " in p: + buf.append(p) + continue + if buf: + _table(d, buf) + buf = [] + d.add_paragraph(p) + if buf: + _table(d, buf) + path.parent.mkdir(parents=True, exist_ok=True) + d.save(path) + return path + + +def write_plain_docx(title: str, blocks: Sequence[Dict[str, Any]], path: Path) -> Path: + """blocks: {h1|h2|p|bullets|numbered|table|quote: value}. Used for the guide, the answer key and the regulation excerpts.""" + d = _new_docx(title) + d.add_heading(title, 0) + for b in blocks: + (kind, val), = b.items() + if kind == "h1": + d.add_heading(val, 1) + elif kind == "h2": + d.add_heading(val, 2) + elif kind == "p": + d.add_paragraph(val) + elif kind == "quote": + d.add_paragraph(val, style="Intense Quote") + elif kind == "bullets": + for v in val: + d.add_paragraph(v, style="List Bullet") + elif kind == "numbered": + for v in val: + d.add_paragraph(v, style="List Number") + elif kind == "table": + _table(d, val) + path.parent.mkdir(parents=True, exist_ok=True) + d.save(path) + return path + + +def _cell(v: Any) -> Any: + if isinstance(v, str): + if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$", v): + return datetime.fromisoformat(v) + if re.match(r"^\d{4}-\d{2}-\d{2}$", v): + return datetime.fromisoformat(v) + if NUM.match(v) and not (len(v) > 1 and v.startswith("0") and "." not in v): + return float(v) if "." in v else int(v) + return v + + +def add_table_sheet(wb: Workbook, title: str, columns: List[str], rows: List[List[Any]], table_name: str, first: bool = False) -> None: + ws = wb.active if first else wb.create_sheet() + ws.title = title + ws.append(columns) + for r in rows: + ws.append([_cell(v) for v in r]) + if not rows: + ws.append([""] * len(columns)) # a table needs at least one data row + ref = f"A1:{get_column_letter(len(columns))}{max(len(rows), 1) + 1}" + t = Table(displayName=re.sub(r"[^A-Za-z0-9_]", "_", table_name), ref=ref) + t.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True) + ws.add_table(t) + for i, c in enumerate(columns, 1): + width = max([len(str(c))] + [len(str(r[i - 1])) for r in rows[:200]]) + 2 + ws.column_dimensions[get_column_letter(i)].width = min(max(width, 10), 60) + for cell in ws[get_column_letter(i)][1:]: + if isinstance(cell.value, datetime): + cell.number_format = "yyyy-mm-dd hh:mm" if c.lower() in ("date and time", "timestamp") else "yyyy-mm-dd" + elif isinstance(cell.value, float): + cell.number_format = "#,##0.00" + ws.freeze_panes = "A2" + + +def write_xlsx(path: Path, sheets: Sequence[Dict[str, Any]]) -> Path: + """sheets: [{title, columns, rows, name}] -> one Excel table per sheet.""" + wb = Workbook() + wb.properties.creator = "Maple Ridge Credit Union (invented)" + wb.properties.created = wb.properties.modified = FIXED + for i, s in enumerate(sheets): + add_table_sheet(wb, s["title"], s["columns"], s["rows"], s["name"], first=i == 0) + path.parent.mkdir(parents=True, exist_ok=True) + wb.save(path) + return path diff --git a/labagent/packs/aml/copilot/package.py b/labagent/packs/aml/copilot/package.py new file mode 100644 index 0000000..51b8098 --- /dev/null +++ b/labagent/packs/aml/copilot/package.py @@ -0,0 +1,257 @@ +"""Build the Copilot folder: Policies, Regulations, Cases, Data and the exercise guide for learners, and a separate instructor folder +with the answers. The learner folder never contains an answer.""" +from __future__ import annotations + +import re +import zipfile +from datetime import date +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +from docx import Document +from openpyxl import load_workbook + +from ..corpus import documents as D +from ..schema import INSTITUTION, RULES, Tables +from .exercises import SECTIONS, build_exercises, case_facts +from .office import write_docx, write_plain_docx, write_xlsx +from .select import select_cases + +EXCERPTS = { + "us": [("31 CFR", ["1010.311", "1010.306", "1010.313", "1010.314", "1010.230", "1010.430", "1020.220", "1020.320"])], + "ca": [("PCMLTFA", ["6", "6.1", "7", "8", "9.3", "9.6"]), ("PCMLTF STR Regulations", ["9"]), ("PCMLTF Regulations", ["10", "132", "148"])], +} +EXCERPT_TITLE = {"31 CFR": "US rules: 31 CFR Chapter X (excerpt)", "PCMLTFA": "Canada: Proceeds of Crime (Money Laundering) and Terrorist Financing Act (excerpt)", + "PCMLTF STR Regulations": "Canada: Suspicious Transaction Reporting Regulations (excerpt)", "PCMLTF Regulations": "Canada: Proceeds of Crime (Money Laundering) and Terrorist Financing Regulations (excerpt)"} +PLAIN = {"fan_out": "One account sends money to many accounts", "fan_in": "Many accounts send money to one account", + "gather_scatter": "Money is gathered into one account, then split out", "scatter_gather": "Money is split across accounts, then gathered again", + "cycle": "Money moves around a loop and comes back", "random": "Money passes through a chain of accounts", "bipartite": "One group of accounts pays another group", + "stack": "Layers of accounts pay each other", "disguised_payroll": "Illegal money paid out as payroll", "disguised_supplies": "Illegal money paid out as supplier invoices", + "sanctions_match": "The customer name matches a sanctions list entry", "possible_fan_in": "Looks like many-to-one, but small gifts", + "possible_cash_structuring": "Cash deposits close to the limit at a cash business", "possible_cycle": "A loan and its repayment", + "possible_rapid_in_out": "A payment passed straight on to a supplier", "none": "Ordinary activity with a documented reason"} +JARGON = re.compile(r"\b(RAG|embedding|embeddings|vector|vectors|Databricks|Lakebase|pgvector|chunk|chunks|LLM|API|JSON|CSV|Python|labagent|ground[_ ]truth)\b", re.I) +EMOJI = re.compile("[\U0001F300-\U0001FAFF☀-➿]") + + +def _name(cohort: str) -> str: + return re.sub(r"[^A-Za-z0-9 _.-]", "", cohort).strip() or "cohort" + + +def excerpt_blocks(chunks: List[Dict[str, Any]], jurisdiction: str) -> Dict[str, List[Dict[str, Any]]]: + """{file title: blocks} for the sections listed in EXCERPTS. Each file carries the source's notice.""" + by: Dict[str, Dict[str, List[str]]] = {} + meta: Dict[Tuple[str, str], Dict[str, Any]] = {} + for c in chunks: + m = c["meta"] + by.setdefault(m["instrument"], {}).setdefault(m["section"], []).append(c["content"]) + meta[(m["instrument"], m["section"])] = m + out: Dict[str, List[Dict[str, Any]]] = {} + for j in (["ca", "us"] if jurisdiction == "both" else [jurisdiction]): + for inst, sections in EXCERPTS[j]: + blocks: List[Dict[str, Any]] = [] + notice = "" + for sec in sections: + parts = by.get(inst, {}).get(sec) + if not parts: + continue + m = meta[(inst, sec)] + notice = m["notice"] + blocks += [{"h1": f"{m['citation']} {m['heading']}".strip()}, {"p": "\n\n".join(parts)}, {"p": f"Source: {m['url']} (version of {m['as_of']})"}] + if blocks: + out[EXCERPT_TITLE[inst]] = [{"quote": notice}] + blocks + return out + + +def case_sheets(t: Tables, ix: D.CaseIndex, case: Dict[str, Any]) -> List[Dict[str, Any]]: + a = case["alert"] + c = ix.customer(a) + rows, _ = ix.rows(a) + return [ + {"title": "Summary", "name": "Summary", "columns": ["Item", "Value"], "rows": [ + ["Case number", f"{case['number']:02d}"], ["Alert ID", a["id"]], ["Date raised", a["date"]], ["Rule triggered", a["rule"]], ["What the rule looks for", RULES[a["rule"]]], + ["Alert amount", a["amount"]], ["Risk score", str(a["risk_score"])], ["Account", a["account"]], ["Customer", c["name"]], ["Customer ID", c["id"]]]}, + {"title": "KYC", "name": "KYC", "columns": ["Field", "Value"], "rows": [ + ["Name", c["name"]], ["Type", c["type"]], ["Occupation or industry", c["occupation"]], ["Country", c["country"]], ["Risk rating", c["risk_rating"]], + ["Expected monthly activity", str(c["expected_monthly_activity"])], ["Customer since", c["onboarding_date"]], ["Profile", ix.kyc[c["id"]]]]}, + {"title": "Transactions", "name": "Transactions", "columns": ["Date and time", "ID", "From account", "To account", "Amount", "Currency", "Channel", "Description"], + "rows": [[x["timestamp"], x["id"], x["from_account"], x["to_account"], x["amount"], x["currency"], x["channel"], x["description"]] for x in rows]}, + {"title": "Alerts", "name": "Alerts", "columns": ["Alert ID", "Date", "Rule", "Amount", "Risk score"], + "rows": [[o["id"], o["date"], o["rule"], o["amount"], str(o["risk_score"])] for o in ix.alerts_by_account.get(a["account"], [])]}, + ] + + +def data_files(t: Tables) -> Dict[str, List[Dict[str, Any]]]: + tx = sorted(t["transactions"], key=lambda x: (x["timestamp"], x["id"])) + cols = ["Date and time", "ID", "From account", "To account", "Amount", "Currency", "Channel", "Description"] + row = lambda x: [x["timestamp"], x["id"], x["from_account"], x["to_account"], x["amount"], x["currency"], x["channel"], x["description"]] + files: Dict[str, List[Dict[str, Any]]] = {"Transactions": [{"title": "Transactions", "name": "Transactions", "columns": cols, "rows": [row(x) for x in tx]}]} + months: Dict[str, List[Any]] = {} + for x in tx: + months.setdefault(x["timestamp"][:7], []).append(row(x)) + for m, rows in sorted(months.items()): + files[f"Transactions by month/Transactions {m}"] = [{"title": "Transactions", "name": "Transactions", "columns": cols, "rows": rows}] + kyc = {k["customer_id"]: k["profile"] for k in t["kyc_profiles"]} + files["Customers"] = [{"title": "Customers", "name": "Customers", "columns": ["ID", "Name", "Type", "Occupation or industry", "Country", "Risk rating", "Expected monthly activity", "Customer since", "Profile"], + "rows": [[c["id"], c["name"], c["type"], c["occupation"], c["country"], c["risk_rating"], str(c["expected_monthly_activity"]), c["onboarding_date"], kyc[c["id"]]] for c in t["customers"]]}] + files["Accounts"] = [{"title": "Accounts", "name": "Accounts", "columns": ["ID", "Customer ID", "Type", "Opened", "Branch"], + "rows": [[a["id"], a["customer_id"], a["type"], a["open_date"], a["branch"]] for a in t["accounts"]]}] + files["Alerts"] = [{"title": "Alerts", "name": "Alerts", "columns": ["ID", "Rule", "What the rule looks for", "Account", "Date", "Amount", "Risk score"], + "rows": [[a["id"], a["rule"], RULES[a["rule"]], a["account"], a["date"], a["amount"], str(a["risk_score"])] for a in t["alerts"]]}] + files["Sanctions screening results"] = [{"title": "Results", "name": "Screening", "columns": ["ID", "Customer ID", "Customer name", "List", "List entry name", "Match type", "Score", "List date", "Note"], + "rows": [[h["id"], h["customer_id"], h["customer_name"], h["list"], h["sdn_name"], h["match_type"], h["score"], h["list_date"], h["note"]] for h in t["sanctions_hits"]]}] + return files + + +def guide_blocks(exercises: List[Dict[str, Any]], answers: bool, cohort: str, jurisdiction: str) -> List[Dict[str, Any]]: + country = {"ca": "Canada", "us": "the United States", "both": "Canada"}[jurisdiction] + b: List[Dict[str, Any]] = [ + {"p": f"{INSTITUTION} is an invented credit union. Every name, account and transfer in this folder is made up. The rules and laws are real ({country})." + (" This copy has the expected answers. Do not share it with learners." if answers else "")}, + {"h1": "Before you start"}, + {"numbered": ["Upload the whole folder to OneDrive or SharePoint. Copilot only works on files stored there.", "Open each Excel file from OneDrive or SharePoint. Check that AutoSave is on.", + "In Excel, click inside the table before you ask Copilot a question.", "Copilot can look at up to five files at a time. Pick your files before you ask.", + "If Copilot cannot handle the big Transactions file, use one month from Data > Transactions by month."]}, + {"h1": "How to check an AI answer"}, + {"bullets": ["Find the source. Every fact must point to a row, a cell or a sentence in a file.", "Do the sums yourself. Add up the amounts and count the rows.", + "Look hardest at numbers, dates and small words such as more than, at least and within."]}, + {"p": f"Cohort: {cohort}. There are {len(exercises)} exercises in four parts. Each part ends with one exercise where you find mistakes in an AI draft."}, + ] + for sec in SECTIONS: + group = [e for e in exercises if e["section"] == sec] + if not group: + continue + b.append({"h1": f"Part: {sec}"}) + for e in group: + b += [{"h2": f"{e['id']}. {e['title']}"}, {"numbered": e["steps"]}] + if e.get("draft"): + b.append({"quote": e["draft"]}) + b.append({"p": "Check yourself: " + " ".join(e["check"])}) + if answers: + b += [{"p": "Expected answer:"}, {"bullets": e["expected"]}] + return b + + +def answer_key_blocks(cases: List[Dict[str, Any]], t: Tables) -> List[Dict[str, Any]]: + b: List[Dict[str, Any]] = [{"p": "For instructors only. Decisions come from how the data was built, not from a real investigation. Use them to guide the discussion."}, + {"table": ["Case | Alert | Decision | What happened"] + [ + f"{c['number']:02d} | {c['alert']['id']} | {c['truth']['label']} | {PLAIN.get(c['truth']['pattern'], c['truth']['pattern'])}" for c in cases]}] + for c in cases: + f = case_facts(t, c) + b += [{"h2": f"Case {c['number']:02d} ({c['alert']['id']}): {c['truth']['label']}"}, {"p": c["truth"]["reason"]}, + {"p": f"Customer: {f['customer']['name']}. Key transfers: {', '.join(x['id'] for x in f['keys'][:12])}{' and more' if len(f['keys']) > 12 else ''}."}] + return b + + +def zip_folder(src: Path, dest: Path) -> Path: + """A zip with the folder as its top level, in sorted order with fixed times.""" + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: + for p in sorted(x for x in src.rglob("*") if x.is_file()): + info = zipfile.ZipInfo(f"{src.name}/{p.relative_to(src).as_posix()}", (2026, 1, 1, 0, 0, 0)) + info.compress_type, info.external_attr = zipfile.ZIP_DEFLATED, 0o644 << 16 + z.writestr(info, p.read_bytes()) + return dest + + +def build_package(t: Tables, jurisdiction: str, cohort: str, out: Path, n_cases: int, reg_chunks: List[Dict[str, Any]], + guidance: Optional[Dict[str, str]] = None, with_data_months: bool = True) -> Dict[str, Any]: + cohort = _name(cohort) + learner, instr = out / cohort, out / f"{cohort}-instructor" + country = lambda j: {"ca": "Canada", "us": "United States"}[j] + created: List[str] = [] + + for doc in D.policy_documents(jurisdiction): + base = {"policy": "AML Policy Manual", "procedure": "Alert Handling Procedure", "guide": "Guide to Writing the Report Narrative"}[doc.kind] + created.append(str(write_docx(doc, learner / "Policies" / f"{base} ({country(doc.jurisdiction)}).docx").relative_to(out))) + + ex_files = excerpt_blocks(reg_chunks, jurisdiction) + for title, blocks in ex_files.items(): + created.append(str(write_plain_docx(title, blocks, learner / "Regulations" / f"{re.sub(r'[:/]', '', title)}.docx").relative_to(out))) + for title, text in (guidance or {}).items(): + blocks = [{"p": para} for para in text.split("\n\n") if para.strip()] + created.append(str(write_plain_docx(title, blocks, learner / "Regulations" / f"{title}.docx").relative_to(out))) + if not ex_files and not guidance: + created.append(str(write_plain_docx("Regulations were not included", [{"p": "This cohort folder was built without the regulation text. Ask your instructor to build it again with the regulation text included."}], + learner / "Regulations" / "Read me.docx").relative_to(out))) + + cases = select_cases(t, n_cases) + ix = D.CaseIndex(t) + case_docs = {c.meta["alert_id"]: c for c in D.case_documents(t, jurisdiction, ix)} + for c in cases: + stem = f"Case {c['number']:02d} - {c['alert']['id']}" + created.append(str(write_xlsx(learner / "Cases" / f"{stem}.xlsx", case_sheets(t, ix, c)).relative_to(out))) + d = case_docs[c["alert"]["id"]] + d.title = f"Case {c['number']:02d} file ({c['alert']['id']})" + created.append(str(write_docx(d, learner / "Cases" / f"{stem}.docx").relative_to(out))) + created.append(str(write_xlsx(learner / "Cases" / "Case list.xlsx", [{"title": "Cases", "name": "Cases", "columns": ["Case", "Alert ID", "Date raised", "Rule", "Account", "Alert amount"], + "rows": [[f"{c['number']:02d}", c["alert"]["id"], c["alert"]["date"], c["alert"]["rule"], c["alert"]["account"], c["alert"]["amount"]] for c in cases]}]).relative_to(out))) + + for name, sheets in data_files(t).items(): + if name.startswith("Transactions by month/") and not with_data_months: + continue + created.append(str(write_xlsx(learner / "Data" / f"{name}.xlsx", sheets).relative_to(out))) + + exercises = build_exercises(t, cases, jurisdiction, cohort) + created.append(str(write_plain_docx(f"Copilot exercises: {INSTITUTION}", guide_blocks(exercises, False, cohort, jurisdiction), learner / "Exercise Guide.docx").relative_to(out))) + created.append(str(write_plain_docx(f"Copilot exercises with answers: {INSTITUTION}", guide_blocks(exercises, True, cohort, jurisdiction), instr / "Exercise Guide with Answers.docx").relative_to(out))) + created.append(str(write_plain_docx(f"Answer key: {INSTITUTION}", answer_key_blocks(cases, t), instr / "Instructor Answer Key.docx").relative_to(out))) + + lz = zip_folder(learner, out / f"{cohort}.zip") + iz = zip_folder(instr, out / f"{cohort}-instructor.zip") + created += [str(lz.relative_to(out)), str(iz.relative_to(out))] + return {"learner_dir": str(learner), "instructor_dir": str(instr), "learner_zip": str(lz), "instructor_zip": str(iz), "cases": len(cases), + "exercises": len(exercises), "files": len(created), "created": created, "excerpts": len(ex_files), "guidance": sorted(guidance or {})} + + +def _docx_text(path: Path) -> str: + d = Document(str(path)) + parts = [p.text for p in d.paragraphs] + for tb in d.tables: + parts += [c.text for r in tb.rows for c in r.cells] + return "\n".join(parts) + + +def verify_package(learner: Path, instructor: Path, reasons: List[str]) -> List[str]: + """Reopen every file as a person's software would. Returns problems (empty is good).""" + problems: List[str] = [] + for p in sorted(learner.rglob("*")): + if not p.is_file(): + continue + rel = p.relative_to(learner).as_posix() + if re.search(r"instructor|answer", p.name, re.I) and p.name != "Exercise Guide.docx": + problems.append(f"{rel}: an instructor file is in the learner folder") + if p.suffix == ".xlsx": + try: + wb = load_workbook(p, read_only=False) + except Exception as e: + problems.append(f"{rel}: cannot open ({type(e).__name__})") + continue + for ws in wb.worksheets: + if not ws.tables: + problems.append(f"{rel}: sheet {ws.title} has no Excel table") + heads = [c.value for c in ws[1]] + if len(set(heads)) != len(heads) or any(h in (None, "") for h in heads): + problems.append(f"{rel}: sheet {ws.title} has empty or repeated headers") + if "/Data/" not in "/" + rel and len(wb.worksheets) < 5: + text = " ".join(str(c.value) for ws in wb.worksheets for r in ws.iter_rows(max_row=60) for c in r if isinstance(c.value, str)) + if JARGON.search(text) or any(r in text for r in reasons): + problems.append(f"{rel}: technical term or answer text") + elif p.suffix == ".docx": + try: + text = _docx_text(p) + except Exception as e: + problems.append(f"{rel}: cannot open ({type(e).__name__})") + continue + if rel.startswith("Regulations/") and p.name.startswith("FINTRAC"): + continue + if JARGON.search(text): + problems.append(f"{rel}: technical term {JARGON.search(text).group(0)!r}") + if ("\u2014" in text and not rel.startswith("Regulations/")) or EMOJI.search(text): # quoted law keeps its own punctuation + problems.append(f"{rel}: em dash or emoji") + if any(r in text for r in reasons): + problems.append(f"{rel}: contains answer text") + for need in ("Exercise Guide.docx",): + if not (learner / need).exists(): + problems.append(f"missing {need}") + if not (instructor / "Instructor Answer Key.docx").exists(): + problems.append("missing Instructor Answer Key.docx") + return problems diff --git a/labagent/packs/aml/copilot/select.py b/labagent/packs/aml/copilot/select.py new file mode 100644 index 0000000..773fd72 --- /dev/null +++ b/labagent/packs/aml/copilot/select.py @@ -0,0 +1,32 @@ +"""Which cases go in the folder, and how they are numbered.""" +from __future__ import annotations + +from typing import Any, Dict, List + +from ..schema import Tables + +# (pattern, label) in the order cases are picked. The first twelve already hold every kind of exercise the guide needs: +# a graph pattern, a disguised payment, a name match, a look-alike and an ordinary event. +PRIORITY = [("fan_in", "escalate"), ("disguised_payroll", "escalate"), ("sanctions_match", "escalate"), ("possible_fan_in", "monitor"), ("none", "dismiss"), + ("fan_out", "escalate"), ("cycle", "escalate"), ("possible_cash_structuring", "monitor"), ("gather_scatter", "escalate"), + ("disguised_supplies", "escalate"), ("stack", "escalate"), ("possible_cycle", "monitor"), ("scatter_gather", "escalate"), + ("bipartite", "escalate"), ("random", "escalate"), ("possible_rapid_in_out", "monitor"), ("sanctions_match", "monitor")] + + +def select_cases(t: Tables, n: int) -> List[Dict[str, Any]]: + """n cases, balanced: one of every kind first (escalate kinds, then look-alikes, then ordinary events), then the rest by alert id. + Returns [{number, alert, truth}] numbered 1..n in alert-id order, so case numbers follow the dates.""" + truth = {g["alert_id"]: g for g in t["ground_truth"]} + alerts = {a["id"]: a for a in t["alerts"]} + chosen: List[str] = [] + for kind, label in PRIORITY: + hit = next((i for i in sorted(truth) if truth[i]["pattern"] == kind and truth[i]["label"] == label and i not in chosen), None) + if hit and len(chosen) < n: + chosen.append(hit) + for i in sorted(truth): + if len(chosen) >= n: + break + if i not in chosen: + chosen.append(i) + chosen = sorted(chosen[:n]) + return [{"number": k, "alert": alerts[i], "truth": truth[i]} for k, i in enumerate(chosen, 1)] diff --git a/labagent/packs/aml/pack.py b/labagent/packs/aml/pack.py index 3bf81ed..43a1752 100644 --- a/labagent/packs/aml/pack.py +++ b/labagent/packs/aml/pack.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import re import shutil from pathlib import Path from typing import Any, Dict, List, Optional @@ -106,7 +107,8 @@ class AmlLab(Pack): platforms = ("local",) cost_tier = "free" default_name = "demo" - output_keys = ("out_dir", "mode", "platform", "jurisdiction", "counts", "quality", "guide", "embedding_model") + output_keys = ("out_dir", "mode", "platform", "jurisdiction", "counts", "quality", "guide", "embedding_model", "learner_dir", "instructor_dir", + "learner_zip", "instructor_zip", "package") params = [ Param("mode", "rag", "rag (developers: indexed corpus and Python helpers) | copilot (staff: Excel, Word and PDF files)"), Param("jurisdiction", "ca", "ca | us | both"), @@ -119,6 +121,9 @@ class AmlLab(Pack): Param("embedder", "auto", "auto (full model if installed, else keyword-only fallback) | hashing (force the fast keyword-only embedder)"), Param("llm", "none", "none (mock answers) | env (use the chat model configured in the environment, and score it on triage)"), Param("dataset", "aml-synthetic", "generator=snapshot: the pinned dataset name"), + Param("cases", 20, "copilot: how many case workbooks to include, 12 to 60"), + Param("guidance", "fetch", "copilot, Canada: fetch (download the FINTRAC guidance pages now) | skip"), + Param("cohort", None, "copilot: name of the folder and zip (default: the instance name)"), Param("min_hit_rate", Q.HIT_GATE, "Retrieval hit rate the launch must reach (not applied to the keyword-only fallback)"), ] @@ -126,6 +131,26 @@ class AmlLab(Pack): def services(self, profile: Optional[str]) -> Dict[str, Any]: return {} + def intent_params(self, text: str) -> Dict[str, str]: + """"set up the AML copilot lab for Canada" -> mode=copilot, jurisdiction=ca.""" + low = text.lower() + out: Dict[str, str] = {} + if re.search(r"\b(copilot|co-pilot|microsoft 365|excel|staff|non-technical)\b", low): + out["mode"] = "copilot" + elif re.search(r"(? Path: return Path(ctx.params["out"] or f"aml-{ctx.name}").expanduser().resolve() @@ -133,8 +158,13 @@ def _check_params(self, ctx: PackContext) -> None: p = ctx.params if p["mode"] not in ("rag", "copilot"): raise PackError("mode must be rag or copilot") - if p["mode"] == "copilot": - raise PackError("mode=copilot is not available in this build yet. Use mode=rag.") + if p["guidance"] not in ("fetch", "skip"): + raise PackError("guidance must be fetch or skip") + try: + if int(p["cases"]) < 12 or int(p["cases"]) > 60: + raise ValueError + except (TypeError, ValueError): + raise PackError("cases must be a number from 12 to 60") from None if p["jurisdiction"] not in ("ca", "us", "both"): raise PackError("jurisdiction must be ca, us or both") if p["size"] not in ("small", "medium") and not ctx.services.get("allow_tiny"): @@ -167,7 +197,71 @@ def _write_config(self, ctx: PackContext, out: Path) -> Dict[str, Any]: return cfg # ---- steps ----------------------------------------------------------------------------------------------------------------- + def copilot_steps(self, ctx: PackContext) -> List[Step]: + from .copilot import guidance as G + from .copilot import package as P + from .schema import read_csv + out = self.out_dir(ctx) + + def preflight(c: PackContext): + self._check_params(c) + if out.exists() and not (out / MARKER).exists() and any(out.iterdir()): + raise PackError(f"{out} already exists and was not created by this pack. Choose another folder with --param out=...") + out.mkdir(parents=True, exist_ok=True) + (out / MARKER).write_text("created by labagent pack aml\n") + return {"out_dir": str(out), "mode": "copilot", "platform": "local", "jurisdiction": c.params["jurisdiction"], "created": [MARKER]} + + def data(c: PackContext): + try: + ds = generators.get(c.params["generator"]).generate( + seed=c.params["seed"], size=c.params["size"], jurisdiction=c.params["jurisdiction"], dataset=c.params["dataset"]) + except (datasets.DatasetError, KeyError) as e: + raise PackError(str(e)) from e + problems = validate(ds.tables) + if problems: + raise PackError("generated data failed validation: " + "; ".join(problems[:3])) + work = out / ".work" / "tables" + names = write_csv(ds.tables, work) + return {"counts": table_counts(ds.tables), "data_sources": ds.sources, + "created": c.state.get("created", []) + [str(p.relative_to(out)) for p in names]} + + def package(c: PackContext): + tables = read_csv(out / ".work" / "tables") + http = c.services.get("regs_http") or R.default_http + chunks: List[Dict[str, Any]] = [] + if c.params["regulations"] != "skip": + try: + chunks = R.regulation_chunks(c.params["jurisdiction"], http=http, cache_dir=c.services.get("regs_cache")) + except (OSError, ValueError) as e: + raise PackError(f"could not download the regulation text ({type(e).__name__}). Check your network, or launch with --param regulations=skip") from e + notes: List[str] = [] + texts: Dict[str, str] = {} + if c.params["guidance"] == "fetch" and c.params["jurisdiction"] in ("ca", "both"): + texts, notes = G.fetch_guidance(c.services.get("guidance_http") or R.default_http) + cohort = c.params["cohort"] or c.name + try: + res = P.build_package(tables, c.params["jurisdiction"], cohort, out, int(c.params["cases"]), chunks, texts) + except OSError as e: + raise PackError(f"could not write the files ({type(e).__name__}). Check the folder and the free space.") from e + reasons = [g["reason"] for g in tables["ground_truth"]] + problems = P.verify_package(Path(res["learner_dir"]), Path(res["instructor_dir"]), reasons) + if problems: + raise PackError("the folder failed its own checks: " + "; ".join(problems[:3])) + summary = {"files": res["files"], "cases": res["cases"], "exercises": res["exercises"], "regulation_excerpts": res["excerpts"], + "fintrac_guidance": res["guidance"], "guidance_notes": notes, "checks_passed": True} + return {"learner_dir": res["learner_dir"], "instructor_dir": res["instructor_dir"], "learner_zip": res["learner_zip"], + "instructor_zip": res["instructor_zip"], "package": summary, "created": c.state.get("created", []) + res["created"]} + + return [ + Step("preflight", "Check the options and the output folder", preflight, check=lambda c: (out / MARKER).exists() and "out_dir" in c.state), + Step("data", "Generate the synthetic customers, transfers and alerts", data, check=lambda c: "counts" in c.state and (out / ".work" / "tables" / "transactions.csv").exists()), + Step("package", "Build the Excel, Word and text files, the exercise guide and the zips", package, + check=lambda c: bool(c.state.get("package", {}).get("checks_passed")) and Path(c.state.get("learner_zip", "")).exists()), + ] + def deploy_steps(self, ctx: PackContext) -> List[Step]: + if ctx.params.get("mode") == "copilot": + return self.copilot_steps(ctx) out = self.out_dir(ctx) def preflight(c: PackContext): @@ -271,6 +365,15 @@ def remove(c: PackContext): shutil.rmtree(path) elif path.exists(): path.unlink() + for rel in c.state.get("created", []): # copilot mode lists every file it wrote: delete those, keep anything else + path = out / rel + if path.is_file(): + path.unlink() + for d in sorted((x for x in out.rglob("*") if x.is_dir()), key=lambda x: -len(x.parts)): + try: + d.rmdir() # empty folders only + except OSError: + pass try: out.rmdir() # only if empty: anything the learner added stays except OSError: diff --git a/labagent/packs/base.py b/labagent/packs/base.py index e0e0c8b..8341dd4 100644 --- a/labagent/packs/base.py +++ b/labagent/packs/base.py @@ -70,6 +70,10 @@ def describe(self) -> Dict[str, Any]: "default_name": self.default_name, "platforms": list(self.platforms), "cost_tier": self.cost_tier, "params": [{"name": q.name, "default": q.default, "help": q.help, "required": q.required} for q in self.params]} + def intent_params(self, text: str) -> Dict[str, str]: + """Parameters a plain-English request implies ("for Canada"). Exact rules on the sentence, never a model. Explicit `name value` pairs win.""" + return {} + def outputs(self, state: Dict[str, Any]) -> Dict[str, Any]: """Non-secret facts from state that go in the envelope (URLs, ids). Override when state has nested or mixed content.""" return {k: v for k, v in state.items() if k in getattr(self, "output_keys", ())} diff --git a/pyproject.toml b/pyproject.toml index 822df15..2aff946 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ rag-local = ["pydantic>=2", "chromadb>=0.5", "sentence-transformers>=3", "stream modal = ["modal>=1.0"] aml = ["openpyxl>=3.1", "python-docx>=1.1", "pyarrow>=15"] rag-lab = ["pydantic>=2", "chromadb>=0.5", "sentence-transformers>=3", "streamlit>=1.35", "pypdf>=4", "groq>=0.9", "langchain>=0.3", "qdrant-client>=1.9", "jupyterlab>=4", "ipykernel>=6"] -dev = ["pytest>=8", "pydantic>=2", "moto[all]>=5", "build>=1.2", "twine>=5"] +dev = ["pytest>=8", "pydantic>=2", "moto[all]>=5", "build>=1.2", "twine>=5", "openpyxl>=3.1", "python-docx>=1.1"] [project.urls] Homepage = "https://github.com/becloudready/labagent" diff --git a/tests/test_aml_copilot.py b/tests/test_aml_copilot.py new file mode 100644 index 0000000..35349de --- /dev/null +++ b/tests/test_aml_copilot.py @@ -0,0 +1,220 @@ +"""AML pack, part 4: mode=copilot. Builds real Word and Excel files and opens them again the way the software would.""" +import re +import zipfile +from pathlib import Path + +import pytest +from docx import Document +from openpyxl import load_workbook + +from labagent.packs.aml.copilot import guidance as G +from labagent.packs.aml.copilot import package as P +from labagent.packs.aml.copilot.exercises import SECTIONS, build_exercises +from labagent.packs.aml.copilot.select import select_cases +from labagent.packs.aml.gen import get +from labagent.packs.aml.pack import AmlLab +from labagent.packs.base import load_state, run_pack +from tests.test_aml_corpus import ECFR, JUSTICE + +PAGE = "

Indicators

" + "".join(f"

Indicator {i}: a client behaviour that may point to laundering.

" for i in range(30)) + "
" + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setenv("LABAGENT_HOME", str(tmp_path / "home")) + return tmp_path + + +def run(home, action="launch", **params): + base = {"mode": "copilot", "size": "tiny", "cases": 12, "out": str(home / "out")} + base.update(params) + sv = {"allow_tiny": True, "regs_http": lambda u: ECFR if "ECFR" in u else JUSTICE, "regs_cache": home / "regs", "guidance_http": lambda u: PAGE.encode()} + return run_pack(AmlLab(), action, "cohort1", base, services=sv) + + +def text_of(path): + d = Document(str(path)) + return "\n".join([p.text for p in d.paragraphs] + [c.text for t in d.tables for r in t.rows for c in r.cells]) + + +@pytest.fixture(scope="module") +def built(tmp_path_factory): + """One launch shared by the tests that only read the result.""" + home = tmp_path_factory.mktemp("copilot") + with pytest.MonkeyPatch.context() as mp: + mp.setenv("LABAGENT_HOME", str(home / "home")) + env = run(home) + assert env.status == "ok", env.error + return home, env + + +@pytest.fixture(scope="module") +def tables(): + return get("builtin").generate(seed=42, size="small", jurisdiction="ca").tables + + +# ---- the launch ---------------------------------------------------------------------------------------------------------------- + +def test_launch_builds_a_learner_folder_an_instructor_folder_and_two_zips(built): + home, env = built + out = home / "out" + learner, instr = out / "cohort1", out / "cohort1-instructor" + assert sorted(p.name for p in learner.iterdir()) == ["Cases", "Data", "Exercise Guide.docx", "Policies", "Regulations"] + assert sorted(p.name for p in instr.iterdir()) == ["Exercise Guide with Answers.docx", "Instructor Answer Key.docx"] + assert len(list((learner / "Cases").glob("Case [0-9]*.xlsx"))) == 12 == len(list((learner / "Cases").glob("Case [0-9]*.docx"))) + assert len(list((learner / "Data" / "Transactions by month").glob("*.xlsx"))) == 12 + assert {p.name for p in (learner / "Policies").iterdir()} == {"AML Policy Manual (Canada).docx", "Alert Handling Procedure (Canada).docx", "Guide to Writing the Report Narrative (Canada).docx"} + o = env.summary["outputs"] + assert o["mode"] == "copilot" and o["package"]["checks_passed"] and o["package"]["exercises"] == 13 and Path(o["learner_zip"]).exists() + + +def test_the_learner_zip_has_no_instructor_file_and_the_instructor_zip_has_the_answers(built): + home, _ = built + lz = zipfile.ZipFile(home / "out" / "cohort1.zip").namelist() + iz = zipfile.ZipFile(home / "out" / "cohort1-instructor.zip").namelist() + assert lz and all(n.startswith("cohort1/") for n in lz) and not any(re.search(r"instructor|answer", n, re.I) for n in lz) + assert sorted(iz) == ["cohort1-instructor/Exercise Guide with Answers.docx", "cohort1-instructor/Instructor Answer Key.docx"] + + +def test_every_workbook_holds_real_excel_tables_with_clean_headers(built): + home, _ = built + books = list((home / "out" / "cohort1").rglob("*.xlsx")) + assert len(books) == 12 + 1 + 1 + 12 + 4 # cases, case list, all transactions, months, customers/accounts/alerts/screening + for b in books: + wb = load_workbook(b) + for ws in wb.worksheets: + assert ws.tables, (b.name, ws.title) + heads = [c.value for c in ws[1]] + assert all(heads) and len(set(heads)) == len(heads) + assert not ws.merged_cells.ranges + + +def test_case_workbooks_have_the_four_tabs_and_no_answer(built, tables): + home, _ = built + wb = load_workbook(next((home / "out" / "cohort1" / "Cases").glob("Case 01 - *.xlsx"))) + assert wb.sheetnames == ["Summary", "KYC", "Transactions", "Alerts"] + flat = " ".join(str(c.value) for ws in wb.worksheets for r in ws.iter_rows() for c in r if c.value) + assert not any(g["reason"] in flat for g in tables["ground_truth"]) and "escalate" not in flat.lower() + tx = wb["Transactions"] + assert [c.value for c in tx[1]][:3] == ["Date and time", "ID", "From account"] and tx.max_row > 2 + + +def test_learner_files_are_free_of_technical_terms_and_answers(built): + home, _ = built + for p in (home / "out" / "cohort1").rglob("*.docx"): + t = text_of(p) + assert not P.JARGON.search(t), (p.name, P.JARGON.search(t).group(0)) + if p.parent.name != "Regulations": + assert "—" not in t and "Expected answer" not in t, p.name + assert P.verify_package(home / "out" / "cohort1", home / "out" / "cohort1-instructor", []) == [] + + +def test_exercise_guide_has_thirteen_exercises_and_one_catch_per_section(built): + home, _ = built + learner = text_of(home / "out" / "cohort1" / "Exercise Guide.docx") + key = text_of(home / "out" / "cohort1-instructor" / "Exercise Guide with Answers.docx") + assert len(re.findall(r"^[A-D]\d\. ", learner, re.M)) == 13 and "Expected answer" not in learner and key.count("Expected answer") == 13 + assert len(re.findall(r"Catch the AI's mistake", learner)) == 4 + for sentence in re.split(r"(?<=[.!?])\s+", learner): + assert len(sentence.split()) <= 40, sentence[:80] + for needle in ("AutoSave", "OneDrive or SharePoint", "five files"): + assert needle in learner + + +def test_answer_key_matches_the_ground_truth(built): + home, _ = built + from labagent.packs.aml.schema import read_csv + t = read_csv(home / "out" / ".work" / "tables") + key = text_of(home / "out" / "cohort1-instructor" / "Instructor Answer Key.docx") + cases = select_cases(t, 12) + for c in cases: + assert f"{c['number']:02d} | {c['alert']['id']} | {c['truth']['label']}" in key.replace("\n", " | ") or c["truth"]["reason"] in key + assert c["truth"]["reason"] in key + + +def test_regulation_excerpts_and_fintrac_guidance_are_written_with_their_notices(built): + home, env = built + names = [p.name for p in (home / "out" / "cohort1" / "Regulations").iterdir()] + assert any("Act (excerpt)" in n for n in names) and any(n.startswith("FINTRAC") for n in names) + act = next(p for p in (home / "out" / "cohort1" / "Regulations").iterdir() if "Act (excerpt)" in p.name) + assert "not the official version" in text_of(act) + f = text_of(next(p for p in (home / "out" / "cohort1" / "Regulations").iterdir() if p.name.startswith("FINTRAC"))) + assert "Source: https://fintrac-canafe.canada.ca/" in f and "Indicator 7" in f and "menu" not in f + assert env.summary["outputs"]["package"]["fintrac_guidance"] + + +def test_guidance_failure_is_reported_and_does_not_stop_the_launch(home): + def down(u): + raise OSError("blocked") + env = run_pack(AmlLab(), "launch", "cohort1", {"mode": "copilot", "size": "tiny", "cases": 12, "out": str(home / "out")}, + services={"allow_tiny": True, "regs_http": lambda u: ECFR if "ECFR" in u else JUSTICE, "regs_cache": home / "regs", "guidance_http": down}) + assert env.status == "ok" and len(env.summary["outputs"]["package"]["guidance_notes"]) == 2 + assert not any(p.name.startswith("FINTRAC") for p in (home / "out" / "cohort1" / "Regulations").iterdir()) + + +def test_guidance_and_regulations_can_be_skipped(home): + env = run(home, guidance="skip", regulations="skip") + assert env.status == "ok" + assert [p.name for p in (home / "out" / "cohort1" / "Regulations").iterdir()] == ["Read me.docx"] + + +def test_us_cohort_gets_us_rules_and_no_fintrac_guidance(home): + env = run(home, jurisdiction="us") + assert env.status == "ok" + names = [p.name for p in (home / "out" / "cohort1" / "Regulations").iterdir()] + assert names == ["US rules 31 CFR Chapter X (excerpt).docx"] and (home / "out" / "cohort1" / "Policies" / "AML Policy Manual (United States).docx").exists() + + +def test_launch_resumes_and_destroy_removes_only_its_own_files(home): + run(home) + assert run(home).summary["skipped"] == 3 + (home / "out" / "cohort1" / "My notes.docx").write_text("mine") + env = run(home, action="destroy") + assert env.status == "ok" + left = [p.relative_to(home / "out").as_posix() for p in (home / "out").rglob("*") if p.is_file()] + assert left == ["cohort1/My notes.docx"] + assert not (home / "home" / "packs" / "aml" / "cohort1.json").exists() + + +def test_cohort_name_sets_the_folder_and_is_made_safe(home): + env = run(home, cohort="Spring 2026/../x") + assert env.status == "ok" and (home / "out" / "Spring 2026..x").is_dir() + + +# ---- exercises ----------------------------------------------------------------------------------------------------------------- + +def test_expected_answers_are_computed_from_the_data(tables): + cases = select_cases(tables, 20) + ex = {e["id"]: e for e in build_exercises(tables, cases, "ca", "c")} + assert len(ex) == 13 and {e["section"] for e in ex.values()} == set(SECTIONS) + et = [float(x["amount"]) for x in tables["transactions"] if x["channel"] == "e-transfer" and x["currency"] == "CAD"] + assert f"{len(et):,}" in ex["D4"]["expected"][0] and f"{sum(et) / len(et):,.2f}" in ex["D4"]["expected"][1] + assert f"{len(et) + 1000:,}" in ex["D4"]["draft"] and f"{sum(et) / len(et):,.2f}" not in ex["D4"]["draft"] + sanc = next(c for c in cases if c["truth"]["pattern"] == "sanctions_match" and c["truth"]["label"] == "escalate") + assert f"Case {sanc['number']:02d}" in ex["C3"]["expected"][0] + top_name = ex["D2"]["expected"][0].split(". ", 1)[1].split(":")[0] + assert top_name in {c["name"] for c in tables["customers"]} + + +def test_planted_mistakes_really_differ_from_the_files(tables): + cases = select_cases(tables, 20) + ca = {e["id"]: e for e in build_exercises(tables, cases, "ca", "c")} + us = {e["id"]: e for e in build_exercises(tables, cases, "us", "c")} + assert "more than $10,000" in ca["B2"]["draft"] and "$10,000 or more" in ca["B2"]["expected"][0] # Canada says 10,000 or more + assert "$10,000 or more" in us["B2"]["draft"] and "more than $10,000" in us["B2"]["expected"][0] # the US says more than 10,000 + assert "three years" in ca["A3"]["draft"] and "every month" in ca["A3"]["draft"] + + +def test_case_selection_covers_every_kind_and_numbers_follow_alert_order(tables): + cases = select_cases(tables, 20) + assert [c["number"] for c in cases] == list(range(1, 21)) and [c["alert"]["id"] for c in cases] == sorted(c["alert"]["id"] for c in cases) + kinds = {c["truth"]["pattern"] for c in cases} + assert {"fan_in", "cycle", "stack", "disguised_payroll", "sanctions_match", "possible_cycle", "none"} <= kinds + assert select_cases(tables, 20) == cases + + +def test_guidance_parser_keeps_the_main_text_only(): + text = G.html_to_text(PAGE) + assert text.startswith("Indicators") and "menu" not in text and text.count("Indicator ") >= 30 + out, problems = G.fetch_guidance(lambda u: b"

short

") + assert out == {} and len(problems) == 2 diff --git a/tests/test_aml_nl.py b/tests/test_aml_nl.py new file mode 100644 index 0000000..82ea421 --- /dev/null +++ b/tests/test_aml_nl.py @@ -0,0 +1,37 @@ +"""Plain-English requests for the AML lab. The parameters come from exact rules on the sentence; the model only picks the pack.""" +import json + +from labagent import nl +from labagent.packs import registry + +PACKS = registry.all_packs() + + +def ask(text, pack="aml", action="launch"): + return nl.translate(text, PACKS, registry.resolve, chat=lambda *a, **k: json.dumps({"action": action, "pack": pack})) + + +def test_copilot_lab_for_canada(): + plan = ask("set up the AML copilot lab for Canada") + assert plan.command_line() == "labagent launch aml --name demo --param mode=copilot --param jurisdiction=ca" + + +def test_rag_lab_for_the_us_with_a_name(): + plan = ask("deploy the AML lab for developers in the United States called team7") + assert plan.name == "team7" and plan.params == {"mode": "rag", "jurisdiction": "us"} + + +def test_both_countries_and_a_bigger_dataset(): + assert ask("create the aml copilot lab for Canada and the US, medium size").params == {"mode": "copilot", "jurisdiction": "both", "size": "medium"} + assert ask("start aml for both countries").params == {"jurisdiction": "both"} + + +def test_an_explicit_value_beats_the_guess_and_nothing_is_invented(): + assert ask("launch aml-rag for Canada with jurisdiction us").params["jurisdiction"] == "us" + assert ask("launch aml-rag").params == {} # no country named: the pack default (ca) applies at launch + assert "us" not in ask("set us up with the aml lab").params.values() # the pronoun is not the United States + + +def test_destroy_still_needs_a_delete_word_and_names_the_pack(): + plan = ask("tear down the aml copilot lab called demo", action="destroy") + assert plan.action == "destroy" diff --git a/tests/test_aml_rag.py b/tests/test_aml_rag.py index 310545c..e64e765 100644 --- a/tests/test_aml_rag.py +++ b/tests/test_aml_rag.py @@ -102,7 +102,7 @@ def down(url): def test_bad_options_fail_early_with_a_plain_message(home): for bad, msg in (({"mode": "x"}, "mode"), ({"jurisdiction": "fr"}, "jurisdiction"), ({"seed": "abc"}, "seed"), ({"generator": "magic"}, "generator"), - ({"mode": "copilot"}, "not available")): + ({"cases": 3}, "cases"), ({"guidance": "x"}, "guidance")): env = launch(home, out=str(home / "x"), **bad) assert env.status == "failed" and msg in env.error, (bad, env.error) env = run_pack(AmlLab(), "launch", "t", {"size": "tiny", "out": str(home / "x")}, services={**services(home), "allow_tiny": False})