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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user