Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
06675d3
Don't let unrecognized BISAC codes vote nonfiction
dbernstein Sep 2, 2026
78c4544
Reclassify subjects holding a fabricated nonfiction status
dbernstein Sep 2, 2026
e35ca3a
Treat a BISAC heading in the identifier field as a name
dbernstein Sep 8, 2026
99a4e92
Ask the classifier which subjects to reset
dbernstein Sep 8, 2026
2925881
Rebase migration onto the current alembic head
dbernstein Sep 8, 2026
a0e3467
Update tests/manager/core/classifiers/test_classifier.py
dbernstein Sep 14, 2026
412dcc3
Update tests/manager/core/classifiers/test_classifier.py
dbernstein Sep 14, 2026
0f14c03
Describe the migration in its docstring, not the classifier
dbernstein Sep 14, 2026
ce7e236
Share one ruleset walk between is_fiction and audience
dbernstein Sep 14, 2026
9cef011
Name the keep-known-value rule instead of describing it twice
dbernstein Sep 14, 2026
4146fdd
Parameterize the keyword-fallback test
dbernstein Sep 14, 2026
55bf453
Parameterize the recognized-codes test
dbernstein Sep 14, 2026
0be2ca9
Cover audience when classification falls back to keywords
dbernstein Sep 14, 2026
e367b9a
Decide by the heading, not the shape of the identifier
dbernstein Sep 14, 2026
5395f9b
Drop the migration; the repair moves to a startup task
dbernstein Sep 15, 2026
e9ec925
Correct two inaccurate rationales in the new comments
dbernstein Sep 16, 2026
4d1593e
Filter genres against the fiction status we are about to keep
dbernstein Sep 17, 2026
a8391b0
Make the non-BISAC nonfiction reset re-runnable (PP-5129)
dbernstein Sep 14, 2026
e3a6fcb
Re-score the reset subjects at deploy instead of overnight (PP-5129)
dbernstein Sep 14, 2026
8fdbb75
Do the reset from the startup task, not a migration
dbernstein Sep 15, 2026
dc8c7b9
Drop the stale migration reference from the task test
dbernstein Sep 15, 2026
2d90632
Restore the reset predicate's case coverage in the task test
dbernstein Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions bin/work_reset_non_bisac_nonfiction_subjects
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env python
"""Queue the reset_non_bisac_nonfiction_subjects Celery task.

Convenience wrapper that manually dispatches the repair for BISAC subjects
stored as nonfiction in error (the `reset_non_bisac_nonfiction_subjects` Celery
task) for a worker to process.
"""

from palace.manager.scripts.work import ResetNonBisacNonfictionSubjectsScript

ResetNonBisacNonfictionSubjectsScript().run()
49 changes: 49 additions & 0 deletions src/palace/manager/celery/tasks/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sqlalchemy.orm import Session

from palace.manager.celery.task import Task
from palace.manager.core.classifier.bisac import BISACClassifier
from palace.manager.data_layer.policy.presentation import PresentationCalculationPolicy
from palace.manager.service.celery.celery import QueueNames
from palace.manager.sqlalchemy.model.classification import Classification, Subject
Expand Down Expand Up @@ -39,6 +40,54 @@ def reclassify_null_audience_works(task: Task) -> None:
session.commit()


@shared_task(queue=QueueNames.default, bind=True)
def reset_non_bisac_nonfiction_subjects(task: Task) -> None:
"""Mark BISAC subjects unchecked when their stored fiction status went stale.

A code that cannot be resolved to a canonical BISAC heading used to be
read as nonfiction by the ruleset catch-all, so those subjects carry a
fabricated fiction=False. Subjects are only re-examined when checked is
false, so repairing them means resetting that flag.

This resets only. Re-scoring is classify_unchecked_subjects' job: the
startup task that runs this at deploy chains the two together, and the
nightly run picks up anything left over.

Idempotent and self-selecting -- it recomputes which subjects the
classifier no longer agrees with, so a second run finds nothing to do.
That is what lets a later release re-apply it safely.
"""
with task.session() as session:
candidates = (
session.query(Subject.id, Subject.identifier, Subject.name)
.filter(
Subject.type == Subject.BISAC,
Subject.checked == True, # noqa: E712
Subject.fiction == False, # noqa: E712
)
.all()
)

stale_ids = [
row.id
for row in candidates
if BISACClassifier.contradicts_stored_fiction(
row.identifier, row.name, False
)
]

