Skip to content

P5-search-show: add aggregate search and show commands#22

Open
narugo1992 wants to merge 9 commits into
mainfrom
dev/issue-19-search-show
Open

P5-search-show: add aggregate search and show commands#22
narugo1992 wants to merge 9 commits into
mainfrom
dev/issue-19-search-show

Conversation

@narugo1992
Copy link
Copy Markdown
Contributor

@narugo1992 narugo1992 commented May 11, 2026

Summary

This PR adds the Phase 5 aggregate entity surface: animedex search <type> <q> and animedex show <type> <prefix:id>. The implementation introduces the shared aggregate substrate under animedex/agg/: a backend fan-out helper, prefix-id parser, type route table, Python API modules, and a shared AggregateResult envelope. search keeps backend-rich rows lossless and annotates every row with _source plus _prefix_id; show routes a prefixed ID back to the owning backend and rejects invalid type/backend pairs before any HTTP call.

Closes #19. Refs #1 Phase 5. Coordinates with #18 through the shared _fanout.py and AggregateResult substrate.

Demo

search_show.gif

The GIF was rendered from docs/source/_static/gifs/search_show.tape after prewarming the local cache from committed fixtures with tools/fixtures/prewarm_aggregate_cache.py, so the demo does not depend on live upstream availability and does not include normal-example error output.

Examples

animedex search anime Frieren --limit 2 --source jikan,kitsu,shikimori --jq '.items[] | {_prefix_id, _source}'
# => {"_prefix_id":"mal:63816","_source":"jikan"}
# => {"_prefix_id":"mal:52991","_source":"jikan"}
# => {"_prefix_id":"kitsu:46474","_source":"kitsu"}
# => {"_prefix_id":"kitsu:49240","_source":"kitsu"}
# => {"_prefix_id":"shikimori:52991","_source":"shikimori"}
# => {"_prefix_id":"shikimori:56885","_source":"shikimori"}
animedex search manga Berserk --limit 2 --source mangadex,shikimori --jq '.items[] | {_prefix_id, _source}'
# => {"_prefix_id":"mangadex:801513ba-a712-498c-8f57-cae55b38cc92","_source":"mangadex"}
# => {"_prefix_id":"mangadex:30196491-8fc2-4961-8886-a58f898b1b3e","_source":"mangadex"}
# => {"_prefix_id":"shikimori:2","_source":"shikimori"}
# => {"_prefix_id":"shikimori:92299","_source":"shikimori"}
animedex search publisher Kodansha --limit 2 --json --jq '{sources: .sources, ids: [.items[]._prefix_id]}'
# => {"sources":{"shikimori":{"status":"ok","items":1,"reason":null,"message":null,"http_status":null,"duration_ms":0}},"ids":["shikimori:456"]}
animedex show anime mal:52991 --jq '{title, score, status}'
# => {"title":"Sousou no Frieren","score":9.31,"status":"Finished Airing"}

animedex show manga mangadex:801513ba-a712-498c-8f57-cae55b38cc92 --jq '{id, title: (.attributes.title.en // .attributes.title["ja-ro"]), status: .attributes.status}'
# => {"id":"801513ba-a712-498c-8f57-cae55b38cc92","title":"Berserk","status":"ongoing"}

animedex show character shikimori:184947 --jq '{id, name}'
# => {"id":184947,"name":"Frieren"}

Failure-Mode Example

This block is intentionally a failure-mode example. The ann row uses the synthetic test/fixtures/ann/substring_search/17-synthetic-503.yaml fixture; healthy sources still return rows, stdout remains a valid aggregate envelope, stderr reports the failed source, and the command exits 0 because at least one source succeeded.

animedex search anime Frieren --source ann,jikan,kitsu,shikimori --limit 2 --json --jq '{items: [.items[]._source], sources: .sources}'
# stderr => source 'ann' failed: upstream-error: ann 503 on /api.xml; continuing with other sources
# => {"items":["jikan","jikan","kitsu","kitsu","shikimori","shikimori"],"sources":{"ann":{"status":"failed","items":0,"reason":"upstream-error","message":"upstream-error: ann 503 on /api.xml","http_status":503,"duration_ms":0},"jikan":{"status":"ok","items":2,"reason":null,"message":null,"http_status":null,"duration_ms":0},"kitsu":{"status":"ok","items":2,"reason":null,"message":null,"http_status":null,"duration_ms":0},"shikimori":{"status":"ok","items":2,"reason":null,"message":null,"http_status":null,"duration_ms":0}}}

Source Matrix

Type Default sources
anime AniList, Anime News Network, Jikan, Kitsu, Shikimori
manga AniList, Jikan, Kitsu, MangaDex, Shikimori
character AniList, Jikan, Kitsu, Shikimori
person AniList, Jikan, Kitsu, Shikimori
studio AniList, Jikan, Kitsu, Shikimori
publisher Shikimori

Fixture Notes

Most new live fixtures were captured on 2026-05-11 UTC; the reused MangaDex Berserk fixture was captured on 2026-05-07 UTC. The PR includes real upstream fixtures for the positive routes and clearly named synthetic fixtures for failure-path coverage.

Availability observations from fixture capture are intentionally documented instead of hidden: AniList typed Frieren anime search currently returned an empty media list, AniList typed Berserk manga search also yielded zero aggregate demo rows, and Kitsu people search for Miyazaki returned upstream 400 because that free-text filter is not accepted. The working examples therefore use positive captured rows from the other available sources while the aggregate layer still reports per-source failures or empty results honestly.

Verification

  • Done: make format
  • Done: make format-check
  • Done: python -m animedex.policy.lint animedex/
  • Done: python -m animedex --help
  • Done: python -m animedex search --help
  • Done: python -m animedex show --help
  • Done: python -m animedex selftest (111 passed, 0 failed)
  • Done: targeted aggregate/entry/render tests (70 passed)
  • Done: make test (2821 passed, 84 skipped, total coverage 99%)
  • Done: make rst_auto
  • Done: make docs (warning-free Sphinx build)
  • Done: make build && make test_cli (4 passed, 0 failed)
  • Done: git diff --check

Abstraction Proposal

Implementation follows the aggregate proposal in #19: #19 (comment). The shared-substrate constraints were also posted on #18 to avoid duplicate fan-out/envelope implementations: #18 (comment).

@codecov
Copy link
Copy Markdown

codecov Bot commented May 11, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6a15de2) to head (b12d583).

Additional details and impacted files
@@            Coverage Diff             @@
##              main       #22    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          112       118     +6     
  Lines         8817      9235   +418     
==========================================
+ Hits          8817      9235   +418     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@narugo1992
Copy link
Copy Markdown
Contributor Author

Heads-up: PR #21 (P5-calendar) has merged to main — please git merge main before continuing

