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