if stale_ids:
session.query(Subject).filter(Subject.id.in_(stale_ids)).update(
{Subject.checked: False}, synchronize_session=False
)
session.commit()

task.log.info(
f"Reset checked=False for {len(stale_ids)} of {len(candidates)} "
f"BISAC subjects stored as nonfiction."
)


@shared_task(queue=QueueNames.default, bind=True)
def classify_unchecked_subjects(task: Task) -> None:
"""Reclassify all Works whose current classifications appear to
Expand Down
148 changes: 128 additions & 20 deletions src/palace/manager/core/classifier/bisac.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import csv
import re
from collections.abc import Callable, Sequence

from frozendict import frozendict

Expand Down Expand Up @@ -654,27 +655,130 @@ class BISACClassifier(Classifier):
m(classifier.Life_Strategies, nonfiction, social_topics),
]

# The top-level headings a canonical BISAC name can begin with ("Fiction",
# "Juvenile Nonfiction", "Antiques & Collectibles", ...).
TOP_LEVEL_HEADINGS: frozenset[str] = frozenset(
Lowercased(name.split("/")[0].strip()) for name in NAMES.values()
) | frozenset(
# Former top-level spellings of renamed categories, which distributors
# still send and bisac.csv no longer lists. They belong here so that a
# name beginning with one still reaches the catch-all rules: without
# the entry, a deprecated spelling carried by a code that does not
# resolve abstains instead of being read as nonfiction/Adult. That is
# the test for whether a new entry belongs -- not whether the rulesets
# have an Interchangeable for it, which is a separate mechanism in
# GENRE, and GENRE does not consult this set.
Lowercased(name)
for name in (
"Mind & Spirit",
"Psychology & Psychiatry",
"Technology",
"Foreign Language Study",
"Literary Criticism & Collections",
)
)

@classmethod
def _has_canonical_heading(cls, name: list[str]) -> bool:
"""Does `name` begin with a real BISAC top-level heading?

This is the premise the FICTION and AUDIENCE catch-all rules rest on,
so it is what has to be checked before running them. It holds for a
name that came from `NAMES`, and for a heading a distributor supplied
in the identifier field -- an OPDS `<category term="FICTION / Horror">`
with no `label` arrives that way, since the OPDS1 extractor maps `term`
to the identifier and `label` to the name. A bare top-level heading
such as "Juvenile" is equally a heading.

It does not hold for the fragment left over when a code cannot be
resolved ("Historical", "English literature"), which is the case these
rulesets must not be applied to.
"""
return bool(name) and name[0] in cls.TOP_LEVEL_HEADINGS

@classmethod
def contradicts_stored_fiction(
cls,
identifier: str | None,
name: str | None,
stored_fiction: bool | None,
) -> bool:
"""Does this classifier disagree with a subject's stored fiction status?

Subjects are only re-examined when `checked` is false, so a value
scored under superseded rules persists indefinitely. Repairs that
reset `checked` need to identify those rows, and they need to agree
with each other about which rows they are. Expressing the question
here keeps that definition in one place: a subject is stale when the
classifier, run now, does not return what is stored.

:param identifier: The subject's identifier, as stored.
:param name: The subject's name, as stored.
:param stored_fiction: The subject's current `fiction` value.
:return: True when the classifier no longer agrees with `stored_fiction`.
"""
if not identifier and not name:
# Nothing to classify. Subject.lookup will not create such a row,
# but both columns are nullable, so do not assume.
return False
scrubbed_identifier, scrubbed_name = cls.scrub_identifier_and_name(
identifier, name
)
return cls.is_fiction(scrubbed_identifier, scrubbed_name) is not stored_fiction

@classmethod
def _apply_rulesets[RulesetResult](
cls,
identifier: str | None,
name: list[str],
rulesets: Sequence[MatchingRule],
keyword_fallback: Callable[[str | None, str], RulesetResult | None],
) -> RulesetResult | None:
"""Match `name` against `rulesets`, falling back to keyword matching.

Both the FICTION and AUDIENCE rulesets end in a catch-all that reasons
from the top-level BISAC heading -- "not filed under Fiction, therefore
nonfiction", "no juvenile heading, therefore Adult". That inference
holds for a canonical BISAC name and for nothing else, so an
unrecognized code skips the rulesets entirely and is left to the keyword
classifier, which abstains when the distributor's name carries no
signal.

