-
Notifications
You must be signed in to change notification settings - Fork 9
Resolve suppress-work identifier lookup through equivalencies (PP-5215) #3759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
327a5dc
ebb9927
39bbbe6
cf61f87
caa336e
8e2e97d
d7e850b
11e24c0
c60db6a
04e7802
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
| from collections.abc import Sequence | ||
| from datetime import datetime, timezone | ||
| from enum import Enum, auto | ||
| from typing import cast | ||
| from typing import Any, NamedTuple, cast | ||
|
|
||
| from sqlalchemy import select | ||
| from sqlalchemy.orm import Session | ||
|
|
@@ -13,19 +13,36 @@ | |
| from palace.manager.scripts.base import Script, _normalize_cmd_args | ||
| from palace.manager.sqlalchemy.model.identifier import Identifier | ||
| from palace.manager.sqlalchemy.model.library import Library | ||
| from palace.manager.sqlalchemy.model.licensing import LicensePool | ||
| from palace.manager.sqlalchemy.model.work import Work | ||
|
|
||
|
|
||
| class SuppressResult(Enum): | ||
| NEWLY_SUPPRESSED = auto() | ||
| ALREADY_SUPPRESSED = auto() | ||
| NOT_FOUND = auto() | ||
| NOT_IN_LIBRARY = auto() | ||
| AMBIGUOUS = auto() | ||
|
|
||
|
|
||
| class SuppressOutcome(NamedTuple): | ||
| result: SuppressResult | ||
| # The title and work id of every work described by this outcome, | ||
| # formatted as "<title> (work id: <id>)" and joined with "; " when | ||
| # there's more than one -- e.g. for AMBIGUOUS, or when | ||
| # --suppress-ambiguous affects more than one work at once. | ||
| description: str | None = None | ||
|
|
||
|
|
||
| class SuppressWorkForLibraryScript(Script): | ||
| """Suppress works from a library by identifier""" | ||
|
|
||
| BY_DATABASE_ID = "Database ID" | ||
|
|
||
| def __init__(self, *args: Any, **kwargs: Any) -> None: | ||
| super().__init__(*args, **kwargs) | ||
| self._collection_ids: dict[int, list[int]] = {} | ||
|
|
||
| @classmethod | ||
| def arg_parser(cls, _db: Session) -> argparse.ArgumentParser: | ||
| parser = argparse.ArgumentParser() | ||
|
|
@@ -66,6 +83,14 @@ def arg_parser(cls, _db: Session) -> argparse.ArgumentParser: | |
| help="Report what would be suppressed without making any changes.", | ||
| action="store_true", | ||
| ) | ||
| parser.add_argument( | ||
| "--suppress-ambiguous", | ||
| help="If an identifier resolves to more than one distinct work " | ||
| "(e.g. the same title licensed through more than one of the " | ||
| "library's collections), suppress it in all of them instead of " | ||
| "skipping it.", | ||
| action="store_true", | ||
| ) | ||
| return parser | ||
|
|
||
| @classmethod | ||
|
|
@@ -142,34 +167,226 @@ def load_identifiers_from_file( | |
| raise PalaceValueError(f"CSV file not found: {file_path}") | ||
| return identifiers | ||
|
|
||
| def _library_collection_ids(self, library: Library) -> list[int]: | ||
| """The ids of the collections a library is associated with. | ||
|
|
||
| Cached for the life of the script: `Library.associated_collections` | ||
| issues a fresh SELECT on every access, and this is consulted once | ||
| per identifier, so a `--file` run would otherwise repeat the same | ||
| query for every row. | ||
|
|
||
| Collections are scoped by association rather than by whether | ||
| they're currently active: `suppressed_for` is a durable flag, not | ||
| something that should depend on where today falls in a | ||
| collection's subscription window. | ||
| """ | ||
| if library.id not in self._collection_ids: | ||
| self._collection_ids[library.id] = [ | ||
| c.id for c in library.associated_collections if c.id is not None | ||
| ] | ||
| return self._collection_ids[library.id] | ||
|
|
||
| def load_works(self, identifier: Identifier, library: Library) -> list[Work]: | ||
| """Find the Work(s) `library` carries for an identifier. | ||
|
|
||
| Only works the library actually licenses are considered, so that a | ||
| title carried by two libraries through two different collections | ||
| (e.g. one via OverDrive, one via Bibliotheca) resolves cleanly for | ||
| each of them instead of looking ambiguous to both. | ||
|
|
||
| Pools for this exact identifier in one of the library's collections | ||
| are exact matches, and win outright over equivalency. With | ||
| consistent data there is at most one Work among them, because | ||
| `LicensePool.calculate_work` forces every pool sharing an | ||
| identifier onto the same Work -- so suppressing it already covers | ||
| every collection that identifier appears in, a consortium's | ||
| OverDrive collection and a library's own Advantage collection | ||
| alike. Every matching work is nonetheless returned rather than the | ||
| first one found, so that inconsistent data (a state | ||
| `calculate_work` itself warns about and repairs, logging that the | ||
| pools have "more than one Work between them") meets the ambiguity | ||
| guard instead of being silently resolved to one of them. | ||
|
|
||
| The legitimate way one identifier reaches several works is | ||
| different vendor identifiers sharing an ISBN, and that goes | ||
| through the equivalency path below rather than this one. | ||
|
|
||
| A pool whose Work hasn't been calculated yet counts for nothing | ||
| here: suppression is a Work/Library relation, so a pool without a | ||
| Work has nothing that *can* be suppressed. Such a pool therefore | ||
| doesn't make this an exact match, and resolution falls through to | ||
| equivalency rather than refusing -- otherwise a title the library | ||
| demonstrably carries under an equivalent identifier would go | ||
| unsuppressed because some other pool of the same identifier was | ||
| mid-import. | ||
|
|
||
| Failing an exact match, we look past LicensePools whose own | ||
| identifier matches, to also include LicensePools reachable through | ||
| identifier equivalency -- e.g. an ISBN a librarian has on hand is | ||
| often linked to a vendor's LicensePool via metadata equivalency | ||
| rather than being that LicensePool's own identifier. This uses the | ||
| same strict, high-confidence equivalency policy that | ||
| `Work.from_identifiers` applies by default everywhere else in the | ||
| codebase, so it won't walk into loosely-related works. | ||
|
|
||
| Exact matches deliberately don't get combined with equivalency | ||
| matches: two pools of the same identifier are certainly the same | ||
| title, whereas an equivalency edge is only ever a vendor's | ||
| assertion, so widening an exact hit with equivalent works could | ||
| suppress an unrelated title. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes sense in a way, but we will use that equivalency if we don’t have an exact match. That means that in some cases the equivalent is okay, but in others it’s not?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In almost every case cases it will be okay and if it's not it will likely be due to bad vendor data which is relatively rare. I think there is some value to suggest the probability that a title could be accidentally suppressed greater than zero.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don’t understand what this means.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry - this is a missing "is" between "suppressed" and "zero". And it was not a very well written sentence even with the grammar corrected. Anyway I think I misunderstood your point which is that we want consistency, determinism, and transparency. I'm going to work on this a little more - perhaps it should wait until the next release because I don't want to hold things up any longer.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @tdilauro: I stepped back, did a little more investigation into the equivalency subsystem to make sure I'm not confusing myself. You're right that there's an inconsistency here: the PR justifies preferring exact matches on the grounds that equivalents are vendor assertions we trust less — and then, when there's no exact match, treats those same assertions as good enough to suppress on. The trust we place on an association ends up depending on what else happens to be in the data rather than on the association itself. I've implemented the provenance half of your suggestion. Every match now carries whether it was exact or equivalency-derived, and that shows up in the output: [SUPPRESSED] ISBN/9781433383670 -- The Great Gatsby (work id: 42, matched via equivalency) plus a "Matched via equivalency" count in the run summary, so a long --file run can't bury them in the detail lines. This doesn't change which works get suppressed. On the determinism point : as far as I can see it only two things actually fix it: never use equivalents unless asked, or always union them and let the ambiguity guard sort out disagreements. We had the second behavior earlier in this PR and pulled it, because an exact match that happened to also be equivalent to something unrelated would then refuse to act. So the flag question is really about the default. My inclination is to keep equivalents on with an --exact-only escape hatch rather than making them opt-in: staff almost always have ISBNs while pools are keyed on vendor ids, so opt-in means most runs come back NOT_FOUND and we're back to the bug that started this. But I don't feel strongly — and with --dry-run plus the new provenance you can now preview exactly which matches are inferred before committing to anything, which may cover most of what the flag would buy us. What sounds best to you?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm okay with either default. We must just be sure to document it, either way. My main concern was making sure that we all understood what is re-articulated in the in the first paragraph, and responded appropriately. |
||
| """ | ||
| collection_ids = self._library_collection_ids(library) | ||
| if not collection_ids: | ||
| return [] | ||
|
|
||
| direct_works = { | ||
| pool.work.id: pool.work | ||
| for pool in identifier.licensed_through | ||
| if pool.work is not None and pool.collection_id in collection_ids | ||
| } | ||
| if direct_works: | ||
| return [direct_works[work_id] for work_id in sorted(direct_works)] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, if we have a pool for an exact id, but the pool has no work, then we fall through and might suppress a different, equivalence-based book?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. You can't suppress a workless pool at all: suppressed_for is a Work↔Library relation, so with no Work there's nothing to attach a suppression to. That means the choice isn't "suppress the right book vs. the equivalent book" — it's "suppress the equivalent book vs. suppress nothing." Refusing would mean telling a librarian NOT FOUND for a title their library demonstrably carries (under the equivalent identifier, with a real Work), purely because some other pool of the same identifier happened to be mid-import. That's the worse outcome. The "different book" risk is real if low, but it's the generic equivalency risk that the whole fallback carries — not something specific to this path — and it's bounded three ways: the 0.999 threshold, the AMBIGUOUS guard if more than one work matches, and the output naming the title and work id so a wrong hit is visible. One residual limitation worth knowing, which nothing can fix at suppression time: when that workless pool does get its Work calculated later, the new Work won't be retroactively suppressed. Same partial-coverage hazard as the multi-collection case. I documented the fall-through in the load_works docstring and added a test pinning it, since your question showed the behavior was both unstated and untested. To address this we could add a new scheduled celery task (in a separate PR) that periodically ensures that if one title is suppressed and an equivalent title is not, the equivalent title should be suppressed.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| # The collection scope is an EXISTS rather than a filter on the | ||
| # joined pool, because the pool carrying the equivalent identifier | ||
| # and the pool in this library's collection may be different pools | ||
| # of the same Work. | ||
| query = ( | ||
| Work.from_identifiers(self._db, [identifier]) | ||
| .filter( | ||
| Work.license_pools.any(LicensePool.collection_id.in_(collection_ids)) | ||
| ) | ||
| .order_by(Work.id) | ||
| ) | ||
| return cast(list[Work], query.distinct().all()) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| @staticmethod | ||
| def _describe_works(works: list[Work]) -> str: | ||
| """Format a list of works for operator-facing output, e.g. for an | ||
| ambiguous match or when suppressing more than one work at once.""" | ||
| return "; ".join(f"{w.title or '[no title]'} (work id: {w.id})" for w in works) | ||
|
|
||
| def _nothing_to_suppress( | ||
| self, identifier: Identifier, library: Library | ||
| ) -> SuppressOutcome: | ||
| """Report why an identifier resolved to none of the library's works. | ||
|
|
||
| A title the library doesn't carry is a different problem from an | ||
| identifier that matches nothing at all -- the first means the | ||
| identifier is fine but belongs to someone else's collection, the | ||
| second usually means a typo -- so they get distinct results rather | ||
| than both landing in one "not found" bucket. | ||
| """ | ||
| elsewhere = ( | ||
| Work.from_identifiers(self._db, [identifier]).order_by(Work.id).first() | ||
| ) | ||
| if elsewhere is not None: | ||
| self.log.warning( | ||
| f"{identifier.type}/{identifier.identifier} resolves to a work " | ||
| f"that {library.short_name} does not carry in any of its collections." | ||
| ) | ||
| return SuppressOutcome( | ||
| SuppressResult.NOT_IN_LIBRARY, self._describe_works([elsewhere]) | ||
| ) | ||
|
|
||
| self.log.warning(f"No work found for {identifier}") | ||
| return SuppressOutcome(SuppressResult.NOT_FOUND) | ||
|
|
||
| def suppress_work( | ||
| self, | ||
| library: Library, | ||
| identifier: Identifier, | ||
| dry_run: bool = False, | ||
| ) -> SuppressResult: | ||
| work = identifier.work | ||
| if not work: | ||
| self.log.warning(f"No work found for {identifier}") | ||
| return SuppressResult.NOT_FOUND | ||
| suppress_ambiguous: bool = False, | ||
| ) -> SuppressOutcome: | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| """Suppress the work(s) resolved from an identifier for a library. | ||
|
|
||
| :param library: The library for which the resolved work should be suppressed. | ||
| :param identifier: The identifier used to resolve the work, either | ||
| directly (it owns a LicensePool in one of the library's | ||
| collections) or through identifier equivalency. | ||
| :param dry_run: If true, report the outcome without changing suppression. | ||
| :param suppress_ambiguous: If the identifier resolves to more than one | ||
| distinct work, suppress it for the library in every candidate | ||
| work instead of refusing to guess which one is meant. | ||
| :return: The result of the suppression attempt, and a description of | ||
| the work(s) it applies to, when any were resolved. | ||
| """ | ||
| works = self.load_works(identifier, library) | ||
| if not works: | ||
| return self._nothing_to_suppress(identifier, library) | ||
|
|
||
| if len(works) == 1: | ||
| work = works[0] | ||
| description = self._describe_works(works) | ||
|
|
||
| if library in work.suppressed_for: | ||
| return SuppressOutcome(SuppressResult.ALREADY_SUPPRESSED, description) | ||
|
|
||
| if not dry_run: | ||
| # Suppression is scoped to exactly this one library. Resolving | ||
| # the work via identifier equivalency only changes *which work* | ||
| # we find -- it never changes *which libraries* it's suppressed | ||
| # for, since only the `library` argument passed in is ever | ||
| # appended to `suppressed_for`. | ||
| work.suppressed_for.append(library) | ||
|
|
||
| self.log.info( | ||
| f"{'[DRY RUN] Would suppress' if dry_run else 'Suppressing'} " | ||
| f"{identifier.type}/{identifier.identifier} (work id: {work.id}) " | ||
| f"for {library.short_name}." | ||
| ) | ||
| return SuppressOutcome(SuppressResult.NEWLY_SUPPRESSED, description) | ||
|
|
||
| # This identifier resolves to more than one of the library's works | ||
| # (see load_works) -- usually different vendor identifiers sharing | ||
| # an ISBN, each with its own Work. | ||
| if all(library in work.suppressed_for for work in works): | ||
| # Every candidate already reflects the desired state, so there's | ||
| # nothing to decide or change -- this isn't really ambiguous in | ||
| # practice, just a no-op re-run (e.g. after an earlier | ||
| # --suppress-ambiguous run already covered every candidate). | ||
| return SuppressOutcome( | ||
| SuppressResult.ALREADY_SUPPRESSED, self._describe_works(works) | ||
| ) | ||
|
|
||
| if library in work.suppressed_for: | ||
| return SuppressResult.ALREADY_SUPPRESSED | ||
| if not suppress_ambiguous: | ||
| # Without --suppress-ambiguous, refuse to guess which one(s) the | ||
| # operator meant. | ||
| self.log.warning( | ||
| f"{identifier.type}/{identifier.identifier} resolves to " | ||
| f"{len(works)} different works for {library.short_name}; " | ||
| "skipping rather than guessing which one to suppress. Pass " | ||
| "--suppress-ambiguous to suppress all of them for this library." | ||
| ) | ||
| return SuppressOutcome( | ||
| SuppressResult.AMBIGUOUS, self._describe_works(works) | ||
| ) | ||
|
|
||
| if not dry_run: | ||
| work.suppressed_for.append(library) | ||
| # At least one candidate isn't yet suppressed (the all-suppressed | ||
| # case was handled above), so this always changes at least one work. | ||
| changed_works: list[Work] = [] | ||
| for work in works: | ||
| if library in work.suppressed_for: | ||
| continue | ||
| if not dry_run: | ||
| work.suppressed_for.append(library) | ||
| changed_works.append(work) | ||
|
|
||
| self.log.info( | ||
| f"{'[DRY RUN] Would suppress' if dry_run else 'Suppressing'} " | ||
| f"{identifier.type}/{identifier.identifier} (work id: {work.id}) " | ||
| f"for {library.short_name}." | ||
| f"{identifier.type}/{identifier.identifier} in {len(changed_works)} " | ||
| f"of {len(works)} ambiguous work(s) for {library.short_name}." | ||
| ) | ||
| return SuppressOutcome( | ||
| SuppressResult.NEWLY_SUPPRESSED, self._describe_works(changed_works) | ||
| ) | ||
| return SuppressResult.NEWLY_SUPPRESSED | ||
|
|
||
| def do_run(self, cmd_args: list[str] | None = None) -> None: | ||
| parsed = self.parse_command_line(self._db, cmd_args=cmd_args) | ||
| library = self.load_library(parsed.library) | ||
| dry_run: bool = parsed.dry_run | ||
| suppress_ambiguous: bool = parsed.suppress_ambiguous | ||
| started_at = datetime.now(tz=timezone.utc) | ||
|
|
||
| if parsed.file: | ||
|
|
@@ -189,15 +406,20 @@ def do_run(self, cmd_args: list[str] | None = None) -> None: | |
| ) | ||
| pairs = unique_pairs | ||
|
|
||
| results: dict[tuple[str, str], SuppressResult] = {} | ||
| results: dict[tuple[str, str], SuppressOutcome] = {} | ||
| try: | ||
| for id_type, id_value in pairs: | ||
| try: | ||
| identifier = self.load_identifier(id_type, id_value) | ||
| result = self.suppress_work(library, identifier, dry_run=dry_run) | ||
| outcome = self.suppress_work( | ||
| library, | ||
| identifier, | ||
| dry_run=dry_run, | ||
| suppress_ambiguous=suppress_ambiguous, | ||
| ) | ||
| except PalaceValueError: | ||
| result = SuppressResult.NOT_FOUND | ||
| results[(id_type, id_value)] = result | ||
| outcome = SuppressOutcome(SuppressResult.NOT_FOUND) | ||
| results[(id_type, id_value)] = outcome | ||
|
|
||
| if not dry_run: | ||
| self._db.commit() | ||
|
|
@@ -212,19 +434,29 @@ def do_run(self, cmd_args: list[str] | None = None) -> None: | |
|
|
||
| def _print_results( | ||
| self, | ||
| results: dict[tuple[str, str], SuppressResult], | ||
| results: dict[tuple[str, str], SuppressOutcome], | ||
| dry_run: bool, | ||
| library: Library, | ||
| started_at: datetime, | ||
| duration_seconds: float, | ||
| ) -> None: | ||
| newly_suppressed = [ | ||
| k for k, v in results.items() if v == SuppressResult.NEWLY_SUPPRESSED | ||
| k for k, v in results.items() if v.result == SuppressResult.NEWLY_SUPPRESSED | ||
| ] | ||
| already_suppressed = [ | ||
| k for k, v in results.items() if v == SuppressResult.ALREADY_SUPPRESSED | ||
| k | ||
| for k, v in results.items() | ||
| if v.result == SuppressResult.ALREADY_SUPPRESSED | ||
| ] | ||
| not_found = [ | ||
| k for k, v in results.items() if v.result == SuppressResult.NOT_FOUND | ||
| ] | ||
| not_in_library = [ | ||
| k for k, v in results.items() if v.result == SuppressResult.NOT_IN_LIBRARY | ||
| ] | ||
| ambiguous = [ | ||
| k for k, v in results.items() if v.result == SuppressResult.AMBIGUOUS | ||
| ] | ||
| not_found = [k for k, v in results.items() if v == SuppressResult.NOT_FOUND] | ||
|
|
||
| prefix = "[DRY RUN] " if dry_run else "" | ||
| suppress_label = "Would suppress" if dry_run else "Newly suppressed" | ||
|
|
@@ -236,6 +468,8 @@ def _print_results( | |
| (suppress_label + ":", len(newly_suppressed)), | ||
| ("Already suppressed:", len(already_suppressed)), | ||
| ("Not found:", len(not_found)), | ||
| ("Not in this library:", len(not_in_library)), | ||
| ("Ambiguous:", len(ambiguous)), | ||
| ] | ||
| col = max(len(label) for label, _ in summary_rows) | ||
| print(f"\n{prefix}Suppression Results Summary:") | ||
|
|
@@ -249,7 +483,10 @@ def _print_results( | |
| ), | ||
| SuppressResult.ALREADY_SUPPRESSED: "ALREADY SUPPRESSED", | ||
| SuppressResult.NOT_FOUND: "NOT FOUND", | ||
| SuppressResult.NOT_IN_LIBRARY: "NOT IN THIS LIBRARY", | ||
| SuppressResult.AMBIGUOUS: "AMBIGUOUS", | ||
| } | ||
| for (id_type, id_value), result in results.items(): | ||
| status = status_map[result] | ||
| print(f" [{status}] {id_type}/{id_value}") | ||
| for (id_type, id_value), outcome in results.items(): | ||
| status = status_map[outcome.result] | ||
| suffix = f" -- {outcome.description}" if outcome.description else "" | ||
| print(f" [{status}] {id_type}/{id_value}{suffix}") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don’t see a test verifying that a lower-confidence equivalency wouldn’t be used. It would be good to have something that proves this out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every equivalency test used strength=1. Added a parametrized pair: 1 resolves, 0.85 doesn't. I picked 0.85 deliberately rather than an arbitrary low number — it's the strength the importer itself assigns when it links identifiers purely because their editions share a permanent work id, so those edges are all over production data. The pair is self-verifying: identical setup, only strength differs, so the passing high-confidence case proves the low-confidence NOT_FOUND comes from the threshold and not a broken fixture.