Refactor internal data passing to dataclasses and enums, and remove unneeded defensive code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:13:31 +08:00
co-authored by Claude Fable 5
parent 8d223177cd
commit e0eb343ce0
11 changed files with 540 additions and 333 deletions
+1 -2
View File
@@ -81,8 +81,7 @@ def main(argv: list[str] | None = None) -> int:
return 2 return 2
prog_backup: str = sys.argv[0] prog_backup: str = sys.argv[0]
main_module: ModuleType = sys.modules["__main__"] main_module: ModuleType = sys.modules["__main__"]
spec_backup: ModuleSpec | None = getattr( spec_backup: ModuleSpec | None = main_module.__spec__
main_module, "__spec__", None)
sys.argv[0] = f"{prog()} {args[0]}" sys.argv[0] = f"{prog()} {args[0]}"
main_module.__spec__ = None main_module.__spec__ = None
try: try:
+55 -30
View File
@@ -27,8 +27,9 @@ import csv
import re import re
import sys import sys
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Self
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -37,6 +38,7 @@ from .database import Base, ds
from .models import ( from .models import (
Artist, Artist,
ChartEntry, ChartEntry,
Role,
Song, Song,
SongArtist, SongArtist,
) )
@@ -56,7 +58,7 @@ RANKS_PER_YEAR: int = 100
ARTIST_FIELDS: dict[str, str] = { ARTIST_FIELDS: dict[str, str] = {
"qid": "wikidata_qid", "qid": "wikidata_qid",
"gender": "gender", "gender": "gender",
"artist_type": "artist_type", "type": "type",
"genre": "genre", "genre": "genre",
"country": "country", "country": "country",
} }
@@ -86,7 +88,7 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
return parser.parse_args(argv) return parser.parse_args(argv)
def parse_artist_credit(credit: str) -> list[tuple[str, str]]: def parse_artist_credit(credit: str) -> list[tuple[str, Role]]:
"""Parse a combined artist credit into artists and roles. """Parse a combined artist credit into artists and roles.
The credit splits into a primary side and a featured side on The credit splits into a primary side and a featured side on
@@ -101,13 +103,14 @@ def parse_artist_credit(credit: str) -> list[tuple[str, str]]:
:param credit: The combined artist credit string. :param credit: The combined artist credit string.
:return: The (name, role) pairs in credit order, primary side :return: The (name, role) pairs in credit order, primary side
first, with the role "primary" or "featured". first, with the role ``Role.PRIMARY`` or
``Role.FEATURED``.
""" """
sides: list[str] = FEATURING_PATTERN.split(credit, maxsplit=1) sides: list[str] = FEATURING_PATTERN.split(credit, maxsplit=1)
pairs: list[tuple[str, str]] = [] pairs: list[tuple[str, Role]] = []
role: str role: Role
side: str side: str
for side, role in zip(sides, ("primary", "featured")): for side, role in zip(sides, (Role.PRIMARY, Role.FEATURED)):
token: str token: str
for token in DELIMITER_PATTERN.split(side): for token in DELIMITER_PATTERN.split(side):
name: str = token.strip() name: str = token.strip()
@@ -139,7 +142,7 @@ def create_song(session: Session, song_id: int, title: str,
seen: set[str] = set() seen: set[str] = set()
position: int = 0 position: int = 0
name: str name: str
role: str role: Role
for name, role in parse_artist_credit(credit): for name, role in parse_artist_credit(credit):
if name in seen: if name in seen:
print(f"warning: {credit}: duplicated artist" print(f"warning: {credit}: duplicated artist"
@@ -220,7 +223,7 @@ def apply_artist_csv(session: Session, path: Path) -> None:
:param session: The database session, with the artists :param session: The database session, with the artists
flushed. flushed.
:param path: The CSV file with the columns name, qid, gender, :param path: The CSV file with the columns name, qid, gender,
artist_type, genre, country, and note. type, genre, country, and note.
:return: None. :return: None.
:raises BuildError: When a name matches no artist. :raises BuildError: When a name matches no artist.
:raises OSError: When the file cannot be read. :raises OSError: When the file cannot be read.
@@ -275,7 +278,7 @@ def find_violations(session: Session, years: Iterable[int],
f"unexpected chart entry: year {year} rank {rank}") f"unexpected chart entry: year {year} rank {rank}")
primary_ids: set[int] = set(session.scalars( primary_ids: set[int] = set(session.scalars(
sa.select(SongArtist.song_id) sa.select(SongArtist.song_id)
.where(SongArtist.role == "primary"))) .where(SongArtist.role == Role.PRIMARY)))
song: Song song: Song
for song in session.scalars(sa.select(Song).order_by(Song.id)): for song in session.scalars(sa.select(Song).order_by(Song.id)):
if song.id not in primary_ids: if song.id not in primary_ids:
@@ -290,29 +293,48 @@ def find_violations(session: Session, years: Iterable[int],
return violations return violations
def count_rows(session: Session) -> dict[str, int]: @dataclass
"""Count the loaded rows for the build summary. class StoreCounts:
"""The row counts of the working store, for the build summary."""
songs: int
"""The number of the songs."""
chart_entries: int
"""The number of the chart entries."""
artists: int
"""The number of the artists."""
credits: int
"""The number of the song-artist credits."""
songs_with_lyrics: int
"""The number of the songs with lyrics."""
@classmethod
def get_instance(cls, session: Session) -> Self:
"""Counts the loaded rows and returns the counts.
:param session: The database session with the loaded data :param session: The database session with the loaded data
flushed. flushed.
:return: The counts of the songs, chart entries, artists, :return: The row counts of the working store.
credits, and songs with lyrics, under those keys.
""" """
return { def count(selectable: sa.Select[tuple[int]]) -> int:
"songs": session.scalar( value: int | None = session.scalar(selectable)
sa.select(sa.func.count()).select_from(Song)) or 0, assert value is not None
"chart entries": session.scalar( return value
return cls(
songs=count(
sa.select(sa.func.count()).select_from(Song)),
chart_entries=count(
sa.select(sa.func.count()) sa.select(sa.func.count())
.select_from(ChartEntry)) or 0, .select_from(ChartEntry)),
"artists": session.scalar( artists=count(
sa.select(sa.func.count()).select_from(Artist)) or 0, sa.select(sa.func.count()).select_from(Artist)),
"credits": session.scalar( credits=count(
sa.select(sa.func.count()) sa.select(sa.func.count())
.select_from(SongArtist)) or 0, .select_from(SongArtist)),
"songs with lyrics": session.scalar( songs_with_lyrics=count(
sa.select(sa.func.count()).select_from(Song) sa.select(sa.func.count()).select_from(Song)
.where(Song.lyrics.is_not(None))) or 0, .where(Song.lyrics.is_not(None))))
}
def prepare_engine(engine: sa.Engine) -> None: def prepare_engine(engine: sa.Engine) -> None:
@@ -355,7 +377,7 @@ def main(argv: list[str] | None = None) -> int:
prepare_engine(engine) prepare_engine(engine)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
session: Session = ds.get_db() session: Session = ds.get_db()
counts: dict[str, int] counts: StoreCounts
try: try:
reset_store(session) reset_store(session)
load_chart(session, CHART_CSV) load_chart(session, CHART_CSV)
@@ -371,7 +393,7 @@ def main(argv: list[str] | None = None) -> int:
for violation in violations: for violation in violations:
print(f"error: {violation}", file=sys.stderr) print(f"error: {violation}", file=sys.stderr)
return 1 return 1
counts = count_rows(session) counts = StoreCounts.get_instance(session)
session.commit() session.commit()
except (OSError, BuildError) as error: except (OSError, BuildError) as error:
session.rollback() session.rollback()
@@ -379,7 +401,10 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
finally: finally:
session.close() session.close()
print("done: " + ", ".join(f"{count} {name}" print(f"done: {counts.songs} songs,"
for name, count in counts.items()), f" {counts.chart_entries} chart entries,"
f" {counts.artists} artists,"
f" {counts.credits} credits,"
f" {counts.songs_with_lyrics} songs with lyrics",
file=sys.stderr) file=sys.stderr)
return 0 return 0
+123 -59
View File
@@ -20,14 +20,16 @@ current working directory.
""" """
import argparse import argparse
import csv import csv
import enum
import json import json
import sys import sys
import time import time
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -37,10 +39,6 @@ from .models import Artist
WIKIDATA_CSV: Path = Path("data/artists_wikidata.csv") WIKIDATA_CSV: Path = Path("data/artists_wikidata.csv")
"""The Wikidata artist snapshot CSV file.""" """The Wikidata artist snapshot CSV file."""
SNAPSHOT_FIELDS: Sequence[str] = (
"name", "qid", "gender", "artist_type", "genre", "country",
"note")
"""The header columns of the Wikidata artist snapshot CSV file."""
API_URL: str = "https://www.wikidata.org/w/api.php" API_URL: str = "https://www.wikidata.org/w/api.php"
"""The URL of the Wikidata API endpoint.""" """The URL of the Wikidata API endpoint."""
USER_AGENT: str = ("pop-fem-audit-tools" USER_AGENT: str = ("pop-fem-audit-tools"
@@ -55,6 +53,71 @@ HUMAN_QID: str = "Q5"
GROUP_KEYWORDS: Sequence[str] = ("band", "group", "duo", "trio") GROUP_KEYWORDS: Sequence[str] = ("band", "group", "duo", "trio")
"""The label keywords that suggest a musical ensemble, covering """The label keywords that suggest a musical ensemble, covering
labels like "boy band" and "girl group".""" labels like "boy band" and "girl group"."""
NOTE_NOT_FOUND: str = "not found"
"""The note sentinel of an artist without a Wikidata search hit,
written to the snapshot and read back for the classification."""
class ArtistType(enum.StrEnum):
"""The decided artist type of a snapshot row."""
SOLO = "solo"
"""A solo artist: a human."""
GROUP = "group"
"""A musical ensemble."""
MIXED = "mixed"
"""A mixed act, assigned manually via the overrides; never
derived by the fetcher."""
@dataclass
class ArtistSnapshot:
"""One row of the Wikidata artist snapshot CSV file."""
name: str
"""The artist name."""
qid: str = ""
"""The Wikidata item ID, or empty when unresolved."""
gender: str = ""
"""The gender label, or empty when unresolved."""
type: str = ""
"""The artist type, an ``ArtistType`` value, or empty for the
human to decide."""
genre: str = ""
"""The genre labels, joined with ``; ``."""
country: str = ""
"""The country label, or empty when unresolved."""
note: str = ""
"""The note for human verification: the description of the
search hit, ``not found``, or ``error: <reason>``."""
def to_row(self) -> dict[str, str]:
"""Return this snapshot as a CSV row.
:return: The row values, keyed by the column name.
"""
return asdict(self)
SNAPSHOT_FIELDS: Sequence[str] = tuple(
x.name for x in fields(ArtistSnapshot))
"""The header columns of the Wikidata artist snapshot CSV file."""
@dataclass
class ArtistClaims:
"""The item-ID claim targets of a Wikidata artist item."""
gender_ids: list[str] = field(default_factory=list)
"""The item IDs of the gender targets."""
instance_of_ids: list[str] = field(default_factory=list)
"""The item IDs of the instance-of targets."""
genre_ids: list[str] = field(default_factory=list)
"""The item IDs of the genre targets."""
country_ids: list[str] = field(default_factory=list)
"""The item IDs of the country-of-citizenship targets."""
origin_country_ids: list[str] = field(default_factory=list)
"""The item IDs of the country-of-origin targets."""
def parse_args(argv: list[str] | None) -> argparse.Namespace: def parse_args(argv: list[str] | None) -> argparse.Namespace:
@@ -78,30 +141,29 @@ class ArtistFetcher:
self.__sent: int = 0 self.__sent: int = 0
"""The number of the HTTP requests already sent.""" """The number of the HTTP requests already sent."""
def fetch(self, name: str) -> dict[str, str]: def fetch(self, name: str) -> ArtistSnapshot:
"""Fetch the metadata of an artist. """Fetch the metadata of an artist.
The note column carries the description of the search hit The note carries the description of the search hit for
for human verification. A search miss yields a row with human verification. A search miss yields a snapshot with
the note ``not found``. An HTTP, network, or decoding the note ``not found``. An HTTP, network, or decoding
error yields a row with what was resolved so far and the error yields a snapshot with what was resolved so far and
note ``error: <reason>``. the note ``error: <reason>``.
:param name: The artist name to query with. :param name: The artist name to query with.
:return: The snapshot CSV row of the artist. :return: The snapshot of the artist.
""" """
row: dict[str, str] = {x: "" for x in SNAPSHOT_FIELDS} snapshot: ArtistSnapshot = ArtistSnapshot(name=name)
row["name"] = name
try: try:
hit: tuple[str, str] | None = self.__search(name) hit: tuple[str, str] | None = self.__search(name)
if hit is None: if hit is None:
row["note"] = "not found" snapshot.note = NOTE_NOT_FOUND
return row return snapshot
row["qid"], row["note"] = hit snapshot.qid, snapshot.note = hit
self.__resolve(row) self.__resolve(snapshot)
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
row["note"] = f"error: {error}" snapshot.note = f"error: {error}"
return row return snapshot
def __search(self, name: str) -> tuple[str, str] | None: def __search(self, name: str) -> tuple[str, str] | None:
"""Search Wikidata for an artist. """Search Wikidata for an artist.
@@ -127,39 +189,36 @@ class ArtistFetcher:
return hit["id"], \ return hit["id"], \
description if isinstance(description, str) else "" description if isinstance(description, str) else ""
def __resolve(self, row: dict[str, str]) -> None: def __resolve(self, snapshot: ArtistSnapshot) -> None:
"""Resolve the claims of an artist into the row fields. """Resolve the claims of an artist into the snapshot.
:param row: The snapshot CSV row, with the QID set. :param snapshot: The snapshot, with the QID set.
:return: None. :return: None.
:raises OSError: On an HTTP or network error. :raises OSError: On an HTTP or network error.
:raises ValueError: On a JSON decoding error. :raises ValueError: On a JSON decoding error.
""" """
claims: dict[str, list[str]] \ claims: ArtistClaims = self.__get_claims(snapshot.qid)
= self.__get_claims(row["qid"]) country_ids: list[str] = claims.country_ids
gender_ids: list[str] = claims.get("P21", [])
type_ids: list[str] = claims.get("P31", [])
genre_ids: list[str] = claims.get("P136", [])
country_ids: list[str] = claims.get("P27", [])
if len(country_ids) == 0: if len(country_ids) == 0:
country_ids = claims.get("P495", []) country_ids = claims.origin_country_ids
labels: dict[str, str] = self.__get_labels( labels: dict[str, str] = self.__get_labels(
gender_ids + type_ids + genre_ids + country_ids) claims.gender_ids + claims.instance_of_ids
if len(gender_ids) > 0: + claims.genre_ids + country_ids)
row["gender"] = labels.get(gender_ids[0], "") if len(claims.gender_ids) > 0:
row["artist_type"] = self.__artist_type(type_ids, labels) snapshot.gender = labels.get(claims.gender_ids[0], "")
row["genre"] = "; ".join( snapshot.type = self.__artist_type(
labels[x] for x in genre_ids if x in labels) claims.instance_of_ids, labels)
snapshot.genre = "; ".join(
labels[x] for x in claims.genre_ids if x in labels)
if len(country_ids) > 0: if len(country_ids) > 0:
row["country"] = labels.get(country_ids[0], "") snapshot.country = labels.get(country_ids[0], "")
def __get_claims(self, qid: str) -> dict[str, list[str]]: def __get_claims(self, qid: str) -> ArtistClaims:
"""Fetch the item-ID claim targets of a Wikidata item. """Fetch the item-ID claim targets of a Wikidata item.
:param qid: The item ID. :param qid: The item ID.
:return: The item-ID targets of the gender, instance-of, :return: The item-ID targets of the gender, instance-of,
genre, and country properties, keyed by the property genre, and country properties.
ID.
:raises OSError: On an HTTP or network error. :raises OSError: On an HTTP or network error.
:raises ValueError: On a JSON decoding error. :raises ValueError: On a JSON decoding error.
""" """
@@ -172,9 +231,13 @@ class ArtistFetcher:
and isinstance(data["entities"].get(qid), dict): and isinstance(data["entities"].get(qid), dict):
claims = data["entities"][qid].get("claims") claims = data["entities"][qid].get("claims")
if not isinstance(claims, dict): if not isinstance(claims, dict):
return {} return ArtistClaims()
return {x: self.__targets(claims.get(x)) return ArtistClaims(
for x in ("P21", "P31", "P136", "P27", "P495")} gender_ids=self.__targets(claims.get("P21")),
instance_of_ids=self.__targets(claims.get("P31")),
genre_ids=self.__targets(claims.get("P136")),
country_ids=self.__targets(claims.get("P27")),
origin_country_ids=self.__targets(claims.get("P495")))
@staticmethod @staticmethod
def __targets(statements: Any) -> list[str]: def __targets(statements: Any) -> list[str]:
@@ -238,21 +301,23 @@ class ArtistFetcher:
@staticmethod @staticmethod
def __artist_type(type_ids: Sequence[str], def __artist_type(type_ids: Sequence[str],
labels: dict[str, str]) -> str: labels: dict[str, str]) \
-> ArtistType | Literal[""]:
"""Derive the artist type from the instance-of targets. """Derive the artist type from the instance-of targets.
:param type_ids: The item IDs of the instance-of targets. :param type_ids: The item IDs of the instance-of targets.
:param labels: The English labels, keyed by the item ID. :param labels: The English labels, keyed by the item ID.
:return: ``solo`` for a human, ``group`` for a musical :return: ``ArtistType.SOLO`` for a human,
ensemble, or empty for the human to decide. ``ArtistType.GROUP`` for a musical ensemble, or the
empty string for the human to decide.
""" """
if HUMAN_QID in type_ids: if HUMAN_QID in type_ids:
return "solo" return ArtistType.SOLO
qid: str qid: str
for qid in type_ids: for qid in type_ids:
label: str = labels.get(qid, "").lower() label: str = labels.get(qid, "").lower()
if any(x in label for x in GROUP_KEYWORDS): if any(x in label for x in GROUP_KEYWORDS):
return "group" return ArtistType.GROUP
return "" return ""
def __get_json(self, params: dict[str, str]) -> Any: def __get_json(self, params: dict[str, str]) -> Any:
@@ -288,17 +353,16 @@ def read_snapshot_names() -> set[str]:
with open(WIKIDATA_CSV, encoding="utf-8", with open(WIKIDATA_CSV, encoding="utf-8",
newline="") as file: newline="") as file:
reader: csv.DictReader[str] = csv.DictReader(file) reader: csv.DictReader[str] = csv.DictReader(file)
return {x["name"] for x in reader return {x["name"] for x in reader}
if x.get("name") is not None}
def append_row(row: dict[str, str]) -> None: def append_row(snapshot: ArtistSnapshot) -> None:
"""Append a row to the snapshot CSV file. """Append a snapshot row to the snapshot CSV file.
The CSV file is created with the header row when missing; the The CSV file is created with the header row when missing; the
existing rows are preserved. existing rows are preserved.
:param row: The snapshot CSV row. :param snapshot: The snapshot of an artist.
:return: None. :return: None.
:raises OSError: When the file cannot be written. :raises OSError: When the file cannot be written.
""" """
@@ -310,7 +374,7 @@ def append_row(row: dict[str, str]) -> None:
file, SNAPSHOT_FIELDS) file, SNAPSHOT_FIELDS)
if is_new: if is_new:
writer.writeheader() writer.writeheader()
writer.writerow(row) writer.writerow(snapshot.to_row())
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -336,15 +400,15 @@ def main(argv: list[str] | None = None) -> int:
if name in done: if name in done:
skipped += 1 skipped += 1
continue continue
row: dict[str, str] = fetcher.fetch(name) snapshot: ArtistSnapshot = fetcher.fetch(name)
append_row(row) append_row(snapshot)
status: str = row["qid"] status: str = snapshot.qid
if row["note"] == "not found": if snapshot.note == NOTE_NOT_FOUND:
not_found += 1 not_found += 1
status = "not found" status = "not found"
elif row["note"].startswith("error: "): elif snapshot.note.startswith("error: "):
errors += 1 errors += 1
status = row["note"] status = snapshot.note
else: else:
fetched += 1 fetched += 1
print(f"artist \"{name}\": {status}", print(f"artist \"{name}\": {status}",
+42 -18
View File
@@ -27,6 +27,7 @@ import time
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -36,6 +37,7 @@ from sqlalchemy.orm import Session
from .database import ds from .database import ds
from .models import ( from .models import (
Artist, Artist,
Role,
Song, Song,
SongArtist, SongArtist,
) )
@@ -61,6 +63,28 @@ SLEEP_SECONDS: float = 1.0
"""The delay between consecutive HTTP requests, in seconds.""" """The delay between consecutive HTTP requests, in seconds."""
@dataclass
class MissingLyrics:
"""One row of the missing lyrics report CSV file."""
song_id: int
"""The song ID."""
title: str
"""The song title."""
artist_credit: str
"""The artist credit of the song."""
reason: str
"""The reason the lyrics are missing."""
def to_row(self) -> list[str]:
"""Return this report entry as a CSV row.
:return: The row values, in the column order.
"""
return [str(self.song_id), self.title,
self.artist_credit, self.reason]
def parse_args(argv: list[str] | None) -> argparse.Namespace: def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command-line arguments. """Parse the command-line arguments.
@@ -160,21 +184,23 @@ class LyricsFetcher:
return None return None
def query_artist(session: Session, song_id: int) -> str | None: def query_artist(session: Session, song_id: int) -> str:
"""Find the artist name to query the APIs with. """Find the artist name to query the APIs with.
:param session: The database session. :param session: The database session.
:param song_id: The song ID. :param song_id: The song ID.
:return: The name of the primary-role artist with the lowest :return: The name of the primary-role artist with the lowest
position, or None when the song has no primary artist. position.
""" """
return session.scalar( name: str | None = session.scalar(
sa.select(Artist.name) sa.select(Artist.name)
.join(SongArtist, SongArtist.artist_id == Artist.id) .join(SongArtist, SongArtist.artist_id == Artist.id)
.where(SongArtist.song_id == song_id, .where(SongArtist.song_id == song_id,
SongArtist.role == "primary") SongArtist.role == Role.PRIMARY)
.order_by(SongArtist.position) .order_by(SongArtist.position)
.limit(1)) .limit(1))
assert name is not None
return name
def save_lyrics(song_id: int, lyrics: str) -> None: def save_lyrics(song_id: int, lyrics: str) -> None:
@@ -213,15 +239,14 @@ def append_provenance(song_id: int, source: str) -> None:
datetime.date.today().isoformat(), ""]) datetime.date.today().isoformat(), ""])
def write_missing(misses: Sequence[Sequence[Any]]) -> None: def write_missing(misses: Sequence[MissingLyrics]) -> None:
"""Rewrite the missing lyrics report CSV file. """Rewrite the missing lyrics report CSV file.
The previous content is replaced, so the file reflects the The previous content is replaced, so the file reflects the
current misses only. current misses only.
:param misses: The rows of the songs still without lyrics, :param misses: The report entries of the songs still without
each with the song ID, the title, the artist credit, and lyrics.
the reason.
:return: None. :return: None.
:raises OSError: When the file cannot be written. :raises OSError: When the file cannot be written.
""" """
@@ -230,7 +255,7 @@ def write_missing(misses: Sequence[Sequence[Any]]) -> None:
newline="") as file: newline="") as file:
writer: Any = csv.writer(file) writer: Any = csv.writer(file)
writer.writerow(MISSING_FIELDS) writer.writerow(MISSING_FIELDS)
writer.writerows(misses) writer.writerows(x.to_row() for x in misses)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -244,7 +269,7 @@ def main(argv: list[str] | None = None) -> int:
parse_args(argv) parse_args(argv)
fetcher: LyricsFetcher = LyricsFetcher() fetcher: LyricsFetcher = LyricsFetcher()
fetched: int = 0 fetched: int = 0
misses: list[tuple[int, str, str, str]] = [] misses: list[MissingLyrics] = []
session: Session = ds.get_db() session: Session = ds.get_db()
try: try:
song: Song song: Song
@@ -252,15 +277,14 @@ def main(argv: list[str] | None = None) -> int:
sa.select(Song).order_by(Song.id)): sa.select(Song).order_by(Song.id)):
if (LYRICS_DIR / f"{song.id}.txt").exists(): if (LYRICS_DIR / f"{song.id}.txt").exists():
continue continue
artist: str | None = query_artist(session, song.id) artist: str = query_artist(session, song.id)
result: tuple[str, str] | None = None result: tuple[str, str] | None = fetcher.fetch(
reason: str = "no primary artist" artist, song.title)
if artist is not None:
result = fetcher.fetch(artist, song.title)
reason = "not found"
if result is None: if result is None:
misses.append((song.id, song.title, misses.append(MissingLyrics(
song.artist_credit, reason)) song_id=song.id, title=song.title,
artist_credit=song.artist_credit,
reason="not found"))
print(f"song {song.id} \"{song.title}\": miss", print(f"song {song.id} \"{song.title}\": miss",
file=sys.stderr) file=sys.stderr)
continue continue
+14 -3
View File
@@ -9,12 +9,23 @@ lyrics, their yearly chart entries, the individual artists, and
the song-artist credits with the role and order. the song-artist credits with the role and order.
""" """
import enum
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base from .database import Base
class Role(enum.StrEnum):
"""The role of an artist on a song credit."""
PRIMARY = "primary"
"""The primary artist role."""
FEATURED = "featured"
"""The featured artist role."""
class Song(Base): class Song(Base):
"""A song, identified by its title and combined artist credit.""" """A song, identified by its title and combined artist credit."""
__tablename__ = "songs" __tablename__ = "songs"
@@ -66,7 +77,7 @@ class Artist(Base):
"""The Wikidata QID of the artist.""" """The Wikidata QID of the artist."""
gender: Mapped[str | None] gender: Mapped[str | None]
"""The gender of the artist.""" """The gender of the artist."""
artist_type: Mapped[str | None] type: Mapped[str | None]
"""The artist type: solo, group, or mixed.""" """The artist type: solo, group, or mixed."""
genre: Mapped[str | None] genre: Mapped[str | None]
"""The music genre of the artist.""" """The music genre of the artist."""
@@ -89,7 +100,7 @@ class SongArtist(Base):
sa.ForeignKey(Artist.id), primary_key=True) sa.ForeignKey(Artist.id), primary_key=True)
"""The ID of the credited artist.""" """The ID of the credited artist."""
role: Mapped[str] = mapped_column() role: Mapped[str] = mapped_column()
"""The role of the artist: primary or featured.""" """The role of the artist, a ``Role`` value."""
position: Mapped[int] position: Mapped[int]
"""The 0-based position of the artist in the credit order.""" """The 0-based position of the artist in the credit order."""
song: Mapped[Song] = relationship(back_populates="song_artists") song: Mapped[Song] = relationship(back_populates="song_artists")
@@ -98,6 +109,6 @@ class SongArtist(Base):
= relationship(back_populates="song_artists") = relationship(back_populates="song_artists")
"""The credited artist.""" """The credited artist."""
__table_args__ = ( __table_args__ = (
sa.CheckConstraint(role.in_(["primary", "featured"]), sa.CheckConstraint(role.in_([x.value for x in Role]),
name="ck_song_artists_role"),) name="ck_song_artists_role"),)
"""The table-level constraints.""" """The table-level constraints."""
+222 -151
View File
@@ -12,27 +12,20 @@ arbitration batch, and archives every artifact self-contained under
``runs/<phase>/<YYYYMMDD-HHMM>-<prompt-stem>/``. ``runs/<phase>/<YYYYMMDD-HHMM>-<prompt-stem>/``.
""" """
import argparse import argparse
import enum
import hashlib import hashlib
import json import json
import sys import sys
import time import time
from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Self
import anthropic import anthropic
from .config import get_settings from .config import get_settings
type Item = dict[str, str]
"""An input item with "id" and "content"."""
type Result = dict[str, Any]
"""One batch result record."""
type Results = dict[str, Result]
"""The batch result records, keyed by item ID."""
MODEL: str = "claude-sonnet-4-6" MODEL: str = "claude-sonnet-4-6"
TEMPERATURE: float = 0.0 TEMPERATURE: float = 0.0
THINKING: dict[str, str] = {"type": "disabled"} THINKING: dict[str, str] = {"type": "disabled"}
@@ -44,10 +37,134 @@ ARBITRATION_TEMPLATE: str = (
"<run2>\n{run2}\n</run2>") "<run2>\n{run2}\n</run2>")
class Source(enum.StrEnum):
"""The source of a final record."""
AGREED = "agreed"
"""The record takes the run text the two runs agreed on."""
ARBITRATION = "arbitration"
"""The record takes the arbitration output."""
class InputFormatError(Exception): class InputFormatError(Exception):
"""An error in the JSONL input file.""" """An error in the JSONL input file."""
@dataclass(frozen=True)
class InputItem:
"""An input item of the LLM step."""
id: str
"""The item ID."""
content: str
"""The item content."""
@classmethod
def get_instance(cls, data: Any, path: Path,
number: int) -> Self:
"""Validate one parsed JSONL record as an input item.
:param data: The parsed JSON value of the line.
:param path: The path of the JSONL input file, for the
messages.
:param number: The line number, for the messages.
:return: The validated input item.
:raises InputFormatError: When the record is malformed.
"""
if not isinstance(data, dict):
raise InputFormatError(
f"{path}: line {number}: not a JSON object")
if set(data.keys()) != {"id", "content"}:
raise InputFormatError(
f"{path}: line {number}: keys must be exactly"
" \"id\" and \"content\"")
if not isinstance(data["id"], str) or data["id"] == "":
raise InputFormatError(
f"{path}: line {number}: \"id\" must be a"
" non-empty string")
if not isinstance(data["content"], str):
raise InputFormatError(
f"{path}: line {number}: \"content\" must be a"
" string")
return cls(id=data["id"], content=data["content"])
@dataclass
class BatchResult:
"""One batch result record."""
id: str
"""The item ID."""
text: str | None = None
"""The output text of a succeeded result."""
stop_reason: str | None = None
"""The stop reason of a succeeded result."""
usage: dict[str, Any] | None = None
"""The token usage of a succeeded result."""
error: str | None = None
"""The error code of a failed result."""
@property
def is_failure(self) -> bool:
"""Whether this result is a failure.
:return: True when the result carries an error, or False
when it succeeded.
"""
return self.error is not None
@classmethod
def get_instance(cls, entry: Any) -> Self:
"""Create the result record of a batch result entry.
A succeeded entry yields the text, the stop reason, and
the usage; any other entry yields the error code.
:param entry: The batch result entry.
:return: The result record.
"""
result: Any = entry.result
match result.type:
case "succeeded":
message: Any = result.message
text: str = "".join(
x.text for x in message.content
if x.type == "text")
return cls(id=entry.custom_id, text=text,
stop_reason=message.stop_reason,
usage=usage_to_dict(message.usage))
case "errored":
return cls(id=entry.custom_id,
error=result.error.error.type)
case other:
return cls(id=entry.custom_id, error=str(other))
def to_record(self) -> dict[str, Any]:
"""Return this result as an archive JSONL record.
:return: The record, with the None fields omitted.
"""
return {k: v for k, v in asdict(self).items()
if v is not None}
type Results = dict[str, BatchResult]
"""The batch result records, keyed by item ID."""
@dataclass
class BatchInfo:
"""The bookkeeping of one submitted message batch."""
batch_id: str
"""The batch ID."""
submitted_at: str
"""The submission time, in ISO 8601 format."""
ended_at: str | None = None
"""The end time, in ISO 8601 format, or None while the batch
is still processing."""
def parse_args(argv: list[str] | None) -> argparse.Namespace: def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command-line arguments. """Parse the command-line arguments.
@@ -77,43 +194,16 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
return parser.parse_args(argv) return parser.parse_args(argv)
def validate_item(data: Any, path: Path, number: int) -> Item: def load_items(path: Path) -> list[InputItem]:
"""Validate one parsed JSONL record as an input item.
:param data: The parsed JSON value of the line.
:param path: The path of the JSONL input file, for the messages.
:param number: The line number, for the messages.
:return: The validated item with "id" and "content".
:raises InputFormatError: When the record is malformed.
"""
if not isinstance(data, dict):
raise InputFormatError(
f"{path}: line {number}: not a JSON object")
if set(data.keys()) != {"id", "content"}:
raise InputFormatError(
f"{path}: line {number}: keys must be exactly"
" \"id\" and \"content\"")
if not isinstance(data["id"], str) or data["id"] == "":
raise InputFormatError(
f"{path}: line {number}: \"id\" must be a"
" non-empty string")
if not isinstance(data["content"], str):
raise InputFormatError(
f"{path}: line {number}: \"content\" must be a"
" string")
return {"id": data["id"], "content": data["content"]}
def load_items(path: Path) -> list[Item]:
"""Load and validate the JSONL input items. """Load and validate the JSONL input items.
:param path: The path of the JSONL input file. :param path: The path of the JSONL input file.
:return: The items, each with "id" and "content", in file order. :return: The input items, in file order.
:raises InputFormatError: When a line is malformed, an ID is :raises InputFormatError: When a line is malformed, an ID is
duplicated, or the file contains no item. duplicated, or the file contains no item.
:raises OSError: When the file cannot be read. :raises OSError: When the file cannot be read.
""" """
items: list[Item] = [] items: list[InputItem] = []
seen: set[str] = set() seen: set[str] = set()
with open(path, encoding="utf-8") as file: with open(path, encoding="utf-8") as file:
for number, line in enumerate(file, start=1): for number, line in enumerate(file, start=1):
@@ -124,29 +214,30 @@ def load_items(path: Path) -> list[Item]:
except json.JSONDecodeError as error: except json.JSONDecodeError as error:
raise InputFormatError( raise InputFormatError(
f"{path}: line {number}: malformed JSON: {error}") f"{path}: line {number}: malformed JSON: {error}")
item: Item = validate_item(data, path, number) item: InputItem = InputItem.get_instance(
if item["id"] in seen: data, path, number)
if item.id in seen:
raise InputFormatError( raise InputFormatError(
f"{path}: line {number}: duplicated ID" f"{path}: line {number}: duplicated ID"
f" \"{item['id']}\"") f" \"{item.id}\"")
seen.add(item["id"]) seen.add(item.id)
items.append(item) items.append(item)
if len(items) == 0: if len(items) == 0:
raise InputFormatError(f"{path}: no input items") raise InputFormatError(f"{path}: no input items")
return items return items
def build_request(item: Item, system_prompt: str, def build_request(item: InputItem, system_prompt: str,
max_tokens: int) -> dict[str, Any]: max_tokens: int) -> dict[str, Any]:
"""Build one Message Batches request for an input item. """Build one Message Batches request for an input item.
:param item: The input item with "id" and "content". :param item: The input item.
:param system_prompt: The system prompt text. :param system_prompt: The system prompt text.
:param max_tokens: The maximum output tokens. :param max_tokens: The maximum output tokens.
:return: The batch request with "custom_id" and "params". :return: The batch request with "custom_id" and "params".
""" """
return { return {
"custom_id": item["id"], "custom_id": item.id,
"params": { "params": {
"model": MODEL, "model": MODEL,
"max_tokens": max_tokens, "max_tokens": max_tokens,
@@ -154,7 +245,7 @@ def build_request(item: Item, system_prompt: str,
"thinking": THINKING, "thinking": THINKING,
"system": system_prompt, "system": system_prompt,
"messages": [ "messages": [
{"role": "user", "content": item["content"]}, {"role": "user", "content": item.content},
], ],
}, },
} }
@@ -214,45 +305,21 @@ def usage_to_dict(usage: Any) -> dict[str, Any]:
:param usage: The usage object of a message. :param usage: The usage object of a message.
:return: The usage as a dictionary, without null entries. :return: The usage as a dictionary, without null entries.
""" """
if hasattr(usage, "model_dump"):
return {k: v for k, v in usage.model_dump().items() return {k: v for k, v in usage.model_dump().items()
if v is not None} if v is not None}
return dict(usage)
def collect_results(client: anthropic.Anthropic, def collect_results(client: anthropic.Anthropic,
batch_id: str) -> Results: batch_id: str) -> Results:
"""Collect the results of an ended batch. """Collect the results of an ended batch.
A succeeded result carries "text", "stop_reason", and "usage";
any other result carries "error" instead.
:param client: The Anthropic client. :param client: The Anthropic client.
:param batch_id: The batch ID. :param batch_id: The batch ID.
:return: The result records, keyed by custom ID. :return: The result records, keyed by custom ID.
""" """
results: Results = {} results: Results = {}
for entry in client.messages.batches.results(batch_id): for entry in client.messages.batches.results(batch_id):
result: Any = entry.result results[entry.custom_id] = BatchResult.get_instance(entry)
record: Result
match result.type:
case "succeeded":
message: Any = result.message
text: str = "".join(
x.text for x in message.content
if x.type == "text")
record = {"id": entry.custom_id, "text": text,
"stop_reason": message.stop_reason,
"usage": usage_to_dict(message.usage)}
case "errored":
error_type: Any = getattr(
result.error, "type", "unknown")
record = {"id": entry.custom_id,
"error": str(error_type)}
case other:
record = {"id": entry.custom_id,
"error": str(other)}
results[entry.custom_id] = record
return results return results
@@ -261,18 +328,18 @@ def find_failures(item_ids: list[str],
"""Find the item IDs that failed in a result set. """Find the item IDs that failed in a result set.
An item failed when it is missing from the results or when its An item failed when it is missing from the results or when its
record carries an "error" field. record is a failure.
:param item_ids: The item IDs to check, in order. :param item_ids: The item IDs to check, in order.
:param results: The result records, keyed by item ID. :param results: The result records, keyed by item ID.
:return: The failed item IDs, in the given order. :return: The failed item IDs, in the given order.
""" """
return [x for x in item_ids return [x for x in item_ids
if x not in results or "error" in results[x]] if x not in results or results[x].is_failure]
def split_by_agreement( def split_by_agreement(
items: list[Item], run1: Results, run2: Results, items: list[InputItem], run1: Results, run2: Results,
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
"""Split the item IDs into agreed and disagreeing ones. """Split the item IDs into agreed and disagreeing ones.
@@ -287,18 +354,18 @@ def split_by_agreement(
agreed: list[str] = [] agreed: list[str] = []
disagreed: list[str] = [] disagreed: list[str] = []
for item in items: for item in items:
item_id: str = item["id"] text1: str | None = run1[item.id].text
text1: str = run1[item_id]["text"].strip() text2: str | None = run2[item.id].text
text2: str = run2[item_id]["text"].strip() assert text1 is not None and text2 is not None
if text1 == text2: if text1.strip() == text2.strip():
agreed.append(item_id) agreed.append(item.id)
else: else:
disagreed.append(item_id) disagreed.append(item.id)
return agreed, disagreed return agreed, disagreed
def build_final_records( def build_final_records(
items: list[Item], run1: Results, arbitration: Results, items: list[InputItem], run1: Results, arbitration: Results,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
"""Assemble the final records, one per item, in input order. """Assemble the final records, one per item, in input order.
@@ -312,15 +379,17 @@ def build_final_records(
""" """
records: list[dict[str, str]] = [] records: list[dict[str, str]] = []
for item in items: for item in items:
item_id: str = item["id"] text: str | None
if item_id in arbitration: if item.id in arbitration:
records.append({"id": item_id, text = arbitration[item.id].text
"text": arbitration[item_id]["text"], assert text is not None
"source": "arbitration"}) records.append({"id": item.id, "text": text,
"source": Source.ARBITRATION})
else: else:
records.append({"id": item_id, text = run1[item.id].text
"text": run1[item_id]["text"].strip(), assert text is not None
"source": "agreed"}) records.append({"id": item.id, "text": text.strip(),
"source": Source.AGREED})
return records return records
@@ -335,9 +404,7 @@ def create_archive_dir(runs_root: Path, phase: str, prompt_path: Path,
:return: The created archive directory. :return: The created archive directory.
:raises FileExistsError: When the directory already exists. :raises FileExistsError: When the directory already exists.
""" """
stem: str = prompt_path.name stem: str = prompt_path.stem
if stem.endswith(".md"):
stem = stem[:-len(".md")]
name: str = f"{now.strftime('%Y%m%d-%H%M')}-{stem}" name: str = f"{now.strftime('%Y%m%d-%H%M')}-{stem}"
directory: Path = runs_root / phase / name directory: Path = runs_root / phase / name
if directory.exists(): if directory.exists():
@@ -352,6 +419,7 @@ def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
:param path: The path of the file to write. :param path: The path of the file to write.
:param records: The records, one per line. :param records: The records, one per line.
:return: None.
""" """
with open(path, "w", encoding="utf-8") as file: with open(path, "w", encoding="utf-8") as file:
for record in records: for record in records:
@@ -363,12 +431,29 @@ def write_json(path: Path, data: dict[str, Any]) -> None:
:param path: The path of the file to write. :param path: The path of the file to write.
:param data: The data to write. :param data: The data to write.
:return: None.
""" """
path.write_text( path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n", json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8") encoding="utf-8")
def write_meta(path: Path, meta: dict[str, Any]) -> None:
"""Write the metadata to the ``meta.json`` file.
The ``BatchInfo`` values under ``batches`` are written as
plain JSON objects.
:param path: The path of the ``meta.json`` file.
:param meta: The metadata to write.
:return: None.
"""
write_json(path, {
**meta,
"batches": {k: asdict(v)
for k, v in meta["batches"].items()}})
def sha256_of(path: Path) -> str: def sha256_of(path: Path) -> str:
"""Calculate the SHA-256 digest of a file. """Calculate the SHA-256 digest of a file.
@@ -387,24 +472,8 @@ def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds") return datetime.now().astimezone().isoformat(timespec="seconds")
def format_timestamp(value: Any) -> str:
"""Format a timestamp value from a batch object as a string.
:param value: The timestamp: a datetime, a string, or None.
:return: The timestamp as a string, or the current local time
when the value is missing.
"""
match value:
case datetime():
return value.isoformat()
case str():
return value
case _:
return now_iso()
def execute_runs( def execute_runs(
client: anthropic.Anthropic, items: list[Item], client: anthropic.Anthropic, items: list[InputItem],
system_prompt: str, max_tokens: int, meta: dict[str, Any], system_prompt: str, max_tokens: int, meta: dict[str, Any],
) -> tuple[Results, Results]: ) -> tuple[Results, Results]:
"""Submit the two identical runs and await their results. """Submit the two identical runs and await their results.
@@ -421,26 +490,25 @@ def execute_runs(
""" """
requests: list[dict[str, Any]] = [ requests: list[dict[str, Any]] = [
build_request(x, system_prompt, max_tokens) for x in items] build_request(x, system_prompt, max_tokens) for x in items]
batch_ids: dict[str, str] = {} infos: dict[str, BatchInfo] = {}
for run_name in ("run1", "run2"): for run_name in ("run1", "run2"):
batch_id: str = submit_batch(client, requests) info: BatchInfo = BatchInfo(
batch_ids[run_name] = batch_id batch_id=submit_batch(client, requests),
meta["batches"][run_name] = { submitted_at=now_iso())
"batch_id": batch_id, "submitted_at": now_iso(), infos[run_name] = info
"ended_at": None} meta["batches"][run_name] = info
print(f"{run_name}: submitted batch {batch_id}", print(f"{run_name}: submitted batch {info.batch_id}",
file=sys.stderr) file=sys.stderr)
batches: dict[str, Any] = poll_batches( batches: dict[str, Any] = poll_batches(
client, list(batch_ids.values())) client, [x.batch_id for x in infos.values()])
for run_name, batch_id in batch_ids.items(): for info in infos.values():
meta["batches"][run_name]["ended_at"] = format_timestamp( info.ended_at = batches[info.batch_id].ended_at.isoformat()
getattr(batches[batch_id], "ended_at", None)) return (collect_results(client, infos["run1"].batch_id),
return (collect_results(client, batch_ids["run1"]), collect_results(client, infos["run2"].batch_id))
collect_results(client, batch_ids["run2"]))
def execute_arbitration( def execute_arbitration(
client: anthropic.Anthropic, items: list[Item], client: anthropic.Anthropic, items: list[InputItem],
disagreed: list[str], run1: Results, run2: Results, disagreed: list[str], run1: Results, run2: Results,
system_prompt: str, max_tokens: int, meta: dict[str, Any], system_prompt: str, max_tokens: int, meta: dict[str, Any],
) -> Results: ) -> Results:
@@ -460,23 +528,26 @@ def execute_arbitration(
:return: The arbitration results, keyed by item ID. :return: The arbitration results, keyed by item ID.
""" """
content_by_id: dict[str, str] = { content_by_id: dict[str, str] = {
x["id"]: x["content"] for x in items} x.id: x.content for x in items}
requests: list[dict[str, Any]] = [ requests: list[dict[str, Any]] = []
build_request( for item_id in disagreed:
{"id": x, text1: str | None = run1[item_id].text
"content": build_arbitration_content( text2: str | None = run2[item_id].text
content_by_id[x], run1[x]["text"], run2[x]["text"])}, assert text1 is not None and text2 is not None
system_prompt, max_tokens) requests.append(build_request(
for x in disagreed] InputItem(id=item_id,
batch_id: str = submit_batch(client, requests) content=build_arbitration_content(
meta["batches"]["arbitration"] = { content_by_id[item_id], text1, text2)),
"batch_id": batch_id, "submitted_at": now_iso(), system_prompt, max_tokens))
"ended_at": None} info: BatchInfo = BatchInfo(
print(f"arbitration: submitted batch {batch_id}", file=sys.stderr) batch_id=submit_batch(client, requests),
batches: dict[str, Any] = poll_batches(client, [batch_id]) submitted_at=now_iso())
meta["batches"]["arbitration"]["ended_at"] = format_timestamp( meta["batches"]["arbitration"] = info
getattr(batches[batch_id], "ended_at", None)) print(f"arbitration: submitted batch {info.batch_id}",
return collect_results(client, batch_id) file=sys.stderr)
batches: dict[str, Any] = poll_batches(client, [info.batch_id])
info.ended_at = batches[info.batch_id].ended_at.isoformat()
return collect_results(client, info.batch_id)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -487,7 +558,7 @@ def main(argv: list[str] | None = None) -> int:
""" """
args: argparse.Namespace = parse_args(argv) args: argparse.Namespace = parse_args(argv)
try: try:
items: list[Item] = load_items(args.input) items: list[InputItem] = load_items(args.input)
prompt_text: str = args.prompt.read_text(encoding="utf-8") prompt_text: str = args.prompt.read_text(encoding="utf-8")
arbitration_text: str = args.arbitration_prompt.read_text( arbitration_text: str = args.arbitration_prompt.read_text(
encoding="utf-8") encoding="utf-8")
@@ -522,7 +593,7 @@ def main(argv: list[str] | None = None) -> int:
"script_version": SCRIPT_VERSION, "script_version": SCRIPT_VERSION,
} }
if args.dry_run: if args.dry_run:
write_json(meta_path, meta) write_meta(meta_path, meta)
print(json.dumps( print(json.dumps(
build_request(items[0], prompt_text, args.max_tokens), build_request(items[0], prompt_text, args.max_tokens),
ensure_ascii=False, indent=2)) ensure_ascii=False, indent=2))
@@ -535,15 +606,15 @@ def main(argv: list[str] | None = None) -> int:
run2: Results run2: Results
run1, run2 = execute_runs( run1, run2 = execute_runs(
client, items, prompt_text, args.max_tokens, meta) client, items, prompt_text, args.max_tokens, meta)
item_ids: list[str] = [x["id"] for x in items] item_ids: list[str] = [x.id for x in items]
write_jsonl(run_dir / "run1.jsonl", write_jsonl(run_dir / "run1.jsonl",
[run1[x] for x in item_ids if x in run1]) [run1[x].to_record() for x in item_ids if x in run1])
write_jsonl(run_dir / "run2.jsonl", write_jsonl(run_dir / "run2.jsonl",
[run2[x] for x in item_ids if x in run2]) [run2[x].to_record() for x in item_ids if x in run2])
failed: set[str] = (set(find_failures(item_ids, run1)) failed: set[str] = (set(find_failures(item_ids, run1))
| set(find_failures(item_ids, run2))) | set(find_failures(item_ids, run2)))
if len(failed) > 0: if len(failed) > 0:
write_json(meta_path, meta) write_meta(meta_path, meta)
names: str = ", ".join(x for x in item_ids if x in failed) names: str = ", ".join(x for x in item_ids if x in failed)
print(f"error: failed items: {names}", file=sys.stderr) print(f"error: failed items: {names}", file=sys.stderr)
return 1 return 1
@@ -558,18 +629,18 @@ def main(argv: list[str] | None = None) -> int:
client, items, disagreed, run1, run2, arbitration_text, client, items, disagreed, run1, run2, arbitration_text,
args.max_tokens, meta) args.max_tokens, meta)
write_jsonl(run_dir / "arbitration.jsonl", write_jsonl(run_dir / "arbitration.jsonl",
[arbitration[x] for x in disagreed [arbitration[x].to_record() for x in disagreed
if x in arbitration]) if x in arbitration])
arb_failed: list[str] = find_failures(disagreed, arbitration) arb_failed: list[str] = find_failures(disagreed, arbitration)
if len(arb_failed) > 0: if len(arb_failed) > 0:
write_json(meta_path, meta) write_meta(meta_path, meta)
names = ", ".join(arb_failed) names = ", ".join(arb_failed)
print(f"error: failed arbitration items: {names}", print(f"error: failed arbitration items: {names}",
file=sys.stderr) file=sys.stderr)
return 1 return 1
write_jsonl(run_dir / "final.jsonl", write_jsonl(run_dir / "final.jsonl",
build_final_records(items, run1, arbitration)) build_final_records(items, run1, arbitration))
write_json(meta_path, meta) write_meta(meta_path, meta)
print(f"done: {len(items)} items, {len(agreed)} agreed," print(f"done: {len(items)} items, {len(agreed)} agreed,"
f" {len(disagreed)} arbitrated; archived to {run_dir}", f" {len(disagreed)} arbitrated; archived to {run_dir}",
file=sys.stderr) file=sys.stderr)
+24 -23
View File
@@ -20,6 +20,7 @@ from pop_fem_audit_tools.database import DataSource
from pop_fem_audit_tools.models import ( from pop_fem_audit_tools.models import (
Artist, Artist,
ChartEntry, ChartEntry,
Role,
Song, Song,
) )
@@ -30,47 +31,47 @@ class TestParseArtistCredit(unittest.TestCase):
def test_plain_solo(self) -> None: def test_plain_solo(self) -> None:
"""Test a plain solo artist credit.""" """Test a plain solo artist credit."""
self.assertEqual(build_db.parse_artist_credit("Adele"), self.assertEqual(build_db.parse_artist_credit("Adele"),
[("Adele", "primary")]) [("Adele", Role.PRIMARY)])
def test_featuring_with_and(self) -> None: def test_featuring_with_and(self) -> None:
"""Test a featuring credit with an "and" delimiter.""" """Test a featuring credit with an "and" delimiter."""
self.assertEqual( self.assertEqual(
build_db.parse_artist_credit( build_db.parse_artist_credit(
"Drake featuring Wizkid and Kyla"), "Drake featuring Wizkid and Kyla"),
[("Drake", "primary"), [("Drake", Role.PRIMARY),
("Wizkid", "featured"), ("Wizkid", Role.FEATURED),
("Kyla", "featured")]) ("Kyla", Role.FEATURED)])
def test_comma_and_ampersand(self) -> None: def test_comma_and_ampersand(self) -> None:
"""Test a credit with comma and ampersand delimiters.""" """Test a credit with comma and ampersand delimiters."""
self.assertEqual( self.assertEqual(
build_db.parse_artist_credit( build_db.parse_artist_credit(
"Lady Gaga, Bradley Cooper & BloodPop"), "Lady Gaga, Bradley Cooper & BloodPop"),
[("Lady Gaga", "primary"), [("Lady Gaga", Role.PRIMARY),
("Bradley Cooper", "primary"), ("Bradley Cooper", Role.PRIMARY),
("BloodPop", "primary")]) ("BloodPop", Role.PRIMARY)])
def test_x_delimiter(self) -> None: def test_x_delimiter(self) -> None:
"""Test the "x" delimiter.""" """Test the "x" delimiter."""
self.assertEqual( self.assertEqual(
build_db.parse_artist_credit("KAROL G x Nicki Minaj"), build_db.parse_artist_credit("KAROL G x Nicki Minaj"),
[("KAROL G", "primary"), [("KAROL G", Role.PRIMARY),
("Nicki Minaj", "primary")]) ("Nicki Minaj", Role.PRIMARY)])
def test_plus_delimiter(self) -> None: def test_plus_delimiter(self) -> None:
"""Test the "+" delimiter.""" """Test the "+" delimiter."""
self.assertEqual( self.assertEqual(
build_db.parse_artist_credit("Marshmello + Halsey"), build_db.parse_artist_credit("Marshmello + Halsey"),
[("Marshmello", "primary"), [("Marshmello", Role.PRIMARY),
("Halsey", "primary")]) ("Halsey", Role.PRIMARY)])
def test_with_delimiter(self) -> None: def test_with_delimiter(self) -> None:
"""Test the "with" delimiter.""" """Test the "with" delimiter."""
self.assertEqual( self.assertEqual(
build_db.parse_artist_credit( build_db.parse_artist_credit(
"Kane Brown with Lauren Alaina"), "Kane Brown with Lauren Alaina"),
[("Kane Brown", "primary"), [("Kane Brown", Role.PRIMARY),
("Lauren Alaina", "primary")]) ("Lauren Alaina", Role.PRIMARY)])
def test_feat_abbreviation(self) -> None: def test_feat_abbreviation(self) -> None:
"""Test that "Feat." splits the featured side.""" """Test that "Feat." splits the featured side."""
@@ -78,17 +79,17 @@ class TestParseArtistCredit(unittest.TestCase):
build_db.parse_artist_credit( build_db.parse_artist_credit(
"Ariana Grande Feat. Doja Cat" "Ariana Grande Feat. Doja Cat"
" & Megan Thee Stallion"), " & Megan Thee Stallion"),
[("Ariana Grande", "primary"), [("Ariana Grande", Role.PRIMARY),
("Doja Cat", "featured"), ("Doja Cat", Role.FEATURED),
("Megan Thee Stallion", "featured")]) ("Megan Thee Stallion", Role.FEATURED)])
def test_case_insensitive_featuring(self) -> None: def test_case_insensitive_featuring(self) -> None:
"""Test that "Featuring" splits case-insensitively.""" """Test that "Featuring" splits case-insensitively."""
self.assertEqual( self.assertEqual(
build_db.parse_artist_credit( build_db.parse_artist_credit(
"24kGoldn Featuring iann dior"), "24kGoldn Featuring iann dior"),
[("24kGoldn", "primary"), [("24kGoldn", Role.PRIMARY),
("iann dior", "featured")]) ("iann dior", Role.FEATURED)])
class TestBuildDB(unittest.TestCase): class TestBuildDB(unittest.TestCase):
@@ -183,8 +184,8 @@ class TestBuildDB(unittest.TestCase):
[(2016, 2), (2017, 1)]) [(2016, 2), (2017, 1)])
self.assertEqual([(x.artist.name, x.role, x.position) self.assertEqual([(x.artist.name, x.role, x.position)
for x in song.song_artists], for x in song.song_artists],
[("Drake", "primary", 0), [("Drake", Role.PRIMARY, 0),
("Wizkid", "featured", 1)]) ("Wizkid", Role.FEATURED, 1)])
self.assertIn("3 songs", stderr) self.assertIn("3 songs", stderr)
self.assertIn("4 chart entries", stderr) self.assertIn("4 chart entries", stderr)
self.assertIn("4 artists", stderr) self.assertIn("4 artists", stderr)
@@ -244,11 +245,11 @@ class TestBuildDB(unittest.TestCase):
def test_overrides_apply_over_wikidata(self) -> None: def test_overrides_apply_over_wikidata(self) -> None:
"""Test that the overrides win over the Wikidata snapshot.""" """Test that the overrides win over the Wikidata snapshot."""
Path("data/artists_wikidata.csv").write_text( Path("data/artists_wikidata.csv").write_text(
"name,qid,gender,artist_type,genre,country,note\n" "name,qid,gender,type,genre,country,note\n"
"Adele,Q2831,female,solo,pop,GB,\n", "Adele,Q2831,female,solo,pop,GB,\n",
encoding="utf-8") encoding="utf-8")
Path("data/artists_overrides.csv").write_text( Path("data/artists_overrides.csv").write_text(
"name,qid,gender,artist_type,genre,country,note\n" "name,qid,gender,type,genre,country,note\n"
"Adele,,,,soul,,manually checked\n", "Adele,,,,soul,,manually checked\n",
encoding="utf-8") encoding="utf-8")
self.assertEqual(self.__run_build()[0], 0) self.assertEqual(self.__run_build()[0], 0)
@@ -264,7 +265,7 @@ class TestBuildDB(unittest.TestCase):
def test_unknown_override_name_fails(self) -> None: def test_unknown_override_name_fails(self) -> None:
"""Test that an unknown override name fails the build.""" """Test that an unknown override name fails the build."""
Path("data/artists_overrides.csv").write_text( Path("data/artists_overrides.csv").write_text(
"name,qid,gender,artist_type,genre,country,note\n" "name,qid,gender,type,genre,country,note\n"
"Adel,,female,,,,typo\n", encoding="utf-8") "Adel,,female,,,,typo\n", encoding="utf-8")
status: int status: int
stderr: str stderr: str
+1 -1
View File
@@ -26,7 +26,7 @@ class TestFetchArtists(unittest.TestCase):
"""Test cases for the artist metadata fetcher.""" """Test cases for the artist metadata fetcher."""
HEADER: list[str] = [ HEADER: list[str] = [
"name", "qid", "gender", "artist_type", "genre", "name", "qid", "gender", "type", "genre",
"country", "note"] "country", "note"]
"""The expected header row of the snapshot CSV file.""" """The expected header row of the snapshot CSV file."""
+2 -1
View File
@@ -21,6 +21,7 @@ from pop_fem_audit_tools import config, fetch_lyrics
from pop_fem_audit_tools.database import Base, DataSource from pop_fem_audit_tools.database import Base, DataSource
from pop_fem_audit_tools.models import ( from pop_fem_audit_tools.models import (
Artist, Artist,
Role,
Song, Song,
SongArtist, SongArtist,
) )
@@ -81,7 +82,7 @@ class TestFetchLyrics(unittest.TestCase):
session.add(song) session.add(song)
session.add(SongArtist(song=song, session.add(SongArtist(song=song,
artist=artists[artist], artist=artists[artist],
role="primary", role=Role.PRIMARY,
position=0)) position=0))
session.commit() session.commit()
finally: finally:
+5 -4
View File
@@ -14,6 +14,7 @@ from pop_fem_audit_tools.database import Base, DataSource
from pop_fem_audit_tools.models import ( from pop_fem_audit_tools.models import (
Artist, Artist,
ChartEntry, ChartEntry,
Role,
Song, Song,
SongArtist, SongArtist,
) )
@@ -42,9 +43,9 @@ class TestModels(unittest.TestCase):
song.chart_entries = [ChartEntry(year=2016, rank=4)] song.chart_entries = [ChartEntry(year=2016, rank=4)]
song.song_artists = [ song.song_artists = [
SongArtist(artist=Artist(name="Drake"), SongArtist(artist=Artist(name="Drake"),
role="primary", position=0), role=Role.PRIMARY, position=0),
SongArtist(artist=Artist(name="Wizkid"), SongArtist(artist=Artist(name="Wizkid"),
role="featured", position=1)] role=Role.FEATURED, position=1)]
song.lyrics = "Baby, I like your style" song.lyrics = "Baby, I like your style"
self.__session.add(song) self.__session.add(song)
self.__session.commit() self.__session.commit()
@@ -63,8 +64,8 @@ class TestModels(unittest.TestCase):
[(2016, 4)]) [(2016, 4)])
self.assertEqual([(x.artist.name, x.role, x.position) self.assertEqual([(x.artist.name, x.role, x.position)
for x in song.song_artists], for x in song.song_artists],
[("Drake", "primary", 0), [("Drake", Role.PRIMARY, 0),
("Wizkid", "featured", 1)]) ("Wizkid", Role.FEATURED, 1)])
self.assertEqual(song.lyrics, self.assertEqual(song.lyrics,
"Baby, I like your style") "Baby, I like your style")
artist: Artist | None = self.__session.scalar( artist: Artist | None = self.__session.scalar(
+43 -33
View File
@@ -51,8 +51,12 @@ class RunLLMTestCase(unittest.TestCase):
error_type: str) -> mock.Mock: error_type: str) -> mock.Mock:
"""Create a mock errored batch result entry. """Create a mock errored batch result entry.
The error object is shaped as the SDK envelope: the outer
error carries the constant type ``error``, and the specific
error code lives at ``error.error.type``.
:param custom_id: The custom ID of the entry. :param custom_id: The custom ID of the entry.
:param error_type: The error type. :param error_type: The specific error code.
:return: The mock result entry. :return: The mock result entry.
""" """
entry: mock.Mock = mock.Mock() entry: mock.Mock = mock.Mock()
@@ -60,7 +64,9 @@ class RunLLMTestCase(unittest.TestCase):
entry.result = mock.Mock() entry.result = mock.Mock()
entry.result.type = "errored" entry.result.type = "errored"
entry.result.error = mock.Mock() entry.result.error = mock.Mock()
entry.result.error.type = error_type entry.result.error.type = "error"
entry.result.error.error = mock.Mock()
entry.result.error.error.type = error_type
return entry return entry
@staticmethod @staticmethod
@@ -105,9 +111,10 @@ class TestLoadItems(RunLLMTestCase):
path: Path = self.__write_input( path: Path = self.__write_input(
'{"id": "a", "content": "one"}\n' '{"id": "a", "content": "one"}\n'
'{"id": "b", "content": "two"}\n') '{"id": "b", "content": "two"}\n')
items: list[run_llm.Item] = run_llm.load_items(path) items: list[run_llm.InputItem] = run_llm.load_items(path)
self.assertEqual(items, [{"id": "a", "content": "one"}, self.assertEqual(items, [
{"id": "b", "content": "two"}]) run_llm.InputItem(id="a", content="one"),
run_llm.InputItem(id="b", content="two")])
def test_malformed_json_names_line(self) -> None: def test_malformed_json_names_line(self) -> None:
"""Test that malformed JSON reports the line number.""" """Test that malformed JSON reports the line number."""
@@ -161,7 +168,7 @@ class TestRequestBuilding(RunLLMTestCase):
def test_build_request(self) -> None: def test_build_request(self) -> None:
"""Test the shape of a batch request.""" """Test the shape of a batch request."""
request: dict[str, Any] = run_llm.build_request( request: dict[str, Any] = run_llm.build_request(
{"id": "song-1", "content": "the lyrics"}, run_llm.InputItem(id="song-1", content="the lyrics"),
"the system prompt", 2048) "the system prompt", 2048)
self.assertEqual(request["custom_id"], "song-1") self.assertEqual(request["custom_id"], "song-1")
params: dict[str, Any] = request["params"] params: dict[str, Any] = request["params"]
@@ -188,18 +195,18 @@ class TestAgreement(RunLLMTestCase):
def test_split_by_agreement(self) -> None: def test_split_by_agreement(self) -> None:
"""Test splitting items into agreed and disagreeing ones.""" """Test splitting items into agreed and disagreeing ones."""
items: list[run_llm.Item] = [ items: list[run_llm.InputItem] = [
{"id": "a", "content": "one"}, run_llm.InputItem(id="a", content="one"),
{"id": "b", "content": "two"}, run_llm.InputItem(id="b", content="two"),
{"id": "c", "content": "three"}] run_llm.InputItem(id="c", content="three")]
run1: run_llm.Results = { run1: run_llm.Results = {
"a": {"id": "a", "text": "same\n"}, "a": run_llm.BatchResult(id="a", text="same\n"),
"b": {"id": "b", "text": "left"}, "b": run_llm.BatchResult(id="b", text="left"),
"c": {"id": "c", "text": " padded "}} "c": run_llm.BatchResult(id="c", text=" padded ")}
run2: run_llm.Results = { run2: run_llm.Results = {
"a": {"id": "a", "text": "same"}, "a": run_llm.BatchResult(id="a", text="same"),
"b": {"id": "b", "text": "right"}, "b": run_llm.BatchResult(id="b", text="right"),
"c": {"id": "c", "text": "padded"}} "c": run_llm.BatchResult(id="c", text="padded")}
agreed: list[str] agreed: list[str]
disagreed: list[str] disagreed: list[str]
agreed, disagreed = run_llm.split_by_agreement( agreed, disagreed = run_llm.split_by_agreement(
@@ -213,14 +220,15 @@ class TestFinalRecords(RunLLMTestCase):
def test_build_final_records(self) -> None: def test_build_final_records(self) -> None:
"""Test assembling final records from runs and arbitration.""" """Test assembling final records from runs and arbitration."""
items: list[run_llm.Item] = [ items: list[run_llm.InputItem] = [
{"id": "a", "content": "one"}, run_llm.InputItem(id="a", content="one"),
{"id": "b", "content": "two"}] run_llm.InputItem(id="b", content="two")]
run1: run_llm.Results = { run1: run_llm.Results = {
"a": {"id": "a", "text": "agreed text\n"}, "a": run_llm.BatchResult(id="a", text="agreed text\n"),
"b": {"id": "b", "text": "left"}} "b": run_llm.BatchResult(id="b", text="left")}
arbitration: run_llm.Results = { arbitration: run_llm.Results = {
"b": {"id": "b", "text": "arbitrated text"}} "b": run_llm.BatchResult(id="b",
text="arbitrated text")}
records: list[dict[str, str]] = run_llm.build_final_records( records: list[dict[str, str]] = run_llm.build_final_records(
items, run1, arbitration) items, run1, arbitration)
self.assertEqual(records, [ self.assertEqual(records, [
@@ -237,23 +245,23 @@ class TestCollectResults(RunLLMTestCase):
client: mock.Mock = mock.Mock() client: mock.Mock = mock.Mock()
client.messages.batches.results.return_value = iter([ client.messages.batches.results.return_value = iter([
self._make_success_entry("a", "output a"), self._make_success_entry("a", "output a"),
self._make_error_entry("b", "invalid_request")]) self._make_error_entry("b", "invalid_request_error")])
results: run_llm.Results = run_llm.collect_results( results: run_llm.Results = run_llm.collect_results(
client, "batch_x") client, "batch_x")
self.assertEqual(results["a"]["text"], "output a") self.assertEqual(results["a"].text, "output a")
self.assertEqual(results["a"]["stop_reason"], "end_turn") self.assertEqual(results["a"].stop_reason, "end_turn")
self.assertEqual(results["a"]["usage"], self.assertEqual(results["a"].usage,
{"input_tokens": 10, "output_tokens": 5}) {"input_tokens": 10, "output_tokens": 5})
self.assertEqual(results["b"], self.assertEqual(results["b"], run_llm.BatchResult(
{"id": "b", "error": "invalid_request"}) id="b", error="invalid_request_error"))
client.messages.batches.results.assert_called_once_with( client.messages.batches.results.assert_called_once_with(
"batch_x") "batch_x")
def test_find_failures(self) -> None: def test_find_failures(self) -> None:
"""Test finding failed and missing items.""" """Test finding failed and missing items."""
results: run_llm.Results = { results: run_llm.Results = {
"a": {"id": "a", "text": "fine"}, "a": run_llm.BatchResult(id="a", text="fine"),
"b": {"id": "b", "error": "errored"}} "b": run_llm.BatchResult(id="b", error="errored")}
self.assertEqual( self.assertEqual(
run_llm.find_failures(["a", "b", "c"], results), run_llm.find_failures(["a", "b", "c"], results),
["b", "c"]) ["b", "c"])
@@ -345,7 +353,7 @@ class TestMainFlow(RunLLMTestCase):
mock.Mock(id=x) for x in batch_ids] mock.Mock(id=x) for x in batch_ids]
ended: mock.Mock = mock.Mock() ended: mock.Mock = mock.Mock()
ended.processing_status = "ended" ended.processing_status = "ended"
ended.ended_at = "2026-07-30T20:00:00+08:00" ended.ended_at = datetime(2026, 7, 30, 20, 0)
client.messages.batches.retrieve.return_value = ended client.messages.batches.retrieve.return_value = ended
results: dict[str, list[Any]] = { results: dict[str, list[Any]] = {
"batch_run1": run1, "batch_run2": run2, "batch_run1": run1, "batch_run2": run2,
@@ -482,7 +490,8 @@ class TestMainFlow(RunLLMTestCase):
"""Test that a failed item aborts with a non-zero status.""" """Test that a failed item aborts with a non-zero status."""
client: mock.Mock = self.__make_client( client: mock.Mock = self.__make_client(
run1=[self._make_success_entry("a", "answer a"), run1=[self._make_success_entry("a", "answer a"),
self._make_error_entry("b", "invalid_request")], self._make_error_entry(
"b", "invalid_request_error")],
run2=[self._make_success_entry("a", "answer a"), run2=[self._make_success_entry("a", "answer a"),
self._make_success_entry("b", "answer b")]) self._make_success_entry("b", "answer b")])
status: int = self.__run_main(self.__argv, client)[0] status: int = self.__run_main(self.__argv, client)[0]
@@ -495,7 +504,8 @@ class TestMainFlow(RunLLMTestCase):
run1_lines: list[str] = (run_dir / "run1.jsonl") \ run1_lines: list[str] = (run_dir / "run1.jsonl") \
.read_text(encoding="utf-8").splitlines() .read_text(encoding="utf-8").splitlines()
self.assertEqual(json.loads(run1_lines[1]), self.assertEqual(json.loads(run1_lines[1]),
{"id": "b", "error": "invalid_request"}) {"id": "b",
"error": "invalid_request_error"})
def test_invalid_input_exits_non_zero(self) -> None: def test_invalid_input_exits_non_zero(self) -> None:
"""Test that an invalid input file aborts before archiving.""" """Test that an invalid input file aborts before archiving."""