Only those two callers share this. `genre` must not skip its rulesets:
GENRE has no catch-all but does have rules that match a bare fragment,
so a subject named "Historical" is still a Historical Fiction signal
even though "Historical" is not a top-level heading. `target_age` is
left out for scope rather than correctness -- its rules key off a
juvenile first token, which only a real heading produces, so routing it
through here would be safe.
"""
# A subject with no identifier had no code that could fail to resolve,
# so there is nothing here to protect it from -- and some distributors
# classify entirely this way. Bibliotheca sends every genre as a bare
# name ("Action & Adventure", "Magic") with no code at all; gating
Comment on lines +658 to +758

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New code in deprecated package

This adds TOP_LEVEL_HEADINGS, _has_canonical_heading, contradicts_stored_fiction, and _apply_rulesets under palace.manager.core. The repository architecture guide marks /core as deprecated and prohibits new code there. This repository requirement must be satisfied before merging by placing the new classification behavior in a supported package.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining, and please remember this one too.

Taken literally this asks me to relocate the BISAC classifier. TOP_LEVEL_HEADINGS, _has_canonical_heading and _apply_rulesets are not a helper bolted onto core — they are the fix, and they are internals of BISACClassifier, which lives in palace/manager/core/classifier/ along with the 5,984-code table and both rulesets. Moving them somewhere else would split one class across two packages.

core/classifier/ is also the one part of core/ that is not being retired. Over the last 12 months core/ excluding the classifier is net -2,001 lines: the CoverageProvider machinery removed in 7a3274ea1, monitors in 963b651b2, facets in 17b873e4d, equivalents coverage in 49bc00071. core/classifier/ over the same period is net +131 and is the only part still receiving behaviour changes, including 7d5b8a06a and ff2432b7e this April. The deprecation label reflects a package being drained; the classifier is the part that is not.

There is nowhere else for it to go. packages/ contains only palace-opds and palace-util, neither of which has any classification code; palace/manager/service/ is dependency-injection wiring. Exactly one module outside the classifier package references BISACClassifier at all — the Celery task in this PR.

On contradicts_stored_fiction specifically, the placement was deliberate and is explained in a8391b04f: it reproduces exactly the path Classifier.classify takes, scrub_identifier_and_name then is_fiction, so the repair and the runtime cannot disagree about which rows are affected. An earlier revision of this work did approximate the predicate elsewhere, with a pattern on the identifier, and it was wrong — FBZZZ000000 is shape-valid but does not exist, and it slipped through. That is why the definition was moved onto the class that owns the answer. Note also that the migration your comment refers to no longer exists; the Celery task is the only non-test caller.

It is temporary regardless: the PP-5129 cleanup removes contradicts_stored_fiction along with the task, the script and the bin/ wrapper.

Separately, CLAUDE.md is inaccurate as written and I am fixing it, so this stops misfiring.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. palace/manager/core/classifier/ is an active exception to the broader core/ deprecation guidance, so classifier internals should remain colocated with BISACClassifier unless the repository guidance changes.

I’ve remembered this rule for future reviews:

View rule

# those on the heading would leave its titles with no fiction evidence
# whatsoever. Whether a bare sub-heading should carry the rulesets'
# top-level inference is a real question, but a separate one.
if not identifier or cls._has_canonical_heading(name):
for ruleset in rulesets:
result = ruleset.match(*name)
if result is cls.stop:
return None
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if result is not None:
return result
return keyword_fallback(identifier, "/".join(name))

@classmethod
def is_fiction(cls, identifier, name):
for ruleset in cls.FICTION:
fiction = ruleset.match(*name)
if fiction is cls.stop:
return None
if fiction is not None:
return fiction
keyword = "/".join(name)
return KeywordBasedClassifier.is_fiction(identifier, keyword)
return cls._apply_rulesets(
identifier, name, cls.FICTION, KeywordBasedClassifier.is_fiction
)

@classmethod
def audience(cls, identifier, name):
for ruleset in cls.AUDIENCE:
audience = ruleset.match(*name)
if audience is cls.stop:
return None
if audience is not None:
return audience
keyword = "/".join(name)
return KeywordBasedClassifier.audience(identifier, keyword)
return cls._apply_rulesets(
identifier, name, cls.AUDIENCE, KeywordBasedClassifier.audience
)

@classmethod
def target_age(cls, identifier, name):
Expand Down Expand Up @@ -725,10 +829,14 @@ def scrub_identifier(cls, identifier):
identifier = identifier.removeprefix("FB")
# Some distributors (e.g. Palace Marketplace) append an "N" suffix to
# standard BISAC codes (e.g. "FBJUV000000N" becomes "JUV000000N" after
# FB-stripping). Official BISAC codes always end with digits, so a
# trailing "N" is always a non-standard extension; strip it so the code
# resolves to its canonical entry.
identifier = identifier.removesuffix("N")
# FB-stripping). Strip it only when doing so produces a code we know,
# because the identifier field does not always hold a code: a heading
# can arrive there too, and stripping unconditionally would turn
# "FICTION" into "FICTIO" (likewise RELIGION, EDUCATION, DESIGN,
# TRANSPORTATION) and stop it being recognized as a heading.
stripped = identifier.removesuffix("N")
if stripped in cls.NAMES or stripped in cls.NON_STANDARD_CODE_ALIASES:
identifier = stripped
# Remap any remaining non-standard codes to their canonical equivalents.
identifier = cls.NON_STANDARD_CODE_ALIASES.get(identifier, identifier)
if identifier in cls.NAMES:
Expand Down
20 changes: 20 additions & 0 deletions src/palace/manager/scripts/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from palace.manager.celery.tasks.work import (
classify_unchecked_subjects,
reclassify_null_audience_works,
reset_non_bisac_nonfiction_subjects,
)
from palace.manager.data_layer.policy.presentation import (
PresentationCalculationPolicy,
Expand Down Expand Up @@ -252,6 +253,25 @@ class WorkOPDSScript(WorkPresentationScript):
)


class ResetNonBisacNonfictionSubjectsScript(Script):
"""Manually dispatch the ``reset_non_bisac_nonfiction_subjects`` Celery task.