This PR's baseline is from 2026-05-11; PR #21 merged to main on 2026-05-12 and carried the shared aggregate substrate. Several files in this PR's working tree are pre-PR-#21 versions of work that main now has in a more complete shape. Continuing to develop on the current base would mean reconciling the divergence at review time instead of at code-time, which is the harder direction.

Concrete guidance for the merge follows. See also the scope-clarification comment on issue #19 for the design context (this round ships search as annotate-only; the cross-source merge that season already performs lands in a follow-up slice on the same substrate).

How to merge main

git fetch origin main
git checkout dev/issue-19-search-show
git merge origin/main

Then resolve the conflicts per the table below. Prefer main's version for every shared substrate file — they are PR #21's fully-reviewed substrate (four review rounds; B1/B2/B3 blocking fixes plus seven non-blocking polish items all landed) and replacing them with this PR's parallel pre-#21 implementation would regress real work.

File-by-file conflict resolution

File What to do Why
animedex/agg/_fanout.py Accept main verbatim (PR #21's 186-line hardened version). Discard the 160-line version on this branch. main has the _HTTP_STATUS_RE regex tightening (status-introducing token required), the _normalise_items dict-envelope + raise-on-unknown-shape changes, and the ApiError import. None of those are in this branch's version.
animedex/models/aggregate.py Accept main verbatim (239-line version with MergedAnime, merge_diagnostics, id_conflicts). This branch's version (140 lines) predates MergedAnime, merge_diagnostics, and id_conflicts. The new search/show commands consume AggregateResult and AggregateSourceStatus, both of which exist in main's version unchanged.
animedex/entry/aggregate.py Accept main (new file on main, absent on this branch). Carries _report_failures / _report_merge_diagnostics / _finish / _emit. The new search/show CLI entries should register through this shared helper rather than ship parallel implementations in entry/search.py and entry/show.py — see "Post-merge integration" below.
animedex/render/tty.py Accept main as the base, then re-apply any Search / Show / merge-result render helpers this branch added (around 500 lines of diff between main and PR head). main has the schedule timeline render and the stream parameter; this branch has whatever render helpers search / show need. Both sides have to coexist.
animedex/diag/selftest.py Union the two _SELFTEST_TARGETS lists. main registers animedex.agg.calendar, animedex.agg._fanout, animedex.utils.timezone, plus the runtime-dep smoke targets (anyascii, unidecode, tzdata, python-dateutil, jaconv). This branch will need to add animedex.agg.search, animedex.agg.show, animedex.agg._prefix_id, animedex.agg._type_routes, and the matching entry-module targets.
requirements.txt Accept main (12-line version with the 5 new deps). main has anyascii, jaconv, unidecode, tzdata, python-dateutil. This branch's reverting of them is purely a baseline artefact, not a deliberate choice.
animedex/utils/timezone.py Accept main (new file). Used by the schedule path on main; harmless for search/show but kept for the substrate's shared posture.
tools/generate_spec.py Accept main (unidecode HIDDEN_IMPORTS + PACKAGE_DATAS additions). This branch doesn't touch the spec generator; no real conflict.
docs/source/api_doc/agg/_fanout.rst Accept main (covers PR #21's helper functions). Auto-generated; regen with make rst_auto after the merge if anything looks off.
docs/source/api_doc/models/aggregate.rst Accept main (covers MergedAnime / merge_diagnostics). Same as above.
AGENTS.md This branch's "clarify data language policy" commit needs to land cleanly on top of main's unchanged AGENTS.md. Should be a clean three-way merge with no manual reconcile.

Post-merge integration (after conflicts resolve)

These are not git conflicts but they are integration tasks that have to happen before this PR is review-ready:

  1. Move entry/search.py + entry/show.py contents into entry/aggregate.py (or have them import the shared helpers). Currently the new CLI entries duplicate the partial-failure inform logic that already exists on main at entry/aggregate.py:_report_failures and _report_merge_diagnostics. The same _finish / _emit helpers should serve all four aggregate commands (season / schedule / search / show).
  2. Thread the stream parameter through search / show render paths: render_tty(result, stream=sys.stdout) per animedex/entry/_cli_factory.py:80 so the Unicode/ASCII fallback inherits correctly.
  3. Drop any _BACKEND_POLICY entry for search / show if one was added — per issue P5-search-show: animedex search + animedex show (entity infrastructure + multi-source flagship) #19 spec, these are top-level aggregate commands, not pseudo-backends.
  4. Update this PR's body to reflect the post-merge state: drop any references to "PR P5-search-show: add aggregate search and show commands #22's own _fanout.py" / "PR P5-search-show: add aggregate search and show commands #22's own AggregateResult"; note that this round is annotate-only and cross-source merging on search is deferred to a follow-up slice (per the issue P5-search-show: animedex search + animedex show (entity infrastructure + multi-source flagship) #19 scope-clarification comment).

Verification checklist (run after the merge resolves)

Before re-requesting review:

  • git merge origin/main completed; no <<<<<<< markers anywhere in the tree.
  • git diff main..HEAD -- animedex/agg/_fanout.py animedex/models/aggregate.py animedex/utils/timezone.py animedex/entry/aggregate.py requirements.txt tools/generate_spec.py returns empty — those are main's files verbatim.
  • git diff main..HEAD -- animedex/agg/search.py animedex/agg/show.py animedex/agg/_prefix_id.py animedex/agg/_type_routes.py shows the new search/show substrate.
  • entry/search.py and entry/show.py either don't exist (logic moved to entry/aggregate.py) or are thin wrappers that import from entry/aggregate.py.
  • _SELFTEST_TARGETS in animedex/diag/selftest.py contains both main's calendar entries (animedex.agg.calendar, animedex.utils.timezone, etc.) and the new search/show entries.
  • make format clean.
  • make test green. Specifically, the test/agg/test_calendar.py test suite still passes against the merged substrate (sanity check that the merge didn't break PR Add calendar aggregate commands #21's regressions), and the new test/agg/test_search.py / test/agg/test_show.py (or wherever they land) pass.
  • make rst_auto regenerates docs/source/api_doc/ cleanly; commit the diff.
  • make build && make test_cli passes — frozen binary still smokes the calendar substrate plus the new commands.
  • python -m animedex.policy.lint animedex/ green.
  • grep -rE 'Phase [0-9]|AGENTS[. ]§|Reviewer review|Refs #' animedex/ tools/ returns zero.
  • CLI dual-path test: animedex search anime "frieren" --json and the TTY default both work; same for animedex show anime anilist:154587.
  • Partial-failure: with a synthetic AniList-429 fixture, animedex search anime "x" returns the healthy backends' rows, exits 0, and emits the same _report_failures stderr line shape that season already does.
  • Backward-compat check: animedex season 2024 spring --json still produces the merged MergedAnime envelope shape from PR Add calendar aggregate commands #21 (the merge should not have regressed the calendar substrate).

What this PR does not do (deferred to follow-up)

Once the merge is clean and the checklist is green, please re-request review. If any of the conflicts above are non-obvious, post the specific file and the conflict shape on this PR before resolving — happy to walk through individual files rather than have you guess.

@narugo1992
Copy link
Copy Markdown
Contributor Author

Merge update summary:

I merged current origin/main (6a15de2, PR #21) into this branch with a normal merge commit (dba655d) rather than rebasing, so the PR #21 calendar aggregate history stays intact and the PR #22 search/show work remains on top of it. After the merge, I added one follow-up test-only commit (b12d583) to cover aggregate renderer edge cases and restore patch coverage.

What I kept from PR #21/main:

  • The calendar/season aggregate substrate stays as the main version for the shared foundation: animedex/agg/_fanout.py, animedex/models/aggregate.py, animedex/entry/aggregate.py, animedex/utils/timezone.py, requirements.txt, and tools/generate_spec.py were kept aligned with origin/main.
  • The PR Add calendar aggregate commands #21 calendar output models, timezone handling, season merge fixtures, generated aggregate docs, tutorial/demo assets, and release packaging updates were preserved.
  • I intentionally kept the PR Add calendar aggregate commands #21 stricter fanout HTTP-status handling, including the requirement that HTTP status detection has explicit status context.

What I kept from PR #22/search-show:

  • The aggregate search/show modules and CLI registration stayed in place alongside the PR Add calendar aggregate commands #21 season and schedule commands.
  • The combined exports now include search, show, season, schedule, FanoutSource, and run_fanout; the top-level CLI help/status path registers all aggregate commands together.
  • The compact aggregate TTY renderer for search/show-style rows was kept and integrated with the richer PR Add calendar aggregate commands #21 renderers for ScheduleCalendarResult and MergedAnime.
  • JSON source attribution now keeps source order, deduplicates consulted backends, and excludes failed aggregate sources from _meta.sources_consulted while still preserving failed source details in the payload.
  • The aggregate cache prewarm helper now includes both the PR P5-search-show: add aggregate search and show commands #22 search/show fixtures and the PR Add calendar aggregate commands #21 calendar/season fixtures.

Conflict handling:

Verification completed:

  • python -m animedex --help, python -m animedex search --help, python -m animedex show --help, python -m animedex season --help, and python -m animedex schedule --help all rendered successfully.
  • python -m animedex.policy.lint animedex/ passed.
  • make format passed.
  • make test passed locally after the merge: 2900 passed, 84 skipped, total coverage 100%.
  • make rst_auto passed with no remaining generated-doc diff.
  • make build && make test_cli passed locally.
  • PR P5-search-show: add aggregate search and show commands #22 GitHub Actions are green across format/lint, source distribution packaging, PyInstaller stage 1 on macOS/Ubuntu/Windows, clean-environment smoke tests on macOS/Ubuntu/Windows, and the full code-test matrix for Python 3.9-3.13 on macOS/Ubuntu/Windows.

@narugo1992
Copy link
Copy Markdown
Contributor Author

Search merge-readiness inventory based on the current PR state, the aggregate search route table, backend model fields, and captured fixtures. This is research-only; no code changes were made for this inventory.

Important framing: mergeability should not be limited to shared IDs. Future search merge work should support calibrated non-ID matching against an adjudicated groundtruth, while still preserving source attribution and partial-source behavior when one upstream is unavailable or rate-limited.

Type Current search sources Merge-ready sources Useful evidence Main risks
anime AniList, ANN, Jikan, Kitsu, Shikimori AniList + Jikan + Shikimori + Kitsu are the strongest merge set; ANN can participate as a weak candidate source MAL ID, AniList ID, Kitsu mappings, title variants, native title, synonyms, year, season, format, episode count, start date ANN has no cross-site ID; Kitsu search does not currently include mappings by default; Shikimori search fixtures do not consistently expose myanimelist_id even though the model supports it
manga AniList, Jikan, Kitsu, MangaDex, Shikimori AniList + Jikan + Shikimori + MangaDex are the strongest merge set; Kitsu is currently a weak candidate source MAL ID, AniList ID, MangaDex attributes.links values such as mal and al, title variants, publication year, format, status MangaDex search can return noisy derivative/side entries for broad titles; Kitsu manga currently has no mapping helper; JikanManga has raw MAL IDs but no common projection yet
character AniList, Jikan, Kitsu, Shikimori All four can be considered for non-ID matching, but this should be conservative Normalized names, native names, aliases/nicknames, portrait URL, gender, popularity/favourites, description terms Highest false-merge risk because search results often lack franchise/work context and many characters share names or aliases
person AniList, Jikan, Kitsu, Shikimori AniList + Jikan + Shikimori are useful; Kitsu has thinner fields but can still contribute Normalized names, native/Japanese names, alternate names, birthday, occupations/job title, portrait URL, bio/description terms Real-person name collisions are common; Kitsu person search has sparse data; missing birthdays should downgrade confidence
studio AniList, Jikan, Kitsu, Shikimori All four are usable for non-ID matching Normalized studio/producer names, Kitsu slug, animation-studio flags, Shikimori real, producer/studio labels Jikan producers are broader than animation studios and can include licensors, production committees, or companies that should not always collapse with studio records
publisher Shikimori No cross-source merge is possible with the current route set Only Shikimori publishers are queried today This needs another publisher-like source before merge work is meaningful

Recommended priority for future implementation:

  1. Start with anime, because it has both strong external-ID evidence and rich non-ID context. Strong ID merge should cover AniList/Jikan/Shikimori/Kitsu when mappings or MAL IDs are available; calibrated title/context matching can cover rows without IDs.
  2. Add manga next, with special handling for MangaDex links and noisy search results. MangaDex should not be collapsed on title alone unless the non-ID rule is backed by groundtruth.
  3. Add studio as the first mostly non-ID entity type. Name normalization plus slug/prefix handling should give useful results, but producer-vs-studio semantics need explicit safeguards.
  4. Add person and character only with groundtruth-driven thresholds. They can be merged by non-ID evidence, but should not silently collapse on name equality alone.
  5. Leave publisher unmerged until another source is added.

Implementation-shape notes for later:

  • Keep identity merge separate from candidate grouping: strong/groundtruth-proven matches can collapse into one item, while lower-confidence matches should remain visible as candidates or diagnostics.
  • Never drop source attribution. A merged item should retain per-source records, source details, IDs, conflicts, and source status information.
  • Partial source failure must continue to return healthy rows. Merge should run over whatever sources succeeded and should not require every source to be available.
  • If a row cannot be safely matched, keep it as its own result rather than forcing a merge. Wrong merges are worse than duplicate search rows.

@narugo1992
Copy link
Copy Markdown
Contributor Author

narugo1992 commented May 12, 2026

CJK+E search-friendliness follow-up inventory and proposed expansion plan. This is research/planning only; no code changes were made for this comment.

The current search backends are not equally friendly to CJK+English users. The observed issue is not simply that one upstream does or does not accept CJK input. Some upstreams return empty results for CJK queries, while others return non-empty but unrelated results, which is more dangerous for aggregate search.

Observed behavior from small live CLI samples:

Source CJK+E friendliness Observed issues Suggested role in enhanced aggregate search
AniList Medium to good Japanese titles and names often work; Chinese aliases and some Korean inputs are inconsistent Strong validation source for anime/manga/person when title/name evidence matches
Jikan/MAL Medium for titles, weak for person/character CJK Anime/manga Japanese titles can work; continuous Japanese person names such as 能登麻美子 fail while spaced/romanized variants work; Chinese aliases can return unrelated rows Keep backend behavior raw, but aggregate search should generate CJK name spacing and romanized fallback variants before judging Jikan as empty
Kitsu Medium for anime/manga, weak for character/person Anime/manga Japanese titles often work; character CJK can return unrelated rows; current person search hits an upstream filter[name] rejection and should be treated as a source/implementation issue rather than language weakness Use cautiously in aggregate scoring; do not treat non-empty character results as high-confidence without title/name validation
Shikimori Good for English/Japanese titles, weak for Chinese aliases Some CJK queries return unrelated rows rather than empty results; publisher search is effectively English-name oriented Useful supplemental source, but aggregate search must validate candidate names/titles before promoting rows
MangaDex Strongest current CJK manga source Manga title queries in Japanese, Chinese, and Korean performed well in samples Give higher manga-search confidence when MangaDex title/alt-title evidence matches the original or expanded query
ANN English-oriented CJK queries mostly return empty results Use as English/romanized fallback only; do not rely on ANN for CJK primary search

Concrete sample notes:

  • animedex jikan person-search '能登麻美子' returned no rows, while 能登 麻美子, Noto Mamiko, and Mamiko Noto returned the expected person.
  • 宮崎駿 against Jikan returned an unrelated first row, while 宮崎 駿, Miyazaki Hayao, and Hayao Miyazaki returned Hayao Miyazaki.
  • Character CJK searches are especially risky: Kitsu and Shikimori can return unrelated non-empty rows for inputs such as 旗木カカシ or Chinese aliases.
  • MangaDex was the strongest manga source for CJK title aliases, including Japanese, Chinese, and Korean examples.

Follow-up validation with manually simulated aggregate query variants confirmed that the expansion strategy is useful, but only when it is source- and type-specific:

Scenario Raw query behavior Enhanced variant that recovered the target Result
Jikan person 能登麻美子 Empty 能登 麻美子, Noto Mamiko, Mamiko Noto Mamiko Noto top-1
Jikan person 宮崎駿 Unrelated first row 宮崎 駿, Miyazaki Hayao, Hayao Miyazaki Hayao Miyazaki top-1
Jikan character 旗木カカシ Empty はたけカカシ, Hatake Kakashi, Kakashi Hatake Kakashi Hatake top-1
Jikan anime 芙莉莲 Unrelated Chinese-title row 葬送のフリーレン, Sousou no Frieren Sousou no Frieren top-1
ANN anime 葬送のフリーレン Empty Frieren Frieren rows returned
ANN anime 進撃の巨人 Empty Attack on Titan Rows returned, but spin-offs need ranking
MangaDex manga 烙印勇士, 海贼王, 원피스 Directly useful Original CJK query Main manga top-1 in tested samples
Shikimori character 旗木カカシ Unrelated first row はたけカカシ, Hatake Kakashi Kakashi Hatake top-1
Kitsu character 旗木カカシ Unrelated first row Hatake Kakashi, Kakashi Hatake Kakashi Hatake top-1
AniList/Shikimori studio スタジオジブリ Empty Studio Ghibli Studio top-1
Shikimori publisher 講談社 / 讲谈社 Empty Kodansha Related Kodansha row returned; needs exact-name ranking

The follow-up tests also clarified an important limitation: generic transliteration helpers already available in the project (jaconv, anyascii, and unidecode) can help with normalization, kana width, and ASCII variants, but they cannot infer translation aliases such as 芙莉莲 -> 葬送のフリーレン -> Frieren. Those aliases must come from adjudicated groundtruth, captured fixtures, source-derived alias corpora, or a small explicit alias map. The aggregate search enhancement should be honest about that boundary.

Design boundary for implementation:

  1. Keep each backend search API raw and source-faithful. animedex jikan person-search, animedex kitsu character-search, animedex mangadex search, etc. should continue to expose the upstream endpoint behavior directly. They should not silently rewrite user queries, inject aliases, romanize names, or hide upstream quirks.
  2. Put CJK+E enhancement in the top-level aggregate animedex search path. That command is already the cross-source user-facing search surface, so it is the right place to plan alternate queries, evaluate candidate quality, deduplicate/merge later, and report source-specific availability honestly.

Proposed aggregate search expansion shape:

  1. Detect query script/features at the aggregate layer: Latin, kana, kanji/han, hangul, mixed CJK+Latin, whitespace, punctuation, and likely person-name patterns.
  2. Generate type-aware query variants without changing backend APIs. Examples: original query; CJK person-name spacing variants; kana/kanji-preserving variants; romanized order variants such as Noto Mamiko and Mamiko Noto; known title alias variants when already available from reliable source data or fixture-backed alias maps.
  3. Fan out variants per source with tight caps and dedupe request keys. Respect existing rate-limit/caching behavior and keep partial-source failure semantics.
  4. Score returned rows against both the original query and the generated variant that produced them. Non-empty results are not automatically good results. Character/person/studio/publisher rows need stricter validation than anime/manga rows.
  5. Preserve provenance. Candidate rows should carry the backend, native row ID, matched query variant, match evidence, and confidence/diagnostic metadata. Merged rows later must retain per-source records and conflicts.
  6. Keep unsafe or weak matches visible but not over-promoted. If a source returns unrelated rows for a CJK query, aggregate search should be able to keep them low-confidence or report diagnostics instead of pretending the source succeeded semantically.
  7. Use adjudicated groundtruth before hardening thresholds. The rule set should be calibrated against Codex-exec-provided expected matches, especially for CJK title aliases, person names, and character names.

A compact implementation sketch for the aggregate-only layer:

@dataclass(frozen=True)
class SearchVariant:
    query: str
    reason: str
    confidence_hint: int


def plan_variants(entity_type: str, source: str, q: str, aliases: AliasLookup) -> list[SearchVariant]:
    variants = [SearchVariant(q, "original", 100)]
    if entity_type in {"person", "character"} and looks_like_cjk_name(q):
        variants.extend(space_cjk_name_variants(q))
        variants.extend(aliases.romanized_name_variants(q))
    if entity_type in {"anime", "manga", "studio", "publisher"}:
        variants.extend(aliases.canonical_title_or_name_variants(q))
    if source == "ann":
        variants = [v for v in variants if looks_latin(v.query)]
    if source == "mangadex" and entity_type == "manga":
        variants = prefer_original_cjk_title_first(variants)
    return dedupe_variants(variants)


def score_candidate(entity_type: str, source: str, input_q: str, variant: SearchVariant, row: object) -> MatchEvidence:
    fields = searchable_fields(row)
    score = 0
    reasons = []
    if exact_or_normalized_match(input_q, fields):
        score += 100
        reasons.append("input-match")
    if exact_or_normalized_match(variant.query, fields):
        score += variant.confidence_hint
        reasons.append(f"variant-match:{variant.reason}")
    if source in {"kitsu", "shikimori"} and entity_type == "character" and not reasons:
        score -= 80
        reasons.append("cjk-character-untrusted-nonmatch")
    if source == "ann" and not looks_latin(variant.query):
        score -= 100
        reasons.append("ann-non-latin-query")
    return MatchEvidence(score=score, matched_query=variant.query, reasons=reasons)

Source-specific rule refinements from the validation:

  • AniList: preserve original Japanese queries and use it as a strong validation source when native/title/name fields match. Add alias fallback mainly for Chinese and Korean aliases.
  • Jikan: aggressively try CJK person-name spacing and romanized order variants for person and character; distrust Chinese title hits unless a canonical Japanese/romaji alias also matches.
  • Kitsu: use anime/manga variants normally, but treat character CJK raw hits as low-confidence unless name/slug/alias evidence matches; handle current person search as a source availability or route issue.
  • Shikimori: useful as a supplemental source, but CJK character raw hits must be validated by kana/romaji/English aliases before promotion.
  • MangaDex: for manga, query original CJK first and rank by title/alt-title/external-link evidence; do not eagerly replace CJK input with English.
  • ANN: query only Latin/English variants and rank conservatively because broad English titles may return many specials/spin-offs before the main entry.

Suggested implementation order:

  1. Add aggregate-only query planning helpers and tests for variant generation.
  2. Add aggregate-only candidate scoring/evidence helpers and tests using captured fixtures.
  3. Wire enhanced planning into animedex search behind the existing top-level command behavior, without modifying backend command behavior.
  4. Add source diagnostics for unavailable or weak sources, including the current Kitsu person-search filter issue.
  5. Calibrate thresholds with groundtruth before enabling any aggressive candidate collapsing or merge behavior.

@narugo1992
Copy link
Copy Markdown
Contributor Author

Follow-up on whether the PR #21 season anime merge can be reused directly for animedex search anime: I tested this by taking live animedex search anime <query> aggregate rows and feeding them into the current season merge path (_merge_season_items) without changing code. This is research-only; no code changes were made.

Short answer: the PR #21 identity work is useful as a base, but _merge_season_items() should not be wired directly into search anime unchanged.

Tested queries included Frieren, Sousou no Frieren, 葬送のフリーレン, 芙莉莲, Attack on Titan, 進撃の巨人, Naruto, One Piece, and Kimetsu no Yaiba.

Observed behavior:

Query area Direct season-merge behavior on search rows
Frieren / 葬送のフリーレン AniList/Jikan/Kitsu/Shikimori main entries merged well, but ANN remained separate, some Jikan/Shikimori sequel variants remained split, and several Kitsu rows fell through as passthrough because to_common() rejected upstream values such as current, unreleased, or lowercase media types.
芙莉莲 Direct merge did not help. The selected sources returned Chinese false positives rather than Frieren rows, so merge only grouped wrong rows more neatly. This confirms that CJK query expansion must happen before anime search merge.
Attack on Titan / 進撃の巨人 Main entries merged across AniList/Jikan/Kitsu/Shikimori, but ANN returned many movies/specials/OVAs that mostly remained standalone. Some sequel/season rows merged partially while others stayed split.
Naruto Main entry merged across the strong ID sources, but ANN produced many standalone rows. Kitsu movie rows also exposed common-projection validation failures.
One Piece Main entry merged only across AniList/Jikan in the direct test; Kitsu main row became passthrough because status normalization rejected current. ANN returned a large noisy result set.
Kimetsu no Yaiba Main entry merged across AniList/Jikan/Kitsu/Shikimori. One Mugen Ressha group even merged ANN/Jikan/Kitsu, but most ANN rows still remained standalone.

What should be reused from PR #21:

  • Common anime projection plus diagnostics (_to_common_anime_with_diagnostic).
  • Title normalization and title-score helpers.
  • Shared external-ID matching and external-ID conflict guards.
  • The merged payload idea: keep records, source_details, source_payloads, source tags, and id_conflicts instead of dropping provenance.

What should not be reused directly:

  • The whole _merge_season_items() grouping flow. It was calibrated for season/schedule rows, not free-text search rows.
  • The assumption that the input set is a bounded season list. Search results contain main entries, sequels, films, OVAs, recap/special rows, unrelated false positives, and duplicate variants.
  • The one-record-per-backend-per-group behavior. Search can return same-source duplicates or equivalent variants that should be deduped before cross-source grouping.
  • Season-specific context weighting as-is. Search needs query-match evidence and ranking, not only season/year/format/episode context.
  • Blind trust in ANN and Kitsu search rows. ANN lacks shared external IDs and often returns many English-title specials; Kitsu currently has normalization issues that produce passthrough rows.

Recommended search-anime merge shape:

# Conceptual flow only; backend search APIs stay raw.
rows = aggregate_search_rows(type="anime", query=q)
rows = attach_query_variant_evidence(rows, input_query=q)
projected, passthrough, diagnostics = project_to_common_anime(rows)
projected = dedupe_same_source_same_external_id(projected)
groups = group_by_shared_ids_then_search_title_context(projected)
groups = apply_search_specific_guards(groups, query=q)
return build_merged_search_result(groups, passthrough, diagnostics)

Required follow-ups before enabling search anime merge:

  1. Add CJK+E query expansion first. Direct merge cannot recover when the source rows are already false positives, as shown by 芙莉莲.
  2. Fix or harden Kitsu anime normalization so values like current, unreleased, movie, special, and music do not unnecessarily fall through as merge diagnostics.
  3. Add same-source dedupe before cross-source grouping, especially for repeated MAL-equivalent rows and sequel variants.
  4. Treat ANN as a weak/English-only source unless title/context evidence is very strong; it should not dominate merged search output.
  5. Calibrate search-specific thresholds against adjudicated search groundtruth rather than reusing the season threshold unchanged.

Conclusion: PR #21 provides useful identity primitives and output-shape conventions, but search anime needs its own merge wrapper with query evidence, same-source dedupe, weak-source guards, and CJK expansion ahead of merge.

@narugo1992
Copy link
Copy Markdown
Contributor Author

narugo1992 commented May 13, 2026

Search-quality eval — empirical findings (R&D for search enhancement and merge-on-search fit)

This comment closes out the maintainer-requested empirical study:

  1. Per-source / per-type search-quality measurement on real queries
  2. Whether PR Add calendar aggregate commands #21's _anime_match_score substrate ports directly to search anime
  3. Concrete enhancement snippets, validated against the eval

A small harness was built in the working tree under tools/search_eval/ (kept out of this PR's commit history per AGENTS §15.1; it is contributor R&D, not product surface):

tools/search_eval/
├── seeds.py            harvest seeds per type from real fixtures (AGENTS §9bis.2)
├── variants.py         9 deterministic query variants (raw + transliterations + alias fallbacks)
├── scoring.py          ID-match → strong title → fuzzy substring scoring on top-10
├── run_eval.py         ThreadPoolExecutor fan-out over (type, backend, variant, seed) cells
├── variant_gain.py     per-(type, backend) rescue rate when a variant catches what `raw` misses
└── merge_fitness.py    1,461-pair simulation of `_anime_match_score` with calendar/search-shaped inputs

Happy to push the harness as a separate scoped PR (no animedex/ touch) if the follow-up slice wants to re-run it; the script paths and shell commands at the bottom of this comment reproduce every number below.

Setup

  • Seeds: real upstream fixtures only; no hand-written test queries (AGENTS §9bis.2). 50 anime / 33 manga / 50 character / 24 person / 19 studio / 50 publisher seeds.
  • Variants per seed: raw, alias_english, alias_romaji, nfkc, jaconv_norm, kata2hira (CJK only), anyascii (CJK only), unidecode (CJK only), first_token. Deduplicated when collapsed.
  • Transport: real production stack via animedex.agg._type_routes.call_search_route (so cache, rate-limit bucket, and rich-model layers are all in the path).
  • Network: brightdata proxy via the PP_AO3 env var. Without it the AniList 0.5/s and Jikan 3/s token buckets saturate workers=8 within seconds and the run is dominated by rate-limited errors rather than upstream behaviour. With proxy + workers=8 the run completes in ~846s.

Key finding 1 — three real client bugs surfaced by the eval (above-zero priority)

The eval doubles as a regression detector. Three reproducible defects came out of the fan-out:

1.1 JikanPerson.alternate_names rejects null

ValidationError: 1 validation error for JikanPerson
alternate_names
  Input should be a valid list [type=list_type, input_value=None, input_type=NoneType]

Jikan returns alternate_names: null (not []) for ~4% of person records (verified against https://api.jikan.moe/v4/people?q=Brian: 1 of 25 rows in the first page has null). The model declares alternate_names: List[str] = [], so the row fails validation and the whole search call surfaces as ValidationError. The fix is the standard null-collapsing pattern:

# animedex/backends/jikan/models.py — JikanPerson
from pydantic import field_validator

class JikanPerson(BackendRichModel):
    ...
    alternate_names: List[str] = Field(default_factory=list)

    @field_validator("alternate_names", mode="before")
    @classmethod
    def _coerce_null_list(cls, value):
        return [] if value is None else value

1.2 Kitsu /people rejects filter[name]; client misreports the 400 as a shape error

upstream-shape: [backend=kitsu reason=upstream-shape] kitsu response missing 'data' key

The real upstream response is HTTP 400 with {"errors":[{"title":"Filter not allowed","detail":"name is not allowed.","code":"102","status":"400"}]}. animedex.backends.kitsu._fetch only branches on 404 and 5xx, lets the 400 body through as if it were a normal payload, and _data then raises upstream-shape: missing 'data' key. The user sees a misleading error.

Fix: in _fetch, surface upstream-side 4xx with the error detail:

# animedex/backends/kitsu/__init__.py — _fetch
def _fetch(path: str, *, params=None, config=None, **kw):
    raw = _raw_kitsu.call(path=path, params=params, config=config, **kw)
    if raw.body_text is None:
        raise ApiError("kitsu returned a non-text body", backend="kitsu", reason="upstream-decode")
    if raw.status == 404:
        raise ApiError(f"kitsu 404 on {path}", backend="kitsu", reason="not-found")
    if raw.status >= 500:
        raise ApiError(f"kitsu {raw.status} on {path}", backend="kitsu", reason="upstream-error")
    try:
        payload = _json.loads(raw.body_text)
    except ValueError as exc:
        raise ApiError(f"kitsu non-JSON body: {exc}", backend="kitsu", reason="upstream-decode") from exc
    if raw.status >= 400 and "errors" in payload:
        first = (payload["errors"] or [{}])[0]
        detail = first.get("detail") or first.get("title") or f"kitsu {raw.status} on {path}"
        raise ApiError(f"kitsu {raw.status}: {detail}", backend="kitsu", reason="bad-args")
    return payload, _src(raw)

The user now sees kitsu 400: name is not allowed. instead of a fictitious shape error. (The existing _data shape check stays where it is for the rarer "200 with a non-JSON:API body" case.)

1.3 ANN substring search has near-zero recall on romaji titles

Not a code bug, but a documented finding: ANN's substring_search indexes English-leaning titles. On 50 romaji anime seeds, raw query produces 18% hit rate; falling back to the seed's English alias raises this to 72%. See enhancement 2.A below.

Key finding 2 — variant rescue rates (proxy run, 50 seeds × 6 types × 6 variants)

A "rescue" counts a seed that raw missed (no ID or strong title match in top-10) but at least one alternate variant hit. That tells us which query rewrites are worth shipping inside the search layer.

chart_variant_gain.png

Blue = raw query hit rate; orange = best-of-9-variants hit rate. Orange annotations are the percentage-point gain. Two cells dominate: anime/ann (+54pp via alias_english) and manga/anilist (+42pp via first_token). Most other cells already ceiling at 90%+ on raw alone, so variants are only worth running when raw returns zero.

type backend seeds raw hit any-variant hit net gain top rescue variants
anime anilist 50 46 (92%) 50 (100%) +4 alias_english (+4), first_token (+3)
anime ann 50 9 (18%) 36 (72%) +27 alias_english (+20), first_token (+14)
anime jikan 50 49 (98%) 50 (100%) +1 alias_english (+1), first_token (+1), alias_romaji (+1)
anime kitsu 50 48 (96%) 49 (98%) +1 alias_english (+1), first_token (+1)
anime shikimori 50 46 (92%) 49 (98%) +3 alias_english (+2), first_token (+1)
character anilist 50 37 (74%) 39 (78%) +2 first_token (+2)
character jikan 50 48 (96%) 48 (96%) +0
character kitsu 50 22 (44%) 24 (48%) +2 first_token (+2)
character shikimori 50 47 (94%) 47 (94%) +0
manga anilist 33 18 (55%) 32 (97%) +14 first_token (+13), alias_english (+4)
manga jikan 33 31 (94%) 32 (97%) +1 alias_english (+1), alias_romaji (+1)
manga kitsu 33 25 (76%) 30 (91%) +5 alias_english (+4), first_token (+3), alias_romaji (+1)
manga mangadex 33 31 (94%) 32 (97%) +1 first_token (+1)
manga shikimori 33 26 (79%) 30 (91%) +4 first_token (+3), alias_english (+2), alias_romaji (+1)
person anilist 24 21 (88%) 21 (88%) +0
person jikan 24 18 (75%) 20 (83%) +2 first_token (+2)
person kitsu 24 0 (0%) 0 (0%) +0
person shikimori 24 23 (96%) 24 (100%) +1 first_token (+1)
publisher shikimori 50 50 (100%) 50 (100%) +0
studio anilist 19 12 (63%) 12 (63%) +0
studio jikan 19 19 (100%) 19 (100%) +0
studio kitsu 19 8 (42%) 8 (42%) +0
studio shikimori 19 19 (100%) 19 (100%) +0

Patterns:

  • alias_english is the most useful rescue when a row carries an English alias. ANN anime is the standout case: raw 18% → any-variant 72%, with alias_english alone rescuing 20 of the 41 raw misses. ANN's substring_search indexes English-leaning titles; when the seed is romaji the substring lookup just misses.

    chart_ann_alias_rescue.png

    Concrete rescued seeds (raw → alias_english):

    'Ore wa Seikan Kokka no Akutoku Ryoushu! '   →  "I'm the Evil Lord of an Intergalactic Empire!"  (pos 0)
    'Gimai Seikatsu'                              →  'Days with My Stepsister'                        (pos 0)
    'Maou 2099'                                   →  'DEMON LORD 2099'                                (pos 0)
    'Yesterday wo Utatte'                         →  'SING "YESTERDAY" FOR ME'                        (pos 0)
    'Zom 100: Zombie ni Naru Made ni Shitai 100 no Koto'  →  'Zom 100: Bucket List of the Dead'      (pos 0)
    
  • first_token is the biggest single rescue on AniList manga search, raw 55% → any-variant 97%, with first_token alone covering 13 of 15 raw misses.

    chart_anilist_manga_ft.png

    The pattern: AniList GraphQL Media(search: ..., type: MANGA) is a substring match that fails on titles with parenthetical or dash-suffix annotations harvested from MangaDex fixtures:

    'Chainsaw Man (Official Colored)'                  raw 0 results  →  'Chainsaw'        (pos 0)
    'Spy × Family (Official Colored)'                  raw 0 results  →  'Spy'             (pos 1)
    'Goodnight Punpun'                                  raw 0 results  →  'Goodnight'       (pos 3)
    'Jujutsu Kaisen - The Possession (Doujinshi)'       raw 0 results  →  'Jujutsu'         (pos 0)
    'Boku no Hero Academia (Official Colored)'         raw 0 results  →  'Boku'            (pos 0)
    

    A targeted "strip parenthetical / dash-suffix" client-side preprocessor would catch the same misses without needing the first-token fallback.

    Combined view of the rescued seeds across both standout cells:

    chart_rescue_examples.png

  • first_token also rescues a small fraction of people searches (Jikan /people and Shikimori /people), where the seed is "Surname Forename" and the upstream's substring filter behaves differently between full-name and first-token inputs.

  • anyascii / unidecode produced near-zero rescues on real seeds. They occasionally collapse Sōsō no FurīrenSosou no Furiren style noise that no upstream's index normalises through. They remain useful in the merge path (matching catalog rows post-fetch) but should not be fan-out queries.

  • nfkc and jaconv_norm rescue only rare full-width-digit / full-width-punctuation cases in the sample. Worth keeping cheaply because they are zero-cost on already-NFKC strings.

  • Studio and publisher rescues are zero for two different reasons depending on the cell:

    • kitsu producers and shikimori studios / shikimori publishers go through the all_items=True path in _type_routes.py (fetch full catalogue, locally filter), so the local-filter step already canonicalises lowercase + substring and variants add nothing.
    • anilist studio_search (63% raw) and kitsu producer search (42% raw) are real upstream search routes with no rescue from any variant. The misses are mostly "MAPPA"-class brand-name lookups where the upstream's tokeniser is the binding constraint; only fan-in to the catalogue itself would fix them.

Key finding 3 — _anime_match_score ports to search anime, conditionally

We took the 1,461 high-confidence (score ≥ 9.5) pairs from test/fixtures/aggregate/season_matrix/candidates/ (the PR #21 adjudication corpus) and re-scored under four input shapes that simulate what a search fan-out actually sees:

chart_merge_fitness.png

Green = pairs above the 70 merge threshold, orange = near (50-69, would not merge today). The first two scenarios merge everything because the _shared_external_id shortcut returns 1000 instantly. Strip the IDs and keep title + year + season: still 82% merge. Strip the context entirely and it collapses to 0% because title alone caps at 55. The implication is that the existing scorer ports as-is, provided the search fan-out preserves either the cross-source ID (best) or the year/season (good enough).

scenario merge (≥70) near (50-69) partial (30-49) miss (<30) raw p50 (title+ctx, pre-clamp)
calendar_with_context (status quo) 100% 0% 0% 0% 1000 (ID shortcut)
search_with_ids_no_context (e.g. AniList/Jikan search rows carry mal_id) 100% 0% 0% 0% 1000 (ID shortcut)
search_partial_context_no_ids (title + season + season_year) 82% 17% 1% 1% 80
search_title_only (title block only) 0% 98% 1% 1% 55 (title cap)

Interpretation:

  • The _shared_external_id shortcut returns 1000 and dominates whenever both sides carry the same external ID — AniList search already returns idMal, Jikan search returns mal_id natively, MangaDex search returns its own UUID. These pairs port without any change to the scorer.
  • The _context_match_score adds up to ~28 (year 15 + season 10 + format 6 + episodes 6 + aired ≤14d 8) and reliably pushes title-only above the 70 threshold whenever just season_year is present (year 15 + title 55 = 70). AniList/Jikan/Kitsu all expose season_year (or its date-derived equivalent) inside their search payload.
  • Pure title-only matches (Shikimori's bare anime search row, ANN's substring row) cap at 55 and never cross the 70 threshold. The substrate does not need a new scorer — it needs the search fan-out to either keep the year field on Shikimori rows (its API has it; we just have to map it) or, for ANN, accept that ANN rows merge on shared ID only.

The takeaway: the follow-up issue's "extend merge to search" slice does not need a new scorer. It needs the search fan-out to retain season_year on every row it can (the easy 82% → 100% bridge) and accept a small Shikimori/ANN miss rate as the cost of not hydrating. Lowering the threshold below 70 would let "Naruto" and "Naruto Shippuden" merge, which is the kind of cross-source false positive the calendar adjudication corpus was specifically built to avoid.

Key finding 4 — concrete search enhancement (code-ready)

These are the cheapest wins from the variant gain table:

4.A — AniList manga: client-side strip-parenthetical preprocessor

The clearest single win in the eval. AniList's GraphQL manga search misses on titles with annotation suffixes harvested from MangaDex. A 5-line preprocessor before the GraphQL call rescues 13/15 raw misses (55% → 97% any-variant; full retry chain not required):

# animedex/backends/anilist/__init__.py — manga_search wrapper
import re

_PAREN_OR_DASH_SUFFIX = re.compile(r"\s*(?:\([^)]*\)|[-–—:]\s*.*|~[^~]*~)\s*$")

def _normalise_manga_query(q: str) -> str:
    cleaned = q
    while True:
        new = _PAREN_OR_DASH_SUFFIX.sub("", cleaned).strip()
        if new == cleaned or not new:
            break
        cleaned = new
    return cleaned or q

The CLI's contract stays unchanged (queries pass through verbatim from the user); this is a per-backend search-query normaliser, not a global rewrite. The animedex-style way to ship this is a _normalise_manga_query hook called inside manga_search before the GraphQL Media(search: ...) parameter is set, with the original query also tried as a tie-break if the normalised query returns nothing the user might want.

4.B — ANN anime: retry with sibling-source English alias (aggregate-level)

ANN's 18% → 72% jump in the eval came from feeding the seed's English alias to ANN's substring search. In production, the user typing animedex search anime "frieren" has no English alias yet — but aggregate.search calls AniList/Jikan/Kitsu in parallel, and the first one to land usually provides one. The fallback can therefore live in the aggregate fan-out:

# animedex/agg/search.py — illustrative shape, not a literal diff
def _call_ann_with_alias_fallback(route, query, limit, *, sibling_results):
    rows = call_search_route(route, query=query, limit=limit)
    if rows:
        return rows
    # Harvest a few English-looking aliases from earlier-completed backends.
    aliases = _english_aliases_from(sibling_results)
    for alias in aliases[:2]:
        if alias != query:
            rows = call_search_route(route, query=alias, limit=limit)
            if rows:
                return rows
    return rows

Cost: one extra HTTP call to ANN only when the first attempt returned zero rows AND at least one sibling backend already produced a result. Net expected gain on the 50-seed eval: +27 rescued anime per 50 seeds.

4.C — Person/character: token-degraded retry on empty result

# animedex/agg/search.py — illustrative
def _person_search_with_fallback(route, query, limit):
    rows = call_search_route(route, query=query, limit=limit)
    if rows:
        return rows
    tokens = query.split()
    if len(tokens) > 1:
        return call_search_route(route, query=tokens[0], limit=limit)
    return rows

Expected gain in the eval: jikan first_token rescues 2/6 missed person seeds; shikimori first_token rescues 1/1; kitsu/anilist character first_token rescues 2/13 + 2/26 missed character seeds. Small absolute numbers, but the implementation is a 4-line fallback so the ROI is good.

4.D — Merge for search anime (follow-up slice, not this PR)

This commits to the maintainer's scoped guidance ("cross-source merging on search lands in a follow-up slice that reuses PR #21's substrate directly"):

# animedex/agg/search.py — proposed search-merge wrapper for the follow-up slice
from animedex.agg.calendar import _anime_match_score, _merge_season_items
from animedex.models.aggregate import AggregateResult

def _maybe_merge_anime(result: AggregateResult) -> AggregateResult:
    # Same dispatcher as _merge_season_items, but on a search result rather than
    # a season result. _anime_match_score already returns 1000 on shared mal_id,
    # which is the only safe shortcut for search inputs that lack full season
    # context (the eval at score >= 9.5 says 100% of high-confidence pairs share
    # mal_id; the title-only score caps at 55 < threshold so this is safe).
    return _merge_season_items(result)

Note: this only fires for type=anime. Manga/character/person/studio/publisher would need their own per-entity scorers + adjudication corpora before they merge. Surface only the merged shape for anime in the follow-up; ship the other types annotate-only.

Reproducing the numbers

# 1. Build seed inventory from real fixtures
PYTHONPATH=. python3 -m tools.search_eval.seeds --limit 50 --output tools/search_eval/seeds.json

# 2. Full eval (requires PP_AO3 or an equivalent proxy to avoid bucket saturation)
export PP_AO3='<your-proxy-url>'
PYTHONPATH=. python3 -m tools.search_eval.run_eval \
    --types anime,manga,character,person,studio,publisher \
    --seeds-per-type 50 --max-variants 6 --workers 8 --limit 10 \
    --out tools/search_eval/runs/$(date -u +%Y%m%dT%H%M%S)

# 3. Per-variant rescue rates from a completed run
PYTHONPATH=. python3 -m tools.search_eval.variant_gain --run tools/search_eval/runs/<dir>

# 4. Merge-fitness analysis (fixture-only, no network needed)
PYTHONPATH=. python3 -m tools.search_eval.merge_fitness

The full eval landed in 846 seconds with --workers 8 through the brightdata proxy; AniList's 0.5 req/s sustained bucket is the dominant wall-clock cost. The artefacts under tools/search_eval/runs/full50/ (results-*.jsonl, summary.md, meta.json, variant_gain.md) are reproducible from the commands above and are auditable from the JSONL rows.

Out-of-scope for this PR (intentionally)

  • Wiring _merge_season_items into search anime is the follow-up issue's slice per maintainer guidance — this comment is the empirical motivation, not the implementation. PR P5-search-show: add aggregate search and show commands #22 ships annotate-only search as agreed.
  • The two client bugs (1.1 Jikan, 1.2 Kitsu) are small and self-contained; recommended as a quick follow-up PR rather than expanding PR P5-search-show: add aggregate search and show commands #22's scope (AGENTS §15.1). Both fixes are 5-10 line patches with HTTP-mocked regression tests against captured null-alternate_names / 400-filter fixtures.
  • The variant-rescue enhancements (4.A–4.C) also belong in a follow-up. The clean cut from this PR is: PR P5-search-show: add aggregate search and show commands #22 lands the aggregate search/show surface; the follow-up adds the per-backend query-rewriting hooks and the AniList/ANN fallbacks now that we have measured numbers to size the implementation by.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P5-search-show: animedex search + animedex show (entity infrastructure + multi-source flagship)

1 participant