Build the arbitration input with a per-ID extras merge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:33 +08:00
co-authored by Claude Opus 5
parent 1714257c89
commit 3bae24b930
4 changed files with 291 additions and 42 deletions
@@ -14,8 +14,11 @@ agreed. The quotes supporting a keyword never take part in the
comparison; they are carried along as the evidence of the disagreed
keyword they support. The disagreements are written as a JSON file,
as :data:`DISAGREEMENTS_JSON`, holding only the songs the two runs
disagree on; it is what the arbitration step of the coding
procedure settles. The agreed half is not written; it is returned
disagree on, each song's disagreed keywords wrapped in a
"disagreements" object so that the file merges into the arbitration
input as the per-song extra parameters; it is what the arbitration
step of the coding procedure settles. The agreed half is not
written; it is returned
by :func:`compare_codings`, as the keyword names alone, for later
steps to consume. The step is fully deterministic; no LLM call is
made.
@@ -229,10 +232,13 @@ def write_disagreements(path: Path,
"""Write the per-song disagreement JSON file.
Writes a JSON file holding a single object mapping the song ID
to the disagreed keywords of that song, each with the quotes of
the run that assigned it, in the given order, UTF-8, with a
trailing newline. Only the songs the two runs disagree on are
written.
to a ``{"disagreements": {...}}`` object holding the disagreed
keywords of that song, each with the quotes of the run that
assigned it, in the given order, UTF-8, with a trailing newline.
The wrapper is what makes the file merge into the arbitration
input as the per-song extra parameters, giving a "disagreements"
sibling of the lyrics rather than loose keywords. Only the
songs the two runs disagree on are written.
:param path: The path of the disagreement JSON file to write.
:param disagreements: The disagreed keywords of every song,
@@ -240,9 +246,11 @@ def write_disagreements(path: Path,
:return: None.
:raises OSError: When the file cannot be written.
"""
wrapped: dict[str, dict[str, Coding]] = {
song_id: {"disagreements": coding}
for song_id, coding in disagreements.items()}
path.write_text(
json.dumps(disagreements, ensure_ascii=False, indent=2)
+ "\n",
json.dumps(wrapped, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
@@ -16,6 +16,14 @@ 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.
With ``--extras-per-id``, the same merge happens per song: the
given file maps a song ID to the extra keys of that one song, and
the export is restricted to the song IDs the file names, so a step
that revisits only some of the songs, each with its own parameters,
gets exactly those records. The two options may be given together,
in which case a record's keys are ``lyrics``, the shared extras'
keys, then that song's own keys, each group in its file order.
"""
import argparse
import json
@@ -50,6 +58,14 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
" becomes a JSON object string with a \"lyrics\" key"
" followed by the extras' keys, instead of the bare"
" lyrics string")
parser.add_argument(
"--extras-per-id", type=Path, default=None,
help="a JSON file holding a single JSON object that maps a"
" song ID, as \"song-<N>\", to a JSON object of extra"
" parameters for that one song; the song's object is"
" merged into its \"content\" the same way as with"
" --extras, and the export is restricted to the song"
" IDs the file names")
return parser.parse_args(argv)
@@ -72,6 +88,31 @@ def _no_duplicate_keys(
return result
def _load_json_object(path: Path, label: str) -> dict[str, Any]:
"""Load a single JSON object from a file, in file order.
:param path: The JSON file.
:param label: The kind of file, for the error messages.
:return: The object, 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, or has duplicate keys.
"""
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 {label} file {path}: {error}") \
from error
if not isinstance(data, dict):
raise ValueError(
f"{label} file {path} must contain a JSON object")
return data
def load_extras(path: Path) -> dict[str, Any]:
"""Load the extras object from a JSON file.
@@ -81,64 +122,111 @@ def load_extras(path: Path) -> dict[str, Any]:
: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")
data: dict[str, Any] = _load_json_object(path, "extras")
if "lyrics" in data:
raise ValueError(
f"extras file {path} must not have a \"lyrics\" key")
return data
def load_extras_per_id(path: Path) -> dict[str, dict[str, Any]]:
"""Load the per-ID extras object from a JSON file.
:param path: The per-ID extras JSON file, mapping a song ID,
as ``song-<N>``, to the extras of that one song.
:return: The extras of each song ID, in file order, every
song's own extras in their file order too.
: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, has a song whose value
is not a JSON object, or has a song with a "lyrics" key.
"""
data: dict[str, Any] = _load_json_object(path, "per-ID extras")
song_id: str
extras: Any
for song_id, extras in data.items():
if not isinstance(extras, dict):
raise ValueError(
f"per-ID extras file {path}: id {song_id} must"
" have a JSON object")
if "lyrics" in extras:
raise ValueError(
f"per-ID extras file {path}: id {song_id} 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.
extras: dict[str, Any] | None = None,
extras_per_id: dict[str, dict[str, Any]] | None = None) \
-> list[str]:
"""Build the JSONL lines of the exported songs' 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.
Without extras of either kind, 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 shared extras' keys
and then the song's own per-ID extras' keys, each group in its
given order.
Every song is exported, unless per-ID extras are given, in
which case only the songs they name are.
: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.
are to appear, or None for none.
:param extras_per_id: The extra parameters merged into the
content of one record alone, keyed by that record's song
ID and in the order they are to appear, restricting the
export to the song IDs they name, or None for no such
extras and no such restriction.
:return: The JSON lines, one per exported song, ordered by
song ID.
:raises ValueError: When an exported song has no lyrics, or
the per-ID extras name a song the working store does not
have.
"""
lines: list[str] = []
exported: set[str] = set()
song: Song
for song in session.scalars(sa.select(Song).order_by(Song.id)):
song_id: str = f"song-{song.id}"
if extras_per_id is not None and song_id not in extras_per_id:
continue
if song.lyrics is None:
raise ValueError(
f"song {song.id} \"{song.title}\": no lyrics")
content: str
if extras is None:
if extras is None and extras_per_id is None:
content = song.lyrics
else:
payload: dict[str, Any] = {"lyrics": song.lyrics}
payload.update(extras)
if extras is not None:
payload.update(extras)
if extras_per_id is not None:
payload.update(extras_per_id[song_id])
content = json.dumps(payload, ensure_ascii=False)
record: dict[str, str] = {
"id": f"song-{song.id}", "content": content}
"id": song_id, "content": content}
lines.append(json.dumps(record, ensure_ascii=False))
exported.add(song_id)
if extras_per_id is not None:
missing: list[str] = sorted(set(extras_per_id) - exported)
if len(missing) > 0:
raise ValueError(
"the per-ID extras name songs the working store"
f" does not have: {', '.join(missing)}")
return lines
def main(argv: list[str] | None = None) -> int:
"""Export the LLM input JSONL file from the working store.
Every song is exported, unless ``--extras-per-id`` is given,
in which case only the songs its file names are.
:param argv: The command-line arguments, or None for
``sys.argv``.
:return: The exit status: 0 on success, non-zero on failure.
@@ -150,7 +238,10 @@ def main(argv: list[str] | None = None) -> int:
extras: dict[str, Any] | None = None
if args.extras is not None:
extras = load_extras(args.extras)
lines = build_lines(session, extras)
extras_per_id: dict[str, dict[str, Any]] | None = None
if args.extras_per_id is not None:
extras_per_id = load_extras_per_id(args.extras_per_id)
lines = build_lines(session, extras, extras_per_id)
except (OSError, sa.exc.SQLAlchemyError, ValueError) as error:
print(f"error: {error}", file=sys.stderr)
return 1