The work itself happens in the Celery task; this script just queues it. It
exists so the repair can be applied again on demand, in case its reset was
consumed by old code before the new classifier was live everywhere.

TODO: Remove this script when the ``reset_non_bisac_nonfiction_subjects``
Celery task is removed.
"""

def do_run(self, *args: Any, **kwargs: Any) -> None:
reset_non_bisac_nonfiction_subjects.delay()
Comment on lines +256 to +268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New code in deprecated package

This change adds ResetNonBisacNonfictionSubjectsScript under palace.manager.scripts, even though the repository architecture guide marks /scripts as deprecated and prohibits new code there. The on-demand repair entry point must use the supported service or CLI structure before merging.

Context Used: CLAUDE.md (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining, and please do remember this one — it is the third PR in a row where it has fired.

There is no supported command framework to move this to. Concretely:

  • [project.scripts] in pyproject.toml has exactly one entry, and it resolves into the package you are asking me to leave: palace-startup-task = "palace.manager.scripts.startup:create_startup_task".
  • There is no cli/ package anywhere under src/, no click or typer application, and no palace command.
  • Of the ~74 wrappers in bin/, 50 import palace.manager.scripts directly and another 16 reach it by subclassing Script / CollectionInputScript / InputScript through the integration packages. The single exception is palace.manager.customlists, a purpose-built argparse CLI for custom-list import/export rather than a general framework.
  • scripts/base.py itself imports TimestampData from palace.manager.core.monitor, so the deprecated CLI framework is built on the deprecated core package.

The pattern is also still actively used after the deprecation label was written. That text entered CLAUDE.md on 2026-03-16 in 21df3c5d9. Since then five new Script subclasses and five new bin/ wrappers have landed, including the direct model for this one — ReclassifyNullAudienceWorksScript and bin/work_reclassify_null_audience, added 2026-06-18 in 9c626b229 for the sibling null-audience defect, and sitting immediately below this class in the same file.

The startup-task framework does not replace this. It runs once per deployment and has no on-demand path, which is the entire reason this script exists — a reviewer asked for something we could run by hand "if we needed to have a do over". The repo treats the two as complementary rather than substitutive: 9c626b229 shipped the startup task, the Script, and the bin/ wrapper in one commit, and this PR does the same.

The intent behind the deprecation is that new business logic should not grow these packages. It does not here: the logic is in reset_non_bisac_nonfiction_subjects under palace/manager/celery/tasks/, and this class is a five-line dispatcher. It is also temporary — the PP-5129 cleanup removes the task, this script, and the bin/ wrapper together.

Separately, CLAUDE.md is inaccurate as written and I am fixing it, so this stops misfiring.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood — I’ve saved this as a review rule. I won’t flag thin, temporary palace.manager.scripts dispatchers or their bin/ wrappers solely because the package is deprecated when no general replacement CLI exists and the dispatcher is the repository’s supported on-demand pattern.

View rule

self.log.info(
'The "reset_non_bisac_nonfiction_subjects" task has been queued for '
"execution. See the celery logs for details about task execution."
)


class ReclassifyNullAudienceWorksScript(Script):
"""Manually dispatch the ``reclassify_null_audience_works`` Celery task.

