{ "cells": [ { "cell_type": "markdown", "metadata": { "id": "science-v2-000" }, "source": "# CS145 NanoQuery\n\n**Starter release: October 19, 2026.**\n\n[NanoQuery: requirements and grading](https://cs145.web.app/projects/project2-science.html)\n\nConnect tables to documents and answer analytical questions. At higher tiers, build a tested SQL library and verified answers, then test whether giving an LLM that library improves generated SQL. No model training or fine-tuning is required.\n\n**Using the project page and notebook:** We’ve aimed to keep the project page and notebook consistent. The project page gives the full requirements and grading; the notebook provides compact guidance, function contracts, and starter code.\n\nThe supplied helpers support setup and retrieval; you write your dataset-specific queries, mappings, and tests through T87. For T93–T100, extend your work with verified reference answers and the LLM comparison. You may need to adapt the helpers to your implementation.\n\nYou’re welcome to adapt the starter code within the project requirements. If something seems inconsistent or buggy, check the project page and ask privately on Ed; include the error and a small reproducible example for bugs. Note important assumptions in your notebook so we can take them into account when reviewing your work.\n\nYour proposed tier is a planning target, not a score cap. We award partial credit within each rubric category; 80 core points plus 4 library points earns 84.\n\n**Bonus:** Staff select the top five Science projects for overall quality and compelling work; each receives 0–5 bonus points.\n\nRun Setup and Configuration first; the supplied helpers need no edits. Then jump to [Phase 0](#scrollTo=phase-0) or [Phase 1](#scrollTo=phase-1).\n", "id": "science-v2-000" }, { "cell_type": "markdown", "metadata": { "heading_collapsed": true, "id": "science-v2-001" }, "source": "## Setup\n\nFirst, finalize the Project 1 tables you will use for Project 2 (at least three tables totaling 500 MB) and select 100–200 related Wikipedia articles or supply your own relevant domain documents. You may choose a different public dataset.\n\nRun setup once, fill in Configuration, then run Connect to BigQuery and verify all three access checks. Keep source tables in BigQuery; no table download is needed. In Phase 0, prepare the articles, map them to table records, and complete your proposal.\n\nSpark needs Java 17 or 21. The setup cell checks Java and installs Java 17 in Colab if needed.\n", "id": "science-v2-001" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-002" }, "source": "# @title Install dependencies\n%pip -q install pyspark==4.0.1 sentence-transformers google-cloud-bigquery pandas pyarrow db-dtypes sqlglot==30.22.0 requests\nimport os, json, time, re, random, platform\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport importlib.metadata as metadata\nprint(\"Python:\", platform.python_version())\nprint({p: metadata.version(p) for p in [\"pyspark\", \"sentence-transformers\", \"transformers\", \"torch\", \"google-cloud-bigquery\", \"sqlglot\"]})\nWORK = Path(\"p2_work\")\nWORK.mkdir(exist_ok=True)\n\n# Spark 4 requires Java 17 or 21. Configure Java before starting the JVM.\nimport shutil, subprocess, sys\n\ndef ensure_spark_java():\n java_home = os.environ.get(\"JAVA_HOME\")\n java = str(Path(java_home) / \"bin\" / \"java\") if java_home else shutil.which(\"java\")\n major = None\n if java:\n try:\n result = subprocess.run([java, \"-version\"], capture_output=True, text=True, check=True)\n match = re.search(r'version \"(\\d+)', result.stderr + result.stdout)\n major = int(match.group(1)) if match else None\n except (OSError, subprocess.CalledProcessError):\n pass\n if major not in (17, 21):\n try:\n import google.colab\n except ImportError:\n raise RuntimeError(\"Spark needs Java 17 or 21. Install one and set JAVA_HOME, then rerun Setup.\") from None\n subprocess.run([\"apt-get\", \"update\", \"-qq\"], check=True)\n subprocess.run([\"apt-get\", \"install\", \"-y\", \"-qq\", \"openjdk-17-jdk-headless\"], check=True)\n homes = sorted(Path(\"/usr/lib/jvm\").glob(\"java-17-openjdk-*\"))\n if not homes:\n raise RuntimeError(\"Java 17 installation was not found under /usr/lib/jvm.\")\n java = str(homes[0] / \"bin\" / \"java\")\n os.environ[\"JAVA_HOME\"] = str(Path(java).resolve().parent.parent)\n subprocess.run([str(Path(os.environ[\"JAVA_HOME\"]) / \"bin\" / \"java\"), \"-version\"], check=True)\n\nensure_spark_java()\nos.environ[\"PYSPARK_PYTHON\"] = sys.executable\nfrom pyspark.sql import SparkSession\n_setup_spark = (SparkSession.builder.master(\"local[2]\").appName(\"cs145-p2\")\n .config(\"spark.driver.memory\", \"2g\").getOrCreate())\ntry:\n assert _setup_spark.range(1).count() == 1\n print(\"PASS: Java and Spark setup\")\nfinally:\n _setup_spark.stop()\n", "outputs": [], "execution_count": null, "id": "science-v2-002" }, { "cell_type": "markdown", "metadata": { "id": "science-v2-003" }, "source": "### Try Spark on a small example\n\nSpark selects relevant documents and removes duplicates. Run these three cells, then change `keyword` and rerun the last cell. This practice data is separate from your project collection; your project tables stay in BigQuery.\n", "id": "science-v2-003" }, { "cell_type": "code", "metadata": { "id": "science-v2-004" }, "source": "# 1. Create a tiny practice collection.\nfrom pyspark.sql import SparkSession, functions as F\npractice_spark = (SparkSession.builder.master(\"local[2]\").appName(\"cs145-p2\")\n .config(\"spark.driver.memory\", \"2g\").getOrCreate())\npractice_docs = practice_spark.createDataFrame([\n (\"1\", \"Urban rail\", \"Trains carry passengers between city stations.\"),\n (\"2\", \"Airport travel\", \"Trains and buses connect the airport to the city.\"),\n (\"3\", \"Bicycle routes\", \"Protected lanes help people cycle across the city.\"),\n (\"2\", \"Airport travel\", \"Trains and buses connect the airport to the city.\"),\n], [\"id\", \"title\", \"text\"])\npractice_docs.select(\"id\", \"title\").show(truncate=False)\n", "outputs": [], "execution_count": null, "id": "science-v2-004" }, { "cell_type": "code", "metadata": { "id": "science-v2-005" }, "source": "# 2. Remove the repeated document.\npractice_unique = practice_docs.dropDuplicates([\"id\"])\npractice_unique.orderBy(\"id\").show(truncate=False)\n", "outputs": [], "execution_count": null, "id": "science-v2-005" }, { "cell_type": "code", "metadata": { "id": "science-v2-006" }, "source": "# 3. Change \"trains\" to \"cycle\" and rerun.\nkeyword = \"trains\"\npractice_unique.filter(F.lower(F.col(\"text\")).contains(keyword.lower())).orderBy(\"id\").show(truncate=False)\n", "outputs": [], "execution_count": null, "id": "science-v2-006" }, { "cell_type": "markdown", "metadata": { "id": "science-v2-007" }, "source": "### Optional: save checkpoints in Drive\n\nIn Colab, uncomment and run the cell below before collecting documents. It saves files in your Drive across runtime resets. On reconnect, rerun Setup and this cell to reuse them. Saving the notebook alone does not save local files.\n", "id": "science-v2-007" }, { "cell_type": "code", "metadata": { "id": "science-v2-008" }, "outputs": [], "execution_count": null, "source": "# from google.colab import drive\n# drive.mount('/content/drive')\n# WORK = Path('/content/drive/MyDrive/cs145_nanoquery/p2_work')\n# WORK.mkdir(parents=True, exist_ok=True)\n", "id": "science-v2-008" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-009" }, "source": "# @title SQL and retrieval helpers\nimport sqlglot\nfrom sqlglot import exp\n# Restrict queries to SELECT-family statements. Pair with read-only IAM in production.\ndef check_select(sql):\n statements = sqlglot.parse(sql, read=\"bigquery\")\n if len(statements) != 1 or not isinstance(statements[0], (exp.Select, exp.Union, exp.Intersect, exp.Except)):\n raise ValueError(\"Expected one SELECT query\")\n return sql\n\ndef run_select(sql, parameters=None):\n assert RUN_BIGQUERY, \"Enable BigQuery after setup\"\n check_select(sql)\n preview = preview_query(sql, parameters)\n if not preview[\"within_limit\"]:\n raise ValueError(\"Estimated scan exceeds MAX_BYTES_BILLED. Narrow the scan or deliberately change the limit; it is never raised automatically.\")\n config = bigquery.QueryJobConfig(query_parameters=parameters or [],\n maximum_bytes_billed=MAX_BYTES_BILLED,\n use_query_cache=False)\n start = time.perf_counter()\n job = bq.query(sql, job_config=config)\n rows = [dict(row) for row in job.result()]\n return {\"rows\": rows, \"seconds\": time.perf_counter()-start,\n \"bytes_processed\": job.total_bytes_processed}\n\ndef preview_query(sql, parameters=None):\n assert RUN_BIGQUERY, \"Enable BigQuery after setup\"\n check_select(sql)\n config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False,\n query_parameters=parameters or [])\n job = bq.query(sql, job_config=config)\n estimated = job.total_bytes_processed\n if estimated is None:\n raise ValueError(\"BigQuery did not provide a scan estimate; inspect the query before executing it\")\n report = {\"estimated_bytes\": estimated, \"limit_bytes\": MAX_BYTES_BILLED,\n \"within_limit\": estimated <= MAX_BYTES_BILLED}\n print(report)\n return report\n\ndef search_passages(question, k=5):\n assert 1 <= k <= 50\n vector = embedder.encode([question], normalize_embeddings=True)[0]\n sql = f\"\"\"SELECT base.document_id, base.passage_id, base.title, base.url, base.text, distance\n FROM VECTOR_SEARCH(TABLE `{PROJECT_ID}.{DATASET_ID}.wiki_passages`, 'embedding',\n (SELECT @vector AS embedding), top_k => {k}, distance_type => 'COSINE',\n options => '{{\"use_brute_force\":true}}')\n ORDER BY distance\"\"\"\n return run_select(sql, [bigquery.ArrayQueryParameter(\"vector\", \"FLOAT64\", vector.tolist())])\n\n# Run this cell as provided. No student work here for Phase 0.\n# Use search_passages() later, after preparing embeddings and the passage table.\n", "outputs": [], "execution_count": null, "id": "science-v2-009" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-010" }, "source": "# @title Compare query results\nfrom collections import Counter\nfrom decimal import Decimal\nfrom numbers import Number\nfrom datetime import date, datetime\n\ndef canonical_cell(value):\n if value is None:\n return (\"null\",)\n if isinstance(value, (float, np.floating)) and np.isnan(value):\n return (\"null\",)\n if isinstance(value, (bool, np.bool_)):\n return (\"bool\", bool(value))\n if isinstance(value, (Number, Decimal)):\n return (\"number\", Decimal(str(value)).normalize())\n if isinstance(value, (date, datetime)):\n return (\"date\", value.isoformat())\n return (\"text\", str(value))\n\ndef results_equal(actual, expected, columns, ordered=False):\n # Pass run_select(...)[\"rows\"] and an explicit output-column order.\n # Round approximate numeric results to your documented precision first.\n from collections.abc import Mapping\n if not columns or len(set(columns)) != len(columns):\n raise ValueError(\"Supply unique output-column names in comparison order\")\n def normalize(rows):\n normalized = []\n for row in rows:\n if not isinstance(row, Mapping) or set(row) != set(columns):\n raise ValueError(\"Each row must be a dictionary with the expected columns\")\n normalized.append(tuple(canonical_cell(row[c]) for c in columns))\n return normalized\n a, e = normalize(actual), normalize(expected)\n return a == e if ordered else Counter(a) == Counter(e)\n\n# Example: results_equal(actual[\"rows\"], expected[\"rows\"], columns=[\"zone\", \"trips\"])\n", "outputs": [], "execution_count": null, "id": "science-v2-010" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-011" }, "source": "# @title Read pasted SQL\ndef strip_model_fence(raw):\n text = raw.strip()\n match = re.fullmatch(r\"```(?:json|sql)?\\s*\\n?(.*?)\\n?```\", text, re.DOTALL | re.IGNORECASE)\n return match.group(1).strip() if match else text\n\n", "outputs": [], "execution_count": null, "id": "science-v2-011" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-012" }, "source": "# @title Build prompts and save responses\ndef build_ab_messages(question, schema_notes, library, *, candidate_ids=(),\n rules=\"\", output_columns=()):\n \"\"\"Build fixed prompts; keep reference SQL and answer values out of the inputs.\"\"\"\n if not library:\n raise ValueError(\"Populate your SQL library before building prompts\")\n common = {\"question\": question, \"schema\": schema_notes,\n \"candidate_ids\": list(candidate_ids), \"output_columns\": list(output_columns)}\n governed = {\"domain_rules\": rules, \"definitions\": library}\n instruction = (\n \"Write one BigQuery SELECT query for the question using only the supplied tables. \"\n \"Return only SQL with the requested output-column names. \"\n \"Use @candidate_ids when filtering by supplied entity IDs; the notebook binds them. \"\n \"Use concrete values from the question for other filters. \"\n \"When a tested SQL library and domain rules are provided, follow those definitions and adapt or combine \"\n \"the SQL examples as needed.\")\n return {\n \"A\": [{\"role\": \"system\", \"content\": instruction},\n {\"role\": \"user\", \"content\": json.dumps(common, ensure_ascii=False)}],\n \"B\": [{\"role\": \"system\", \"content\": instruction},\n {\"role\": \"user\", \"content\": json.dumps(\n {**common, \"governed_context\": governed}, ensure_ascii=False)}]}\n\n\ndef prompt_for_chat(messages):\n return (chr(10) * 2).join(m[\"content\"] for m in messages)\n\ndef save_response(question_id, variant, response):\n if variant not in (\"A\", \"B\") or not response.strip():\n raise ValueError(\"Provide an A/B label and the original response\")\n record = PASTED_RESPONSES.setdefault(question_id, {})\n if variant in record:\n raise ValueError(\"First response already saved; record reruns separately\")\n record[variant] = response\n RESPONSE_FILE.write_text(json.dumps(PASTED_RESPONSES, indent=2))\n\n", "outputs": [], "execution_count": null, "id": "science-v2-012" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-013" }, "source": "# @title BigQuery connection helper\ndef connect_bigquery():\n global bq, bigquery\n assert re.fullmatch(r\"[a-z][a-z0-9-]{4,61}[a-z0-9]\", PROJECT_ID)\n assert re.fullmatch(r\"[A-Za-z_][A-Za-z0-9_]*\", DATASET_ID)\n try:\n import google.colab\n except ImportError:\n pass # Use local Application Default Credentials.\n else:\n from google.colab import auth\n auth.authenticate_user()\n from google.cloud import bigquery\n bq = bigquery.Client(project=PROJECT_ID, location=LOCATION)\n dataset = bigquery.Dataset(f\"{PROJECT_ID}.{DATASET_ID}\")\n dataset.location = LOCATION\n bq.create_dataset(dataset, exists_ok=True)\n", "outputs": [], "execution_count": null, "id": "science-v2-013" }, { "cell_type": "markdown", "metadata": { "id": "science-v2-014" }, "source": "## Configuration\n", "id": "science-v2-014" }, { "cell_type": "markdown", "metadata": { "id": "science-v2-015" }, "source": "Use your own **Google Cloud project ID** from the console's project selector for `PROJECT_ID`, not the public dataset's project. `DATASET_ID` names the output dataset this notebook creates in your project. Set `LOCATION` to your source dataset's location, and `SOURCE_TABLE` to an existing `project.dataset.table` ID you can read. Your account needs permission to run query jobs and create the output dataset in your project.\n\nSet `RUN_BIGQUERY = True` to connect. Set `RUN_COLLECTION = True` after filling in `TITLES` and `WIKIPEDIA_CONTACT` in Phase 0. Sign in with the account that has access to your project. BigQuery sandbox is sufficient within its quotas; no paid LLM API is required.\n\n`MAX_BYTES_BILLED` defaults to 100 MB **per query**, not per dataset. `preview_query(sql)` prints the estimated scan and configured limit; `run_select` checks it before executing. Narrow an over-limit scan or deliberately change the limit within your available quota. The helper never raises it automatically. `LIMIT` controls returned rows and generally does not reduce the scan.\n\nThe connection cell checks query access, reads one source row, and loads and reads a one-row temporary setup table. It removes that setup table afterward. All three checks must pass before continuing.\n", "id": "science-v2-015" }, { "cell_type": "code", "metadata": { "id": "science-v2-016" }, "source": "RUN_COLLECTION = False\nRUN_BIGQUERY = False\nPROJECT_ID = \"\" # Your project; do not enter billing credentials here.\nDATASET_ID = \"cs145_p2\"\nLOCATION = \"US\"\nSOURCE_TABLE = \"\" # One existing source table: project.dataset.table\n# This 100 MB limit applies to each query scan, not total dataset size (500 MB+).\n# Call preview_query(sql) to see the estimated bytes before running a query.\n# If needed, reduce the scan or deliberately raise this cap within sandbox limits.\nMAX_BYTES_BILLED = 100_000_000\n", "outputs": [], "execution_count": null, "id": "science-v2-016" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-017" }, "source": "# @title Connect to BigQuery (also used for the proposal)\nif RUN_BIGQUERY:\n connect_bigquery()\n assert re.fullmatch(r\"[A-Za-z0-9_-]+\\.[A-Za-z0-9_]+\\.[A-Za-z0-9_-]+\", SOURCE_TABLE), \"Fill SOURCE_TABLE with project.dataset.table\"\n assert run_select(\"SELECT 1 AS connected\")[\"rows\"] == [{\"connected\": 1}]\n print(\"PASS: BigQuery query access\")\n source = bq.get_table(SOURCE_TABLE)\n if source.location.lower() != LOCATION.lower():\n raise ValueError(f\"Source is in {source.location}; set LOCATION to match before continuing\")\n sample_rows = list(bq.list_rows(source, max_results=1))\n print(\"PASS: source table access\", SOURCE_TABLE, \"sample rows:\", len(sample_rows))\n from uuid import uuid4\n setup_table = f\"{PROJECT_ID}.{DATASET_ID}._setup_check_{uuid4().hex}\"\n try:\n config = bigquery.LoadJobConfig(schema=[bigquery.SchemaField(\"connected\", \"INTEGER\")])\n bq.load_table_from_json([{\"connected\": 1}], setup_table, job_config=config).result()\n assert run_select(f\"SELECT connected FROM `{setup_table}`\")[\"rows\"] == [{\"connected\": 1}]\n print(\"PASS: output dataset load and read\")\n finally:\n bq.delete_table(setup_table, not_found_ok=True)\n print(\"Setup complete. Continue to Phase 0.\")\nelse:\n print(\"Fill Configuration and set RUN_BIGQUERY = True, then rerun this cell.\")\n", "outputs": [], "execution_count": null, "id": "science-v2-017" }, { "cell_type": "markdown", "metadata": { "id": "phase-0" }, "source": "## Phase 0: Prepare documents\n\n1. **Choose your tables:** Use your Project 1 dataset. You can also choose a different public dataset if you’d like; the same Project 1 data requirements apply.\n2. **Add documents:** Select 100–200 related Wikipedia articles or supply custom domain documents, prepare them with Spark, and map them to your table records. Inspect a random sample of 30 documents for relevance (all if fewer than 30).\n3. **Query and submit:** Run one keyword join combining documents and tables, create one chart, and complete the [proposal checklist](https://cs145.web.app/projects/project2-science.html#proposal).\n\nEmbeddings and LLM runs are not required for the proposal. For custom documents, supply an extract with id, url (or a stable source path), title, and text; use the same Spark preparation, mapping, and evidence checks.\n\n\n", "id": "phase-0" }, { "cell_type": "code", "metadata": { "id": "science-v2-019" }, "source": "DOCUMENT_SOURCE = \"wikipedia\" # Use \"custom\" for your own document extract.\nTITLES = [] # Fill with 100–200 Wikipedia article titles; fewer for development.\nMY_KEYWORDS = [] # Domain keywords used in your Spark filter.\nWIKIPEDIA_CONTACT = \"\" # A contact URL or email for the public API User-Agent.\nEXTRACT_PATH = WORK / \"wiki_extract.parquet\"\n", "outputs": [], "execution_count": null, "id": "science-v2-019" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-020" }, "source": "# @title Download selected Wikipedia articles\nif RUN_COLLECTION:\n import requests\n from datetime import datetime, timezone\n assert WIKIPEDIA_CONTACT and 1 <= len(TITLES) <= 200\n session = requests.Session()\n session.headers[\"User-Agent\"] = f\"CS145CourseProject/1.0 ({WIKIPEDIA_CONTACT})\"\n rows, missing, duplicates = [], [], []\n seen_titles, seen_ids = set(), set()\n for raw_title in TITLES:\n if not isinstance(raw_title, str) or not raw_title.strip():\n raise ValueError(\"Use nonempty article titles in TITLES\")\n title = raw_title.strip()\n if title in seen_titles:\n duplicates.append({\"requested\": title, \"reason\": \"repeated title\"})\n continue\n seen_titles.add(title)\n response = session.get(\"https://en.wikipedia.org/w/api.php\", params={\n \"action\": \"query\", \"format\": \"json\", \"formatversion\": 2,\n \"prop\": \"extracts|info\", \"explaintext\": 1, \"inprop\": \"url\",\n \"redirects\": 1, \"titles\": title}, timeout=60)\n response.raise_for_status()\n payload = response.json()\n if \"error\" in payload:\n raise RuntimeError(payload[\"error\"])\n pages = payload[\"query\"][\"pages\"]\n usable = [p for p in pages if not p.get(\"missing\") and p.get(\"extract\")]\n if not usable:\n missing.append(title)\n for p in usable:\n page_id = str(p[\"pageid\"])\n if page_id in seen_ids:\n duplicates.append({\"requested\": title, \"resolved\": p[\"title\"],\n \"reason\": \"same article ID\"})\n continue\n seen_ids.add(page_id)\n rows.append({\"id\": page_id, \"url\": p[\"fullurl\"],\n \"title\": p[\"title\"], \"text\": p[\"extract\"],\n \"collected_at\": datetime.now(timezone.utc).isoformat()})\n time.sleep(0.2)\n report = {\"requested\": len(TITLES), \"downloaded_unique\": len(rows),\n \"missing_or_empty\": missing, \"duplicates\": duplicates}\n (WORK / \"collection_report.json\").write_text(json.dumps(report, indent=2))\n print(json.dumps(report, indent=2))\n if not rows:\n raise ValueError(\"No articles downloaded. Check collection_report.json; existing extract unchanged.\")\n pd.DataFrame(rows).to_parquet(EXTRACT_PATH, index=False)\n print(\"Saved\", len(rows), \"unique articles to\", EXTRACT_PATH)\n if len(rows) < 100:\n print(\"Development collection: expand to 100–200 relevant articles for submission.\")\n", "outputs": [], "execution_count": null, "id": "science-v2-020" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-021" }, "source": "# @title Prepare documents with Spark\n# Run after supplying a bounded extract and choosing keywords.\nif EXTRACT_PATH.exists() and MY_KEYWORDS:\n assert DOCUMENT_SOURCE in (\"wikipedia\", \"custom\")\n from pyspark.sql import SparkSession, functions as F\n spark = SparkSession.builder.master(\"local[2]\").appName(\"cs145-p2\").config(\"spark.driver.memory\", \"2g\").getOrCreate()\n pattern = \"|\".join(re.escape(k.lower()) for k in MY_KEYWORDS)\n selected = (spark.read.parquet(str(EXTRACT_PATH))\n .filter(F.col(\"text\").isNotNull())\n .filter(F.lower(F.concat_ws(\" \", \"title\", \"text\")).rlike(pattern))\n .dropDuplicates([\"id\"]).orderBy(\"id\"))\n articles = selected.toPandas()\n assert len(articles), \"No matching articles; inspect your filter\"\n articles.to_parquet(WORK / \"articles.parquet\", index=False)\n display(articles[[\"title\", \"text\"]].head(3).assign(text=lambda df: df.text.str.slice(0, 250)))\n sample = articles.sample(n=min(30, len(articles)), random_state=145)\n relevance_path = WORK / \"article_relevance.csv\"\n review = sample.assign(relevant=\"\", explanation=\"\")\n if relevance_path.exists():\n previous = pd.read_csv(relevance_path, dtype={\"id\": str}, keep_default_na=False)\n # Keep a backup, including labels for documents outside the current sample.\n from uuid import uuid4\n relevance_path.with_name(f\"article_relevance_backup_{uuid4().hex}.csv\").write_bytes(relevance_path.read_bytes())\n previous = previous.drop_duplicates(\"id\", keep=\"last\").set_index(\"id\")\n for column in [\"relevant\", \"explanation\"]:\n review[column] = review[\"id\"].astype(str).map(previous[column]).fillna(\"\")\n review.to_csv(relevance_path, index=False)\n if DOCUMENT_SOURCE == \"wikipedia\" and not 100 <= len(articles) <= 200:\n print(\"Development collection: keep 100–200 relevant Wikipedia articles after filtering for submission.\")\n print(\"Review labels:\", relevance_path, \"(existing labels preserved by document ID)\")\n print(\"Selected articles:\", len(articles), \"UTF-8 text bytes:\", sum(len(t.encode()) for t in articles.text))\n spark.stop()\n # Release the local JVM as well as the Spark session before computing embeddings.\n from pyspark import SparkContext\n if SparkContext._gateway is not None:\n SparkContext._gateway.shutdown()\n SparkContext._gateway = None\n SparkContext._jvm = None\n print(\"Spark stopped. Restart the kernel before starting another Spark session.\")\nelse:\n print(\"Supply wiki_extract.parquet and MY_KEYWORDS, then run this cell.\")\n", "outputs": [], "execution_count": null, "id": "science-v2-021" }, { "cell_type": "markdown", "metadata": { "id": "science-v2-022" }, "source": "### Try your documents\n\nRun the supplied filter, then enter two keywords or phrases relevant to your project and rerun. Keep the outputs visible. Which documents appear or disappear? No extra paragraph is needed.\n\nThese are keyword matches in your prepared documents, ordered by document ID—not relevance rankings. This reads Spark’s saved output without changing it.\n", "id": "science-v2-022" }, { "cell_type": "code", "metadata": { "cellView": "form", "id": "science-v2-023" }, "source": "# @title Document filter helpers\nfrom html import escape\nfrom IPython.display import HTML, display\n\ndef show_document_matches(documents, phrase, limit=3):\n phrase = phrase.strip()\n if not phrase:\n return\n needle = phrase.lower()\n ordered = documents.assign(id=documents.id.astype(str)).sort_values(\"id\", kind=\"stable\")\n matches = ordered[\n ordered.title.fillna(\"\").str.lower().str.contains(needle, regex=False) |\n ordered.text.fillna(\"\").str.lower().str.contains(needle, regex=False)]\n print(f\"Filter: {phrase} — {len(matches)} matching documents. Showing up to {limit}.\")\n rows = []\n for row in matches.head(limit).itertuples():\n text = str(row.text)\n match = re.search(re.escape(phrase), text, flags=re.IGNORECASE)\n start = max(0, match.start() - 70) if match else 0\n end = max(start + 250, match.end()) if match else 250\n excerpt = (\"…\" if start else \"\") + text[start:end] + (\"…\" if len(text) > end else \"\")\n excerpt = re.sub(re.escape(phrase), lambda m: \"\" + m.group(0) + \"\", escape(excerpt), flags=re.IGNORECASE) if not any(ch in phrase for ch in \"<>&\") else escape(excerpt)\n rows.append(f\"
| ID | Document | Excerpt |
|---|