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
+22 -14
View File
@@ -47,16 +47,23 @@ LLM。設計原則見 `research-plan.md`;本檔記載可重現的
實作本步,四種模型六次執行全部無法維持完整分割, 實作本步,四種模型六次執行全部無法維持完整分割,
已棄用(詳見 `decision-log.md` 2026-08-05;棄用的 已棄用(詳見 `decision-log.md` 2026-08-05;棄用的
定義檔止於 git 歷史,見 `git log -- prompts/`)。 定義檔止於 git 歷史,見 `git log -- prompts/`)。
- **產物**:分群明細 CSV(欄位 Group、Keyword,一列一個
成員,依兩欄排序)記錄機器算出的分割;定案碼表 JSON
`{"keywords": [...]}`)記錄實際交給模型的碼,即組名
加上先驗主題詞。兩者分開,因為前者是分群結果、後者
含研究者的介入。
- **可重現性**:同一輸入、同一釘定模型、同一參數逐次 - **可重現性**:同一輸入、同一釘定模型、同一參數逐次
重現。不同 CPU/BLAS 實作的浮點尾數差異可能使邊界 重現。不同 CPU/BLAS 實作的浮點尾數差異可能使邊界
詞的歸屬翻動,屬已揭露的限制;論文所用詞彙表逐字 詞的歸屬翻動,屬已揭露的限制;論文所用表逐字
commit,引用單位為該份定案檔案。 commit,引用單位為該份定案檔案。
### women-power 的注入 ### women-power 的注入
定案詞彙表為 50 個分群組名再加上 `women-power` 一詞,共 定案詞彙表為 50 個分群組名再加上 `women-power` 一詞,共
51 個碼。`women-power` 是研究者任意決定的先驗主題(即本 51 個碼。`women-power` 是研究者任意決定的先驗主題(即本
論文的主題本身),不由資料產生,屬揭露的儀器介入。 論文的主題本身),不由資料產生,屬揭露的儀器介入。該詞
以常數寫在分群子命令內、隨定案碼表一併輸出,不經人手
編輯;分群明細 CSV 不含它,兩份產物因此各自誠實。
注入而非另設篩選軌的理由:讓研究者的主題詞與模型自己 注入而非另設篩選軌的理由:讓研究者的主題詞與模型自己
收斂出的類別(分群已自行長出 `female-empowerment` 等組) 收斂出的類別(分群已自行長出 `female-empowerment` 等組)
@@ -97,23 +104,24 @@ LLM。設計原則見 `research-plan.md`;本檔記載可重現的
每一步的輸出如何變成下一步的輸入,皆為確定性程序,規則 每一步的輸出如何變成下一步的輸入,皆為確定性程序,規則
明定如下: 明定如下:
- **歌詞輸入檔(步驟 1、3 共用**`export-llm-input` - **歌詞輸入檔(步驟 1**`export-llm-input`工作
工作儲存產出,每筆 `{"id": "song-<ID>", "content": 儲存產出,每筆 `{"id": "song-<ID>", "content": <歌詞>}`
<歌詞>}`依歌曲 ID 升序。兩個讀歌詞的步驟共用同一 依歌曲 ID 升序。步驟 3 的輸入由同一子命令、同一工作
檔,SHA-256 記入各步 meta。 儲存產出(見下),兩步的語料同一性由此成立;各步
輸入檔的 SHA-256 記入該步 meta。
- **步驟 1 → 2-1**`pool-keywords` 讀兩份執行歸檔的 - **步驟 1 → 2-1**`pool-keywords` 讀兩份執行歸檔的
`output.jsonl`(一律以換行字元 `\n` 切行——歌詞含 `output.jsonl`(一律以換行字元 `\n` 切行——歌詞含
U+0085 等控制字元時,`str.splitlines()` 類的通用切行 U+0085 等控制字元時,`str.splitlines()` 類的通用切行
會截斷 JSON 字串,實測踩中),輸出關鍵字純文字檔與 會截斷 JSON 字串,實測踩中),輸出關鍵字純文字檔與
出處 CSV。 出處 CSV。
- **步驟 2-1 → 2-2**`cluster-keywords` 讀關鍵字純文字檔, - **步驟 2-1 → 2-2**`cluster-keywords` 讀關鍵字純文字
輸出具名分組(組名 → 成員詞,組名與成員皆字典序) 檔,輸出分群明細 CSV 與定案碼表 JSON。
與記錄嵌入模型、revision、演算法參數、輸入 SHA-256 的 - **步驟 2-2 → 3 輸入檔**`export-llm-input --extras
meta。 <定案碼表>` 自工作儲存產出步驟 3 的輸入,每筆
- **步驟 2-2 → 3 定義檔**:50 個組名與 `women-power` 合併 `{"id": "song-<ID>", "content": <字串>}``content` 為
後以字典序逐行填入 `prompts/03-01-code.md` 的詞彙表節 固定鍵序序列化的 `{"lyrics": …, "keywords": [...]}`
(逐字),檔案隨 git commit 後方可執行——步驟 3 的 依歌曲 ID 升序。碼表以參數傳入而非填進定義檔——定義
定義檔因此自我完備,論文附錄可直接引用 檔只規定任務形狀,換詞彙表、換演算法都不必改它
- **步驟 3-1 兩次執行 → 3-2**:逐首比對標籤集合(鍵 - **步驟 3-1 兩次執行 → 3-2**:逐首比對標籤集合(鍵
集合,引述不參與比對);僅有分歧的歌入仲裁輸入 集合,引述不參與比對);僅有分歧的歌入仲裁輸入
JSONL,依歌曲 ID 升序,每筆 `id` 沿用 `song-<ID>`、 JSONL,依歌曲 ID 升序,每筆 `id` 沿用 `song-<ID>`、
+10 -10
View File
@@ -1,21 +1,21 @@
You are coding song lyrics against a fixed vocabulary of You are coding song lyrics against a fixed set of thematic
thematic keywords. keywords.
Vocabulary — the complete set of codes; use these and no Input: a JSON object with the complete lyrics of one song and
the keywords — the complete set of codes; use these and no
others: others:
(TO BE INSERTED VERBATIM WHEN THE VOCABULARY IS FINALIZED — {
this file must not be used in a run before then.) "lyrics": "the complete lyrics of one song",
"keywords": ["first-keyword", "second-keyword"]
}
Input: the complete lyrics of one song. Task: list every given keyword whose theme this song
Task: list every vocabulary keyword whose theme this song
expresses, by your own reading of the lyrics. expresses, by your own reading of the lyrics.
Rules: Rules:
- Use only keywords from the vocabulary, spelled exactly as - Use only the given keywords, spelled exactly as given.
given.
- A song may match any number of keywords, including none. - A song may match any number of keywords, including none.
- For each keyword you assign, quote 1 to 3 verbatim lines of - For each keyword you assign, quote 1 to 3 verbatim lines of
the lyrics that ground it; do not assign a keyword you the lyrics that ground it; do not assign a keyword you
+55
View File
@@ -0,0 +1,55 @@
{
"keywords": [
"abandonment-and-solitude",
"alcohol-and-substance-abuse",
"attraction-and-admiration",
"betrayal-and-mistrust",
"boastful-self-confidence",
"breakup-and-reconciliation",
"celebration-and-partying",
"contentment-and-joy",
"dancing-and-movement",
"defiance-and-confrontation",
"denial-and-pretense",
"designer-fashion-flexing",
"devotion-and-sacrifice",
"embracing-true-identity",
"fame-and-status",
"family-and-fatherhood",
"fantasy-and-imagination",
"fear-of-vulnerability",
"female-empowerment",
"flirtation-and-seduction",
"freedom-and-escape",
"heartbreak-and-grief",
"hidden-emotional-struggle",
"hope-and-disillusionment",
"human-connection",
"hustle-and-self-made-success",
"independence-and-self-reliance",
"intimacy-and-connection",
"jealousy-and-resentment-from-others",
"longing-and-desire",
"longing-for-clarity",
"love-as-fleeting-and-transient",
"loyalty-and-commitment",
"mutual-individuality",
"nostalgia-and-memory",
"obsession-and-madness",
"partner-inadequacy",
"partying-and-nightlife",
"personal-growth-and-change",
"reggaeton-culture",
"regret-and-guilt",
"relentless-ambition",
"resilience-through-hardship",
"rivalry-and-superiority",
"romantic-pursuit",
"secrecy-and-paranoia",
"self-worth-and-insecurity",
"small-town-roots-and-identity",
"street-loyalty-and-danger",
"wealth-and-luxury",
"women-power"
]
}
-50
View File
@@ -1,50 +0,0 @@
abandonment-and-solitude
alcohol-and-substance-abuse
attraction-and-admiration
betrayal-and-mistrust
boastful-self-confidence
breakup-and-reconciliation
celebration-and-partying
contentment-and-joy
dancing-and-movement
defiance-and-confrontation
denial-and-pretense
designer-fashion-flexing
devotion-and-sacrifice
embracing-true-identity
fame-and-status
family-and-fatherhood
fantasy-and-imagination
fear-of-vulnerability
female-empowerment
flirtation-and-seduction
freedom-and-escape
heartbreak-and-grief
hidden-emotional-struggle
hope-and-disillusionment
human-connection
hustle-and-self-made-success
independence-and-self-reliance
intimacy-and-connection
jealousy-and-resentment-from-others
longing-and-desire
longing-for-clarity
love-as-fleeting-and-transient
loyalty-and-commitment
mutual-individuality
nostalgia-and-memory
obsession-and-madness
partner-inadequacy
partying-and-nightlife
personal-growth-and-change
reggaeton-culture
regret-and-guilt
relentless-ambition
resilience-through-hardship
rivalry-and-superiority
romantic-pursuit
secrecy-and-paranoia
self-worth-and-insecurity
small-town-roots-and-identity
street-loyalty-and-danger
wealth-and-luxury
@@ -4,16 +4,19 @@
# imacat@mail.imacat.idv.tw (imacat), 2026/8/5 # imacat@mail.imacat.idv.tw (imacat), 2026/8/5
"""The deterministic clusterer of the pooled keywords. """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 the first positional command-line argument, by sentence-embedding
every keyword and clustering the embeddings: the group membership, every keyword and clustering the embeddings: the group membership,
given as the second positional argument, and the group name given as the second positional argument, is written as a CSV file
vocabulary, given as the third positional argument, are written as holding the clustering result alone. The coding keyword set,
plain files. The step is fully deterministic; no LLM call is given as the third positional argument, is written as a JSON file
made. 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 argparse
import csv import csv
import json
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -29,6 +32,9 @@ CLUSTER_EXTRA_MESSAGE: str = (
" pip install -e \"tools/[cluster]\"") " pip install -e \"tools/[cluster]\"")
"""The error message shown when the heavy clustering dependencies """The error message shown when the heavy clustering dependencies
are not installed.""" 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: 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. :return: The parsed arguments.
""" """
parser: argparse.ArgumentParser = argparse.ArgumentParser( parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Build the coding vocabulary by clustering" description="Build the coding groups by clustering the"
" the sentence embeddings of the pooled" " sentence embeddings of the pooled"
" keywords.") " keywords.")
parser.add_argument( parser.add_argument(
"keywords_txt", type=Path, "keywords_txt", type=Path,
@@ -49,8 +55,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
"groups_csv", type=Path, "groups_csv", type=Path,
help="the group membership CSV output file") help="the group membership CSV output file")
parser.add_argument( parser.add_argument(
"vocabulary_txt", type=Path, "keywords_json", type=Path,
help="the group name vocabulary text output file") help="the group name keyword JSON output file")
parser.add_argument( parser.add_argument(
"--model", default=MODEL, "--model", default=MODEL,
help=f"the sentence embedding 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 Writes a CSV file with the header row ``Group,Keyword``, one
row per member keyword. Rows are sorted by group name 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 :param path: The path of the group membership CSV file to
write. write.
@@ -213,30 +221,35 @@ def write_groups(path: Path, groups: dict[str, list[str]]) -> None:
writer.writerow([group, keyword]) writer.writerow([group, keyword])
def write_vocabulary(path: Path, def write_keywords(path: Path,
groups: dict[str, list[str]]) -> None: groups: dict[str, list[str]]) -> None:
"""Write the group name vocabulary text file. """Write the coding keyword set JSON file.
Writes a plain text file, one group name per line, Writes a JSON file holding a single object with one
lexicographically sorted, UTF-8, LF line endings, with a ``keywords`` key, whose value is the lexicographically
trailing newline. 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 :param groups: The keyword members of every group, keyed by
the group's medoid name. the group's medoid name.
:return: None. :return: None.
:raises OSError: When the file cannot be written. :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( 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") encoding="utf-8")
def main(argv: list[str] | None = None) -> int: 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 Writes the group membership CSV file, holding the clustering
vocabulary text file. 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 :param argv: The command-line arguments, or None for
``sys.argv``. ``sys.argv``.
@@ -259,9 +272,9 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
args.groups_csv.parent.mkdir(parents=True, exist_ok=True) 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_groups(args.groups_csv, groups)
write_vocabulary(args.vocabulary_txt, groups) write_keywords(args.keywords_json, groups)
elapsed: str = format_duration(time.monotonic() - started) elapsed: str = format_duration(time.monotonic() - started)
print( print(
f"done: {len(keywords)} keywords clustered into" 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 project's lyrics-only firewall: the output carries only the
lyrics text of each song, identified by an opaque song key; no lyrics text of each song, identified by an opaque song key; no
title, artist, or chart data crosses into the LLM input. 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 argparse
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -36,13 +43,77 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"output_jsonl", type=Path, "output_jsonl", type=Path,
help="the JSONL output file") 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) 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. """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 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. :return: The JSON lines, one per song, ordered by song ID.
:raises ValueError: When a song has no lyrics. :raises ValueError: When a song has no lyrics.
""" """
@@ -52,8 +123,15 @@ def build_lines(session: Session) -> list[str]:
if song.lyrics is None: if song.lyrics is None:
raise ValueError( raise ValueError(
f"song {song.id} \"{song.title}\": no lyrics") 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] = { 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)) lines.append(json.dumps(record, ensure_ascii=False))
return lines return lines
@@ -69,7 +147,10 @@ def main(argv: list[str] | None = None) -> int:
session: Session = ds.get_db() session: Session = ds.get_db()
lines: list[str] lines: list[str]
try: 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: except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
+32 -16
View File
@@ -5,6 +5,7 @@
"""Unit tests for the keyword clusterer module.""" """Unit tests for the keyword clusterer module."""
import csv import csv
import io import io
import json
import tempfile import tempfile
import unittest import unittest
from contextlib import redirect_stderr from contextlib import redirect_stderr
@@ -32,7 +33,7 @@ class TestClusterKeywords(unittest.TestCase):
self.__dir: Path = Path(tmp.name) self.__dir: Path = Path(tmp.name)
self.__keywords_txt: Path = self.__dir / "keywords.txt" self.__keywords_txt: Path = self.__dir / "keywords.txt"
self.__groups_csv: Path = self.__dir / "groups.csv" 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 @staticmethod
def __two_cluster_vectors() -> Vectors: def __two_cluster_vectors() -> Vectors:
@@ -102,7 +103,7 @@ class TestClusterKeywords(unittest.TestCase):
""" """
argv: list[str] = [ argv: list[str] = [
str(self.__keywords_txt), str(self.__groups_csv), str(self.__keywords_txt), str(self.__groups_csv),
str(self.__vocabulary_txt)] str(self.__keywords_json)]
argv.extend(extra_args or []) argv.extend(extra_args or [])
fake: Any = self.__fake_encode( fake: Any = self.__fake_encode(
vectors if vectors is not None vectors if vectors is not None
@@ -125,16 +126,14 @@ class TestClusterKeywords(unittest.TestCase):
newline="") as file: newline="") as file:
return list(csv.reader(file)) return list(csv.reader(file))
def __read_vocabulary(self) -> list[str]: def __read_keywords(self) -> list[str]:
"""Read the vocabulary text file. """Read the group name keyword JSON file.
:return: The group names, one per line, with the trailing :return: The group names under the "keywords" key.
empty line from the final newline removed.
""" """
lines: list[str] = self.__vocabulary_txt.read_text( data: dict[str, list[str]] = json.loads(
encoding="utf-8").split("\n") self.__keywords_json.read_text(encoding="utf-8"))
self.assertEqual(lines[-1], "") return data["keywords"]
return lines[:-1]
def test_groups_csv_header_and_ordering(self) -> None: def test_groups_csv_header_and_ordering(self) -> None:
"""Test the header row and the group/keyword ordering of """Test the header row and the group/keyword ordering of
@@ -170,17 +169,34 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual( self.assertEqual(
sorted(x[1] for x in rows), sorted(keywords)) sorted(x[1] for x in rows), sorted(keywords))
def test_vocabulary_file_sorted_medoids(self) -> None: def test_keywords_json_sorted_medoids(self) -> None:
"""Test that the vocabulary file holds the sorted medoid """Test that the keyword JSON file holds the sorted medoid
group names.""" group names plus the extra a-priori keyword."""
self.__write_keywords([ self.__write_keywords([
"a-left", "a-center", "a-right", "a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"]) "b-north", "b-middle", "b-south"])
status: int status: int
status, _ = self.__run_cluster() status, _ = self.__run_cluster()
self.assertEqual(status, 0) self.assertEqual(status, 0)
keywords: list[str] = self.__read_keywords()
self.assertEqual( 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: def test_duplicate_keyword_rejected(self) -> None:
"""Test that a duplicate keyword line fails the run """Test that a duplicate keyword line fails the run
@@ -192,7 +208,7 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual(status, 1) self.assertEqual(status, 1)
self.assertIn("duplicate keyword", stderr) self.assertIn("duplicate keyword", stderr)
self.assertFalse(self.__groups_csv.exists()) 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: def test_empty_input_rejected(self) -> None:
"""Test that an empty keyword file fails the run without """Test that an empty keyword file fails the run without
@@ -204,4 +220,4 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual(status, 1) self.assertEqual(status, 1)
self.assertIn("no keywords", stderr) self.assertIn("no keywords", stderr)
self.assertFalse(self.__groups_csv.exists()) 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: finally:
session.close() 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. """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 :return: A tuple of the exit status and the standard
error. error.
""" """
argv: list[str] = [str(self.__output)]
if extras is not None:
argv += ["--extras", str(extras)]
stderr: io.StringIO = io.StringIO() stderr: io.StringIO = io.StringIO()
with redirect_stderr(stderr): with redirect_stderr(stderr):
status: int = export_llm_input.main( status: int = export_llm_input.main(argv)
[str(self.__output)])
return status, stderr.getvalue() 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 @staticmethod
def __read_records(path: Path) -> list[dict[str, str]]: def __read_records(path: Path) -> list[dict[str, str]]:
"""Read the JSONL records of a file. """Read the JSONL records of a file.
@@ -167,3 +181,50 @@ class TestExportLlmInput(unittest.TestCase):
nested) nested)
self.assertEqual(records, [ self.assertEqual(records, [
{"id": "song-1", "content": "hello lyrics\n"}]) {"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())