Expand Down
27 changes: 20 additions & 7 deletions src/palace/manager/sqlalchemy/model/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -1368,6 +1368,16 @@ def calculate_quality(self, identifier_ids, default_quality=0):
if new_quality != self.quality:
self.quality = new_quality

@staticmethod
def _keep_known_value[T](old_value: T | None, new_value: T | None) -> T | None:
"""Prefer `new_value`, but never replace a known value with None.

Handed to the classifier as its default, this leaves an existing
determination alone when a recalculation gathers no usable
classifications, rather than erasing it.
"""
return old_value if new_value is None else new_value

def assign_genres(
self,
identifier_ids,
Expand All @@ -1392,8 +1402,16 @@ def assign_genres(
for classification in classifications:
classifier.add(classification)

# Hand the retained values to the classifier as its defaults rather
# than restoring them after it has run. classify() derives the genres
# from the fiction status and the target age from the audience, so a
# value restored afterwards arrives too late to be consulted: the genre
# filter would already have run with None -- which disables it, keeping
# genres of either fiction status -- and we would then stamp the
# retained status back onto a work whose own genres now contradict it.
(genre_weights, new_fiction, new_audience, target_age) = classifier.classify(
default_fiction=default_fiction, default_audience=default_audience
default_fiction=self._keep_known_value(old_fiction, default_fiction),
default_audience=self._keep_known_value(old_audience, default_audience),
)

new_target_age = tuple_to_numericrange(target_age)
Expand All @@ -1402,12 +1420,7 @@ def assign_genres(

if new_fiction != old_fiction:
self.fiction = new_fiction
# Never let a recalculation erase a known audience. If the classifier
# came back with no audience (e.g. it gathered no usable
# classifications on this pass), keep whatever we already had rather
# than writing NULL over a previously-determined audience.
if new_audience is None and old_audience is not None:
new_audience = old_audience

if new_audience != old_audience:
self.audience = new_audience

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Repair BISAC subjects stored as nonfiction because their code did not resolve.

Everything on the Palace Marketplace / Feedbooks category scheme is stored with
``type='BISAC'``, including codes that are not BISAC at all -- language and
territory categories such as ``INFEN000`` ("English literature"). Those cannot
be resolved to a canonical heading, so classification used to infer nonfiction
from the distributor's name and store ``fiction=False``. The classifier no
longer does that, which leaves the stored values stale.

Subjects are only re-examined when ``checked`` is false, so this dispatches two
steps: ``reset_non_bisac_nonfiction_subjects`` marks the stale ones unchecked,
then ``classify_unchecked_subjects`` re-scores them and recalculates their
works. The second signature is immutable so the chain does not pass the first
task's return value into it.

Doing both here matters. The reset on its own is exposed: anything reaching
``Subject.assign_to_genre`` before the re-score consumes it, and code running
the superseded rules re-stamps ``checked=True`` with the same wrong value.
Nothing errors and nothing revisits the subject afterwards, so the repair
silently did nothing, having paid for a reindex to do it. Chaining the re-score
closes that gap to seconds rather than waiting for the nightly run.

The timing works out. ``helpers/migrate.yml`` stops the scripts container --
where every Celery worker and beat run -- before migrating, and starts it again
from the new image afterwards, so the worker that picks this up is necessarily
new code.

Web containers are the remaining exposure: the deploy recycles them after the
migration step, and they can reach ``assign_to_genre`` through a presentation
recalculation. Fargate deployments are not governed by that playbook at all. A
second startup task re-applies the reset a release later, once no old code is
running anywhere.

TODO: Remove this task once it has run on all deployments (PP-5129)."""

from __future__ import annotations

import logging

from celery.canvas import Signature, chain
from sqlalchemy.orm import Session

from palace.manager.celery.tasks.work import (
classify_unchecked_subjects,
reset_non_bisac_nonfiction_subjects,
)
from palace.manager.service.container import Services


def run(services: Services, session: Session, log: logging.Logger) -> Signature | None:
return chain(
reset_non_bisac_nonfiction_subjects.s(),
classify_unchecked_subjects.si(),
)
Comment thread
dbernstein marked this conversation as resolved.
Loading
Loading