Pass the coding keywords as input instead of baking them into the prompt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:29 +08:00
co-authored by Claude Opus 5
parent ce4f9ad481
commit 04d5095e44
8 changed files with 303 additions and 119 deletions
@@ -4,16 +4,19 @@
# imacat@mail.imacat.idv.tw (imacat), 2026/8/5
"""The deterministic clusterer of the pooled keywords.
Builds the coding vocabulary from the pooled keyword list, given as
Builds the coding groups from the pooled keyword list, given as
the first positional command-line argument, by sentence-embedding
every keyword and clustering the embeddings: the group membership,
given as the second positional argument, and the group name
vocabulary, given as the third positional argument, are written as
plain files. The step is fully deterministic; no LLM call is
made.
given as the second positional argument, is written as a CSV file
holding the clustering result alone. The coding keyword set,
given as the third positional argument, is written as a JSON file
holding the group name keywords plus the researcher's a-priori
topic term (see :data:`EXTRA_KEYWORD`). The step is fully
deterministic; no LLM call is made.
"""
import argparse
import csv
import json
import sys
import time
from pathlib import Path
@@ -29,6 +32,9 @@ CLUSTER_EXTRA_MESSAGE: str = (
" pip install -e \"tools/[cluster]\"")
"""The error message shown when the heavy clustering dependencies
are not installed."""
EXTRA_KEYWORD: str = "women-power"
"""The researcher's a-priori topic term, included in the coding
keyword set although it is not a clustering result."""
def parse_args(argv: list[str] | None) -> argparse.Namespace:
@@ -39,8 +45,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
:return: The parsed arguments.
"""
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Build the coding vocabulary by clustering"
" the sentence embeddings of the pooled"
description="Build the coding groups by clustering the"
" sentence embeddings of the pooled"
" keywords.")
parser.add_argument(
"keywords_txt", type=Path,
@@ -49,8 +55,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
"groups_csv", type=Path,
help="the group membership CSV output file")
parser.add_argument(
"vocabulary_txt", type=Path,
help="the group name vocabulary text output file")
"keywords_json", type=Path,
help="the group name keyword JSON output file")
parser.add_argument(
"--model", default=MODEL,
help=f"the sentence embedding model (default \"{MODEL}\")")
@@ -194,7 +200,9 @@ def write_groups(path: Path, groups: dict[str, list[str]]) -> None:
Writes a CSV file with the header row ``Group,Keyword``, one
row per member keyword. Rows are sorted by group name
lexicographically, then by keyword lexicographically.
lexicographically, then by keyword lexicographically. The
file records the clustering result alone; it holds no row for
:data:`EXTRA_KEYWORD`.
:param path: The path of the group membership CSV file to
write.
@@ -213,30 +221,35 @@ def write_groups(path: Path, groups: dict[str, list[str]]) -> None:
writer.writerow([group, keyword])
def write_vocabulary(path: Path,
groups: dict[str, list[str]]) -> None:
"""Write the group name vocabulary text file.
def write_keywords(path: Path,
groups: dict[str, list[str]]) -> None:
"""Write the coding keyword set JSON file.
Writes a plain text file, one group name per line,
lexicographically sorted, UTF-8, LF line endings, with a
trailing newline.
Writes a JSON file holding a single object with one
``keywords`` key, whose value is the lexicographically
sorted list of the group names plus :data:`EXTRA_KEYWORD`,
UTF-8, with a trailing newline.
:param path: The path of the vocabulary text file to write.
:param path: The path of the keyword JSON file to write.
:param groups: The keyword members of every group, keyed by
the group's medoid name.
:return: None.
:raises OSError: When the file cannot be written.
"""
keywords: list[str] = sorted(
[*groups.keys(), EXTRA_KEYWORD])
data: dict[str, list[str]] = {"keywords": keywords}
path.write_text(
"".join(f"{x}\n" for x in sorted(groups.keys())),
json.dumps(data, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8")
def main(argv: list[str] | None = None) -> int:
"""Cluster the pooled keywords into the coding vocabulary.
"""Cluster the pooled keywords into the coding groups.
Writes the group membership CSV file and the group name
vocabulary text file.
Writes the group membership CSV file, holding the clustering
result alone, and the coding keyword set JSON file, holding
the group names plus :data:`EXTRA_KEYWORD`.
:param argv: The command-line arguments, or None for
``sys.argv``.
@@ -259,9 +272,9 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {error}", file=sys.stderr)
return 1
args.groups_csv.parent.mkdir(parents=True, exist_ok=True)
args.vocabulary_txt.parent.mkdir(parents=True, exist_ok=True)
args.keywords_json.parent.mkdir(parents=True, exist_ok=True)
write_groups(args.groups_csv, groups)
write_vocabulary(args.vocabulary_txt, groups)
write_keywords(args.keywords_json, groups)
elapsed: str = format_duration(time.monotonic() - started)
print(
f"done: {len(keywords)} keywords clustered into"
@@ -10,11 +10,18 @@ command-line argument. This is the enforcement point of the
project's lyrics-only firewall: the output carries only the
lyrics text of each song, identified by an opaque song key; no
title, artist, or chart data crosses into the LLM input.
With ``--extras``, each record's ``content`` becomes a JSON
object serialized as a string, its ``lyrics`` key holding the
song's lyrics followed by the keys of the given extras file in
their file order, so a step that needs parameters alongside the
lyrics can carry them without this module knowing what they mean.
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import sqlalchemy as sa
from sqlalchemy.orm import Session
@@ -36,13 +43,77 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser.add_argument(
"output_jsonl", type=Path,
help="the JSONL output file")
parser.add_argument(
"--extras", type=Path, default=None,
help="a JSON file holding a single JSON object of extra"
" parameters; when given, each record's \"content\""
" becomes a JSON object string with a \"lyrics\" key"
" followed by the extras' keys, instead of the bare"
" lyrics string")
return parser.parse_args(argv)
def build_lines(session: Session) -> list[str]:
def _no_duplicate_keys(
pairs: list[tuple[str, Any]]) -> dict[str, Any]:
"""Build a dict from JSON object pairs, rejecting duplicates.
:param pairs: The key-value pairs of a JSON object, in file
order.
:return: The pairs as a dict, in file order.
:raises ValueError: When a key appears more than once.
"""
result: dict[str, Any] = {}
key: str
value: Any
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate key \"{key}\" in extras")
result[key] = value
return result
def load_extras(path: Path) -> dict[str, Any]:
"""Load the extras object from a JSON file.
:param path: The extras JSON file.
:return: The extras, in file order.
:raises OSError: When the file cannot be read.
:raises ValueError: When the file is not valid JSON, is not
a JSON object, has duplicate keys, or has a "lyrics" key.
"""
with open(path, encoding="utf-8") as file:
text: str = file.read()
try:
data: Any = json.loads(
text, object_pairs_hook=_no_duplicate_keys)
except json.JSONDecodeError as error:
raise ValueError(
f"invalid JSON in extras file {path}: {error}") \
from error
if not isinstance(data, dict):
raise ValueError(
f"extras file {path} must contain a JSON object")
if "lyrics" in data:
raise ValueError(
f"extras file {path} must not have a \"lyrics\" key")
return data
def build_lines(
session: Session,
extras: dict[str, Any] | None = None) -> list[str]:
"""Build the JSONL lines of every song's lyrics.
Without extras, each record's ``content`` is the bare lyrics
string. With extras, ``content`` is a JSON object serialized
as a string, whose first key is ``"lyrics"`` holding the
lyrics string, followed by the extras' keys in their given
order.
:param session: The database session.
:param extras: The extra parameters merged into every
record's content alongside the lyrics, in the order they
are to appear, or None for the bare lyrics string.
:return: The JSON lines, one per song, ordered by song ID.
:raises ValueError: When a song has no lyrics.
"""
@@ -52,8 +123,15 @@ def build_lines(session: Session) -> list[str]:
if song.lyrics is None:
raise ValueError(
f"song {song.id} \"{song.title}\": no lyrics")
content: str
if extras is None:
content = song.lyrics
else:
payload: dict[str, Any] = {"lyrics": song.lyrics}
payload.update(extras)
content = json.dumps(payload, ensure_ascii=False)
record: dict[str, str] = {
"id": f"song-{song.id}", "content": song.lyrics}
"id": f"song-{song.id}", "content": content}
lines.append(json.dumps(record, ensure_ascii=False))
return lines
@@ -69,7 +147,10 @@ def main(argv: list[str] | None = None) -> int:
session: Session = ds.get_db()
lines: list[str]
try:
lines = build_lines(session)
extras: dict[str, Any] | None = None
if args.extras is not None:
extras = load_extras(args.extras)
lines = build_lines(session, extras)
except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
+32 -16
View File
@@ -5,6 +5,7 @@
"""Unit tests for the keyword clusterer module."""
import csv
import io
import json
import tempfile
import unittest
from contextlib import redirect_stderr
@@ -32,7 +33,7 @@ class TestClusterKeywords(unittest.TestCase):
self.__dir: Path = Path(tmp.name)
self.__keywords_txt: Path = self.__dir / "keywords.txt"
self.__groups_csv: Path = self.__dir / "groups.csv"
self.__vocabulary_txt: Path = self.__dir / "vocabulary.txt"
self.__keywords_json: Path = self.__dir / "keywords.json"
@staticmethod
def __two_cluster_vectors() -> Vectors:
@@ -102,7 +103,7 @@ class TestClusterKeywords(unittest.TestCase):
"""
argv: list[str] = [
str(self.__keywords_txt), str(self.__groups_csv),
str(self.__vocabulary_txt)]
str(self.__keywords_json)]
argv.extend(extra_args or [])
fake: Any = self.__fake_encode(
vectors if vectors is not None
@@ -125,16 +126,14 @@ class TestClusterKeywords(unittest.TestCase):
newline="") as file:
return list(csv.reader(file))
def __read_vocabulary(self) -> list[str]:
"""Read the vocabulary text file.
def __read_keywords(self) -> list[str]:
"""Read the group name keyword JSON file.
:return: The group names, one per line, with the trailing
empty line from the final newline removed.
:return: The group names under the "keywords" key.
"""
lines: list[str] = self.__vocabulary_txt.read_text(
encoding="utf-8").split("\n")
self.assertEqual(lines[-1], "")
return lines[:-1]
data: dict[str, list[str]] = json.loads(
self.__keywords_json.read_text(encoding="utf-8"))
return data["keywords"]
def test_groups_csv_header_and_ordering(self) -> None:
"""Test the header row and the group/keyword ordering of
@@ -170,17 +169,34 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual(
sorted(x[1] for x in rows), sorted(keywords))
def test_vocabulary_file_sorted_medoids(self) -> None:
"""Test that the vocabulary file holds the sorted medoid
group names."""
def test_keywords_json_sorted_medoids(self) -> None:
"""Test that the keyword JSON file holds the sorted medoid
group names plus the extra a-priori keyword."""
self.__write_keywords([
"a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"])
status: int
status, _ = self.__run_cluster()
self.assertEqual(status, 0)
keywords: list[str] = self.__read_keywords()
self.assertEqual(
self.__read_vocabulary(), ["a-center", "b-middle"])
keywords,
["a-center", "b-middle", cluster_keywords.EXTRA_KEYWORD])
self.assertEqual(keywords, sorted(keywords))
self.assertEqual(len(keywords), 2 + 1)
def test_extra_keyword_absent_from_groups_csv(self) -> None:
"""Test that the extra a-priori keyword appears in no row
of the group membership CSV file."""
self.__write_keywords([
"a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"])
status: int
status, _ = self.__run_cluster()
self.assertEqual(status, 0)
rows: list[list[str]] = self.__read_groups()
for row in rows:
self.assertNotIn(cluster_keywords.EXTRA_KEYWORD, row)
def test_duplicate_keyword_rejected(self) -> None:
"""Test that a duplicate keyword line fails the run
@@ -192,7 +208,7 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual(status, 1)
self.assertIn("duplicate keyword", stderr)
self.assertFalse(self.__groups_csv.exists())
self.assertFalse(self.__vocabulary_txt.exists())
self.assertFalse(self.__keywords_json.exists())
def test_empty_input_rejected(self) -> None:
"""Test that an empty keyword file fails the run without
@@ -204,4 +220,4 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual(status, 1)
self.assertIn("no keywords", stderr)
self.assertFalse(self.__groups_csv.exists())
self.assertFalse(self.__vocabulary_txt.exists())
self.assertFalse(self.__keywords_json.exists())
+64 -3
View File
@@ -75,18 +75,32 @@ class TestExportLlmInput(unittest.TestCase):
finally:
session.close()
def __run_export(self) -> tuple[int, str]:
def __run_export(
self, extras: Path | None = None) -> tuple[int, str]:
"""Run the exporter with the standard error captured.
:param extras: The extras JSON file, or None for none.
:return: A tuple of the exit status and the standard
error.
"""
argv: list[str] = [str(self.__output)]
if extras is not None:
argv += ["--extras", str(extras)]
stderr: io.StringIO = io.StringIO()
with redirect_stderr(stderr):
status: int = export_llm_input.main(
[str(self.__output)])
status: int = export_llm_input.main(argv)
return status, stderr.getvalue()
def __write_extras(self, text: str) -> Path:
"""Write an extras file with the given raw text.
:param text: The raw file content.
:return: The path of the written extras file.
"""
path: Path = self.__dir / "extras.json"
path.write_text(text, encoding="utf-8")
return path
@staticmethod
def __read_records(path: Path) -> list[dict[str, str]]:
"""Read the JSONL records of a file.
@@ -167,3 +181,50 @@ class TestExportLlmInput(unittest.TestCase):
nested)
self.assertEqual(records, [
{"id": "song-1", "content": "hello lyrics\n"}])
def test_extras_merges_lyrics_first_in_file_order(
self) -> None:
"""Test that with extras, content is a JSON object whose
first key is lyrics, followed by the extras' keys in
their file order."""
self.__seed([("Hello", "Adele", "hello lyrics\n")])
extras: Path = self.__write_extras(
'{"b": 2, "a": 1}')
status: int
stderr: str
status, stderr = self.__run_export(extras)
self.assertEqual(status, 0)
records: list[dict[str, str]] = self.__read_records(
self.__output)
self.assertEqual(len(records), 1)
content: dict[str, Any] = json.loads(
records[0]["content"])
self.assertEqual(
list(content.keys()), ["lyrics", "b", "a"])
self.assertEqual(content["lyrics"], "hello lyrics\n")
self.assertEqual(content["b"], 2)
self.assertEqual(content["a"], 1)
def test_extras_non_object_fails(self) -> None:
"""Test that a non-object extras file is rejected."""
self.__seed([("Hello", "Adele", "hello lyrics\n")])
extras: Path = self.__write_extras('[1, 2]')
status: int
stderr: str
status, stderr = self.__run_export(extras)
self.assertEqual(status, 1)
self.assertIn("error:", stderr)
self.assertFalse(self.__output.exists())
def test_extras_with_lyrics_key_fails(self) -> None:
"""Test that an extras file carrying a "lyrics" key is
rejected."""
self.__seed([("Hello", "Adele", "hello lyrics\n")])
extras: Path = self.__write_extras(
'{"lyrics": "not allowed"}')
status: int
stderr: str
status, stderr = self.__run_export(extras)
self.assertEqual(status, 1)
self.assertIn("error:", stderr)
self.assertFalse(self.__output.exists())