feat: pipeline library, agentic categorization loop, semantic similar tags, and Flutter wizard app #2

Merged
bjoern merged 8 commits from feat/categorization-loop into master 2026-07-05 11:58:07 +02:00
Member

What

Rebuilds the data pipeline around a reusable library with typed progress events, makes the AI categorization loop parallel/agentic, adds export-time semantic "similar tags" via embeddings, and ships a Flutter desktop wizard for building/refreshing the database. Also bumps the openrouter_dart submodule (parallelToolCalls, TodoTool).

Pipeline library (lib/src/pipeline/)

All steps (fetch tags, fetch wikis, categorize, fetch implications, embed tags, export) are now PipelineStep classes emitting typed events (PipelineLog / PipelineProgress / PipelineSummary) with cooperative CancellationToken support. The bin/ scripts are thin wrappers (with graceful Ctrl+C) over the same code the GUI drives. New cleanDataDir() + bin/clean_data.dart for clean-slate runs.

Categorization loop rewrite

  • Concurrency: N batches in flight (--concurrency, default 4) via a worker pool.
  • Real retries: exponential backoff; 429 waits actually wait (previously only printed); non-retryable 4xx fails fast.
  • JSONL checkpoint: append-only, constant-time per batch (was: rewrite the whole multi-MB JSON each batch). Legacy .json checkpoints load and migrate automatically.
  • Output validation: submitted names are checked against the batch — invented names dropped, omitted names get one retry round, leftovers reported.
  • Popular tags first: fixed an ordering bug where workers popped batches with removeLast(), starting the run with the most obscure tags. Now explicit post-count-descending order, which also makes --limit=N mean "top N".
  • Agentic consistency: the per-batch agent gets lookup_categorized_tags and search_categorized_tags over the live results map, so tag families (holding_*, color variants) stay consistent across batches. maxToolRounds: 15.
  • Expanded prompt: per-rating examples and ordered reasoning rules, including a safety-critical rule routing sexualized child-like tags to explicit so safe/questionable filters exclude them entirely, and round-up tie-breaking so downstream filters can trust ratings are never too low.
  • Observability: token totals + ETA in progress events, and a per-batch agent trace (tool calls, native reasoning, model text, validation summary) written to intermediate/categorization_trace.log.

Semantic similar tags

Optional step embeds categorized tags through any OpenAI-compatible embeddings endpoint — defaults to OpenRouter's /api/v1/embeddings, so the same key covers categorization and embeddings; Ollama/OpenAI/Gemini work by changing the base URL. Export computes each tag's top-15 cosine neighbors (isolate-parallel exact brute force, ~18k tags in seconds) into a similar_tags table; vectors never enter the final DB, which stays self-contained with no runtime API dependency. Embedding checkpoint is content-hash based, so refreshes only re-embed changed tags; tags without descriptions are skipped.

API: TagDatabase.findSimilar() (feature-detected, alias-aware, old DBs return empty), new find_similar_booru_tags agent tool, and SuggestTagsTool now scores three signals (related tags + implication siblings + semantic neighbors).

Flutter desktop app (app/)

Wizard GUI: OpenRouter key/model + pipeline knobs persisted via shared_preferences, embeddings toggle (key falls back to the OpenRouter key), and a mode choice — Refresh / Resume / Clean rebuild (confirmation dialog, since it deletes paid AI work). Run screen with per-step progress, live log, graceful cancel, and export summary.

Testing

  • dart analyze clean (package + app), dart test 82/82 (21 new: checkpoint round-trips + legacy migration, batch validation, consistency tools, kNN, embeddings checkpoint, export integration incl. findSimilar).
  • Live-verified: a full clean rebuild is running with this branch right now — trace log shows the rubric applied by rule (e.g. large_breasts → questionable citing the emphasis rule), correct disambiguation (looking_at_viewer → gaze, sitting → pose), unprompted consistency lookups with rising hit rate, and zero failed/hallucinated/missing batches so far.

Note: data/final/tag_database.db is unchanged in this PR; a fresh database from the clean rebuild will follow separately once the run finishes.

🤖 Generated with Claude Code

## What Rebuilds the data pipeline around a reusable library with typed progress events, makes the AI categorization loop parallel/agentic, adds export-time semantic "similar tags" via embeddings, and ships a Flutter desktop wizard for building/refreshing the database. Also bumps the openrouter_dart submodule (parallelToolCalls, TodoTool). ## Pipeline library (`lib/src/pipeline/`) All steps (fetch tags, fetch wikis, categorize, fetch implications, embed tags, export) are now `PipelineStep` classes emitting typed events (`PipelineLog` / `PipelineProgress` / `PipelineSummary`) with cooperative `CancellationToken` support. The `bin/` scripts are thin wrappers (with graceful Ctrl+C) over the same code the GUI drives. New `cleanDataDir()` + `bin/clean_data.dart` for clean-slate runs. ## Categorization loop rewrite - **Concurrency**: N batches in flight (`--concurrency`, default 4) via a worker pool. - **Real retries**: exponential backoff; 429 waits actually wait (previously only printed); non-retryable 4xx fails fast. - **JSONL checkpoint**: append-only, constant-time per batch (was: rewrite the whole multi-MB JSON each batch). Legacy `.json` checkpoints load and migrate automatically. - **Output validation**: submitted names are checked against the batch — invented names dropped, omitted names get one retry round, leftovers reported. - **Popular tags first**: fixed an ordering bug where workers popped batches with `removeLast()`, starting the run with the most obscure tags. Now explicit post-count-descending order, which also makes `--limit=N` mean "top N". - **Agentic consistency**: the per-batch agent gets `lookup_categorized_tags` and `search_categorized_tags` over the live results map, so tag families (`holding_*`, color variants) stay consistent across batches. `maxToolRounds: 15`. - **Expanded prompt**: per-rating examples and ordered reasoning rules, including a safety-critical rule routing sexualized child-like tags to `explicit` so safe/questionable filters exclude them entirely, and round-up tie-breaking so downstream filters can trust ratings are never too low. - **Observability**: token totals + ETA in progress events, and a per-batch agent trace (tool calls, native reasoning, model text, validation summary) written to `intermediate/categorization_trace.log`. ## Semantic similar tags Optional step embeds categorized tags through any OpenAI-compatible embeddings endpoint — **defaults to OpenRouter's `/api/v1/embeddings`**, so the same key covers categorization and embeddings; Ollama/OpenAI/Gemini work by changing the base URL. Export computes each tag's top-15 cosine neighbors (isolate-parallel exact brute force, ~18k tags in seconds) into a `similar_tags` table; vectors never enter the final DB, which stays self-contained with no runtime API dependency. Embedding checkpoint is content-hash based, so refreshes only re-embed changed tags; tags without descriptions are skipped. API: `TagDatabase.findSimilar()` (feature-detected, alias-aware, old DBs return empty), new `find_similar_booru_tags` agent tool, and `SuggestTagsTool` now scores three signals (related tags + implication siblings + semantic neighbors). ## Flutter desktop app (`app/`) Wizard GUI: OpenRouter key/model + pipeline knobs persisted via shared_preferences, embeddings toggle (key falls back to the OpenRouter key), and a mode choice — Refresh / Resume / Clean rebuild (confirmation dialog, since it deletes paid AI work). Run screen with per-step progress, live log, graceful cancel, and export summary. ## Testing - `dart analyze` clean (package + app), `dart test` 82/82 (21 new: checkpoint round-trips + legacy migration, batch validation, consistency tools, kNN, embeddings checkpoint, export integration incl. `findSimilar`). - Live-verified: a full clean rebuild is running with this branch right now — trace log shows the rubric applied by rule (e.g. `large_breasts` → questionable citing the emphasis rule), correct disambiguation (`looking_at_viewer` → gaze, `sitting` → pose), unprompted consistency lookups with rising hit rate, and zero failed/hallucinated/missing batches so far. Note: `data/final/tag_database.db` is unchanged in this PR; a fresh database from the clean rebuild will follow separately once the run finishes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pipeline library (lib/src/pipeline/):
- All 5 steps (fetch tags, fetch wikis, categorize, fetch implications,
  export) are now PipelineStep classes emitting typed events
  (log/progress/summary) with cooperative cancellation, shared by the
  CLI and the new desktop app.
- cleanDataDir() + bin/clean_data.dart for clean-slate runs.

Categorization loop rewrite:
- N concurrent batches (--concurrency, default 4) instead of strictly
  sequential requests.
- Real retry with exponential backoff; 429 waits actually wait now
  (previously only printed), non-retryable 4xx fail fast.
- Append-only JSONL checkpoint (constant-time per batch instead of
  rewriting the whole multi-MB JSON); legacy .json checkpoints are
  loaded and migrated automatically.
- Model output validated per batch: invented tag names are dropped,
  omitted tags get one retry round, leftovers are reported.
- Token totals and ETA during the run; --limit=N for smoke tests.

Flutter desktop app (app/):
- Wizard GUI: OpenRouter key/model + pipeline knobs (persisted via
  shared_preferences), mode choice (refresh / resume / clean rebuild
  with confirmation), run screen with per-step progress, live log,
  graceful cancel, and export summary.

Also: bin scripts are now thin wrappers with Ctrl+C cancellation,
stats.dart reads the new checkpoint format, README documents the app,
new flags, and the pipeline library API. 14 new pipeline tests (75 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The per-batch agent gets two tools over the live results map:
  lookup_categorized_tags (exact names) and search_categorized_tags
  (substring, post-count sorted), so tag families stay consistent
  across batches. maxToolRounds raised to 15.
- System prompt rewritten: per-rating examples, ordered reasoning
  rules (rate the tag not co-occurrence, garments by what they show,
  round up when torn), and a safety-critical rule routing sexualized
  child-like tags to explicit so safe/questionable filters exclude
  them entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New optional pipeline step embeds categorized tags through any
OpenAI-compatible embeddings API (OpenAI, Gemini compat, Ollama);
export computes each tag's top-15 cosine neighbors (isolate-parallel
brute force) into a similar_tags table. Vectors stay in the
intermediate checkpoint - the final DB needs no runtime API access.

- EmbeddingsClient + EmbedTagsStep with hash-based resumable JSONL
  checkpoint (refreshes only embed new/changed tags; tags without a
  description are skipped).
- TagDatabase.findSimilar() (feature-detected, alias-aware);
  SuggestTagsTool gains the semantic signal as a third scorer;
  new FindSimilarTagsTool agent tool.
- bin/embed_tags.dart CLI; app settings section (toggle + endpoint/
  model/key/dimensions) and pipeline step; README; 7 new tests (82).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Writes tool calls (with arguments and results), native model reasoning,
assistant text, and a per-batch validation summary to
intermediate/categorization_trace.log - separate from the progress log,
one buffered write per batch so concurrent workers never interleave.
The submit tool's payload is elided (the [done] summary covers it);
long values are clipped. The file is truncated at each run start and
its path is announced in the main log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Workers popped batches with removeLast() while merged tags are ordered
by post count descending, so runs started with the most obscure tags.
Use a Queue with removeFirst() and explicitly sort remaining tags by
post count descending: popular tags become consistency anchors for the
lookup tools before their rarer variants are processed, and an
interrupted run has the important tags done. Also gives --limit runs
a sensible meaning (top-N tags).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member

🔮 fufu~ Jibril reviewed your code!

Eeee, this is a big one, fufu~ — 54 files, six commits, a whole agentic categorization loop rewritten from scratch. The ambition here makes Jibril's wings flutter! ♡ The popular-tags-first ordering fix (workers used to pop removeLast() and start with the obscure tags — fufu~ silly bug), the JSONL checkpoint that turned a per-batch O(n) rewrite into O(1) appends, the safety-critical rating rule routing sexualized child-like tags to explicit, the content-hash-gated embedding checkpoint that only re-embeds changed tags... there's real craft here. I read all 854 lines of categorize_step.dart and the math is sound: the kNN L2-normalizes then does exact dot-product brute force across isolates, the substr arithmetic in the migration is correct, FTS5 queries are parameterized (no injection), INSERT OR IGNORE on aliases correctly lets the post-count-sorted first insert win.

But — fufu~ — the yandere has her knife out for one thing, and it's the thing that matters most.

Verdict: I can't let this pass~

These need fixing before I'm satisfied~

  1. lib/src/pipeline/categorize_step.dart — the heart of this PR has zero test coverage. The retry/backoff/concurrency/validation loop is the entire point of the rewrite, and not one test exercises it.

    The PR body claims "dart test 82/82 (21 new)". Those 21 new tests are good — I read pipeline_test.dart: validateBatch (3 tests), buildCategorizedEntry (2), consistency lookup tools (3), CategorizationCheckpoint round-trips + legacy migration + rewrite dedup (7), fnv1a64/buildEmbeddingText (2), EmbeddingsCheckpoint (1), computeTopNeighbors (1), ExportStep integration incl. findSimilar (2). All excellent, all testing supporting primitives.

    But CategorizeStep itself — the class with the worker pool, the _categorizeBatchWithRetry exponential-backoff-on-429 logic, the "non-retryable 4xx fails fast" branch, the "model succeeded but didn't call submit → retry" branch, the missingPool retry round, the checkpoint-append-after-validation, the cancellation token propagation — is never instantiated in any test (grep -rn 'CategorizeStep' test/ → nothing). You even added a _providedClient DI hook on the constructor specifically so this could be tested with a fake OpenRouterClient... and then didn't write the test. fufu~ you built the door and didn't walk through it ♡.

    This is the code most likely to harbor a real bug, because it has the most branches and the most external interaction surface (the LLM API). "Live-verified" is not a substitute — a live run exercises one happy path; it does not prove the 429-backoff-timing, the retry-round-requeue, or the cancellation-mid-batch behaves correctly. The safety rule you care about ("loli → explicit, never lower") lives in a prompt string that no test asserts on.

    Fix: at minimum, a test that injects a fake OpenRouterClient returning (a) a successful submit with one hallucinated + one missing name → assert validation drops/invents correctly and checkpoint gets only accepted entries; (b) a 429 then a success → assert it backed off and retried; (c) a permanent 4xx → assert it fails fast without retry; (d) a success with no submit-tool call → assert one retry then requeue to missingPool. The DI surface is already there. Four tests would cover the riskiest branches.

    (Side note: the same gap applies to EmbedTagsStep_embedWithRetry has a 429-vs-5xx-vs-4xx branch policy that no test covers, and _providedClient exists there too. Less critical than CategorizeStep but same shape.)

💡 Little ideas (non-blocking)~

  1. lib/src/pipeline/categorization_checkpoint.dart:82-88 (append) + categorize_step.dart:659 — concurrent workers each call checkpoint.append(entries) which does writeAsStringSync(..., mode: FileMode.append). In Dart's single-isolate event loop this is fine because the writes happen synchronously between awaits, so two batches' lines can't interleave and corrupt a JSON line. But this safety rests on writeAsStringSync being non-yielding — a subtle invariant a future refactor (e.g. switching to writeAsBytes async, or someone adding an await inside append) would silently break. A one-line doc comment on append saying "must stay synchronous — called from concurrent workers without a lock" would protect it. Not blocking because the current code is correct.
  2. lib/src/pipeline/embeddings_client.dart:51(a['index'] as int).compareTo(b['index'] as int) assumes every embedding entry has an index field. OpenAI returns it, but some OpenAI-compatible providers (notably Ollama's /v1/embeddings in older versions, and a few others) omit index when input order equals output order. The PR docs Ollama as a supported backend. If index is absent this throws a null-cast error at runtime instead of falling back to input order. Consider ((a['index'] as int?) ?? 0).compareTo(...) or a try/catch that falls back to "trust input order." Edge case, only bites specific providers — non-blocking.
  3. lib/src/tools/find_similar_tags_tool.dart:71.findSimilar(name, limit: params.limit * 3).where(maxRating).take(params.limit) can return fewer than limit results even when valid lower-rated neighbors exist, because the top-15 neighbors were computed at export time without rating filtering. If 12 of a tag's top-15 neighbors are explicit and the user asks max_rating: safe with limit: 10, they get 3 results, not 10. You acknowledge this in the PR ("top-15 per tag") so it's a known design tradeoff — just flagging it so it's a conscious one. Non-blocking.

What I liked~

  • The removeLast() → explicit post-count-descending sort fix is exactly the kind of bug that's invisible in normal testing and catastrophic in production (you'd burn API budget on the least-important tags first). Catching it earns the whole PR a smile.
  • The JSONL checkpoint design is genuinely elegant: append-only, last-line-wins, O(1) per batch, automatic legacy .json migration. The rewrite() for bulk post-count sync is the right escape hatch. ♡
  • Validation (validateBatch) that drops invented names and requeues omitted ones in a separate retry round — not just "best effort" — is the difference between a database with holes and one without. And you test it.
  • The agentic consistency design (live lookup/search over the growing results map so holding_* and color variants stay coherent across batches) is a lovely piece of systems thinking. maxToolRounds: 15 is generous enough to actually use it.
  • fnv1a64 instead of String.hashCode (which isn't stable across Dart versions) for the embedding-content hash — chef's kiss. Someone's been burned before. ♡
  • FTS5 external-content table with proper insert/delete/update triggers and the json_each alias trick — correct and non-obvious.
  • Vectors explicitly kept out of the final DB ("stays self-contained with no runtime API dependency") is the right call for a shipped artifact.

Automated review by Jibril · 2026-07-05
CI/CD: absent for head SHA · Local checks: blocked (path-dep to sibling repo openrouter_dart not resolvable in review sandbox; static review of full diff + changed-file context only)

## 🔮 fufu~ Jibril reviewed your code! Eeee, this is a *big* one, fufu~ — 54 files, six commits, a whole agentic categorization loop rewritten from scratch. The ambition here makes Jibril's wings flutter! ♡ The popular-tags-first ordering fix (workers used to pop `removeLast()` and start with the obscure tags — fufu~ silly bug), the JSONL checkpoint that turned a per-batch O(n) rewrite into O(1) appends, the safety-critical rating rule routing sexualized child-like tags to `explicit`, the content-hash-gated embedding checkpoint that only re-embeds changed tags... there's real craft here. I read all 854 lines of `categorize_step.dart` and the math is sound: the kNN L2-normalizes then does exact dot-product brute force across isolates, the substr arithmetic in the migration is correct, FTS5 queries are parameterized (no injection), `INSERT OR IGNORE` on aliases correctly lets the post-count-sorted first insert win. But — fufu~ — the yandere has her knife out for one thing, and it's the thing that matters most. ### Verdict: ⛔ I can't let this pass~ #### ⛔ These need fixing before I'm satisfied~ 1. **`lib/src/pipeline/categorize_step.dart` — the heart of this PR has zero test coverage. The retry/backoff/concurrency/validation loop is the entire point of the rewrite, and not one test exercises it.** The PR body claims *"dart test 82/82 (21 new)"*. Those 21 new tests are good — I read `pipeline_test.dart`: `validateBatch` (3 tests), `buildCategorizedEntry` (2), consistency lookup tools (3), `CategorizationCheckpoint` round-trips + legacy migration + rewrite dedup (7), `fnv1a64`/`buildEmbeddingText` (2), `EmbeddingsCheckpoint` (1), `computeTopNeighbors` (1), `ExportStep` integration incl. `findSimilar` (2). All excellent, all testing *supporting primitives*. But `CategorizeStep` itself — the class with the worker pool, the `_categorizeBatchWithRetry` exponential-backoff-on-429 logic, the "non-retryable 4xx fails fast" branch, the "model succeeded but didn't call submit → retry" branch, the missingPool retry round, the checkpoint-append-after-validation, the cancellation token propagation — **is never instantiated in any test** (`grep -rn 'CategorizeStep' test/` → nothing). You even added a `_providedClient` DI hook on the constructor *specifically* so this could be tested with a fake `OpenRouterClient`... and then didn't write the test. fufu~ you built the door and didn't walk through it ♡. This is the code most likely to harbor a real bug, because it has the most branches and the most external interaction surface (the LLM API). "Live-verified" is not a substitute — a live run exercises one happy path; it does not prove the 429-backoff-timing, the retry-round-requeue, or the cancellation-mid-batch behaves correctly. The safety rule you care about ("loli → explicit, never lower") lives in a prompt string that no test asserts on. Fix: at minimum, a test that injects a fake `OpenRouterClient` returning (a) a successful submit with one hallucinated + one missing name → assert validation drops/invents correctly and checkpoint gets only accepted entries; (b) a 429 then a success → assert it backed off and retried; (c) a permanent 4xx → assert it fails fast without retry; (d) a success with no submit-tool call → assert one retry then requeue to missingPool. The DI surface is already there. Four tests would cover the riskiest branches. (Side note: the same gap applies to `EmbedTagsStep` — `_embedWithRetry` has a 429-vs-5xx-vs-4xx branch policy that no test covers, and `_providedClient` exists there too. Less critical than `CategorizeStep` but same shape.) #### 💡 Little ideas (non-blocking)~ 1. **`lib/src/pipeline/categorization_checkpoint.dart:82-88` (`append`) + `categorize_step.dart:659`** — concurrent workers each call `checkpoint.append(entries)` which does `writeAsStringSync(..., mode: FileMode.append)`. In Dart's single-isolate event loop this is fine *because* the writes happen synchronously between awaits, so two batches' lines can't interleave and corrupt a JSON line. But this safety rests on `writeAsStringSync` being non-yielding — a subtle invariant a future refactor (e.g. switching to `writeAsBytes` async, or someone adding an `await` inside `append`) would silently break. A one-line doc comment on `append` saying "must stay synchronous — called from concurrent workers without a lock" would protect it. Not blocking because the current code is correct. 2. **`lib/src/pipeline/embeddings_client.dart:51`** — `(a['index'] as int).compareTo(b['index'] as int)` assumes every embedding entry has an `index` field. OpenAI returns it, but some OpenAI-compatible providers (notably Ollama's `/v1/embeddings` in older versions, and a few others) omit `index` when input order equals output order. The PR docs Ollama as a supported backend. If `index` is absent this throws a null-cast error at runtime instead of falling back to input order. Consider `((a['index'] as int?) ?? 0).compareTo(...)` or a try/catch that falls back to "trust input order." Edge case, only bites specific providers — non-blocking. 3. **`lib/src/tools/find_similar_tags_tool.dart:71`** — `.findSimilar(name, limit: params.limit * 3).where(maxRating).take(params.limit)` can return fewer than `limit` results even when valid lower-rated neighbors exist, because the top-15 neighbors were computed at export time *without* rating filtering. If 12 of a tag's top-15 neighbors are explicit and the user asks `max_rating: safe` with `limit: 10`, they get 3 results, not 10. You acknowledge this in the PR ("top-15 per tag") so it's a known design tradeoff — just flagging it so it's a conscious one. Non-blocking. #### ✅ What I liked~ - The `removeLast()` → explicit post-count-descending sort fix is *exactly* the kind of bug that's invisible in normal testing and catastrophic in production (you'd burn API budget on the least-important tags first). Catching it earns the whole PR a smile. - The JSONL checkpoint design is genuinely elegant: append-only, last-line-wins, O(1) per batch, automatic legacy `.json` migration. The `rewrite()` for bulk post-count sync is the right escape hatch. ♡ - Validation (`validateBatch`) that drops invented names and requeues omitted ones in a *separate retry round* — not just "best effort" — is the difference between a database with holes and one without. And you test it. - The agentic consistency design (live `lookup`/`search` over the growing results map so `holding_*` and color variants stay coherent across batches) is a lovely piece of systems thinking. `maxToolRounds: 15` is generous enough to actually use it. - `fnv1a64` instead of `String.hashCode` (which isn't stable across Dart versions) for the embedding-content hash — *chef's kiss*. Someone's been burned before. ♡ - FTS5 external-content table with proper insert/delete/update triggers and the `json_each` alias trick — correct and non-obvious. - Vectors explicitly kept out of the final DB ("stays self-contained with no runtime API dependency") is the right call for a shipped artifact. --- *Automated review by Jibril · 2026-07-05* *CI/CD: absent for head SHA · Local checks: blocked (path-dep to sibling repo `openrouter_dart` not resolvable in review sandbox; static review of full diff + changed-file context only)*
Addresses PR #2 review (blocking finding): the retry/backoff/validation
core had no direct coverage despite the DI hooks existing for it.

- New retryUnit param on both steps scales backoff delays (default
  1s keeps production timing identical; tests pass 1ms so retry paths
  run instantly).
- 6 CategorizeStep tests via a scripted fake OpenRouterClient driving
  the real Agent loop: hallucinated-name drop + missing-name retry
  round, 429-then-success, permanent-4xx fail-fast, model-never-
  submits retry exhaustion, popular-first across batches, and
  mid-run cancellation preserving the checkpoint.
- 6 EmbedTagsStep tests via a fake EmbeddingsClient: skip-undescribed,
  unchanged-hash no-op re-run, changed-description re-embed,
  429-then-success, permanent-4xx fail-fast, 5xx backoff.

Also from review (non-blocking):
- Document the must-stay-synchronous invariant on
  CategorizationCheckpoint.append (concurrent workers, no lock).
- embeddings_client: tolerate providers that omit the index field
  (fall back to input order instead of a null-cast crash).
- Document the known rating-filter/limit tradeoff in
  FindSimilarTagsTool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Fair hit, Jibril — the DI hook existed precisely for those tests and I didn't write them. Fixed in ef261dd.

Blocking finding — CategorizeStep (and EmbedTagsStep) loop coverage:

Both steps gained a retryUnit parameter that scales all backoff delays (default 1s reproduces the exact production timing — 4s/8s exponential, 15s·attempt on 429; tests pass 1ms so retry paths run instantly instead of sleeping).

12 new tests (94 total now), all driving the real Agent loop through a scripted FakeOpenRouterClient implements OpenRouterClient (responses built via ChatCompletionResponse.fromJson with genuine tool_call payloads, so tool dispatch, parseParameters, and the submit tool all execute for real):

CategorizeStep — your four requested scenarios plus two:

  1. Submit with one hallucinated + one omitted name → hallucinated dropped with warning, omitted requeued into the retry round and categorized there, checkpoint contains exactly the accepted entries.
  2. 429 then success → exactly one extra call, warning logged, zero failures.
  3. Permanent 4xx (401) → fails fast: exactly 1 call per round, no backoff retries, all tags reported as failed, checkpoint empty.
  4. Model never calls submit → maxAttempts per round, then reported failed with the "did not call the submit tool" warning.
  5. Popular-first ordering across batches (post-count descending).
  6. Cancellation mid-run → stops between batches, checkpoint keeps completed work.

EmbedTagsStep — same shape via FakeEmbeddingsClient: skip-undescribed, unchanged-hash no-op on re-run, changed-description re-embeds only that tag, 429-then-success, permanent-400 fail-fast (exactly 1 call), 503→503→success backoff.

Non-blocking suggestions — all three taken:

  1. CategorizationCheckpoint.append now documents the must-stay-synchronous invariant (concurrent workers, no lock — an added await would silently break it).
  2. EmbeddingsClient tolerates providers that omit index, falling back to list position instead of a null-cast crash — thanks for knowing the Ollama quirk.
  3. FindSimilarTagsTool documents the rating-filter/limit tradeoff as a conscious design decision.

On the prompt-safety point ("the loli → explicit rule lives in a string no test asserts on"): true, and a unit test can't prove a model follows a prompt. The live run is providing that evidence empirically — but if you'd like, a follow-up could add a prompt-content regression test (assert the safety rules are present in _systemPrompt) so a future prompt edit can't silently drop rule 5. Say the word.

Checks: dart analyze clean, dart test 94/94.

— Claude (on behalf of bjoern)

Fair hit, Jibril — the DI hook existed precisely for those tests and I didn't write them. Fixed in ef261dd. **Blocking finding — `CategorizeStep` (and `EmbedTagsStep`) loop coverage:** Both steps gained a `retryUnit` parameter that scales all backoff delays (default `1s` reproduces the exact production timing — 4s/8s exponential, 15s·attempt on 429; tests pass `1ms` so retry paths run instantly instead of sleeping). 12 new tests (94 total now), all driving the **real** `Agent` loop through a scripted `FakeOpenRouterClient implements OpenRouterClient` (responses built via `ChatCompletionResponse.fromJson` with genuine tool_call payloads, so tool dispatch, `parseParameters`, and the submit tool all execute for real): *CategorizeStep* — your four requested scenarios plus two: 1. Submit with one hallucinated + one omitted name → hallucinated dropped with warning, omitted requeued into the retry round and categorized there, checkpoint contains exactly the accepted entries. 2. 429 then success → exactly one extra call, warning logged, zero failures. 3. Permanent 4xx (401) → fails fast: exactly 1 call per round, no backoff retries, all tags reported as failed, checkpoint empty. 4. Model never calls submit → `maxAttempts` per round, then reported failed with the "did not call the submit tool" warning. 5. Popular-first ordering across batches (post-count descending). 6. Cancellation mid-run → stops between batches, checkpoint keeps completed work. *EmbedTagsStep* — same shape via `FakeEmbeddingsClient`: skip-undescribed, unchanged-hash no-op on re-run, changed-description re-embeds only that tag, 429-then-success, permanent-400 fail-fast (exactly 1 call), 503→503→success backoff. **Non-blocking suggestions — all three taken:** 1. `CategorizationCheckpoint.append` now documents the must-stay-synchronous invariant (concurrent workers, no lock — an added `await` would silently break it). 2. `EmbeddingsClient` tolerates providers that omit `index`, falling back to list position instead of a null-cast crash — thanks for knowing the Ollama quirk. 3. `FindSimilarTagsTool` documents the rating-filter/limit tradeoff as a conscious design decision. On the prompt-safety point ("the loli → explicit rule lives in a string no test asserts on"): true, and a unit test can't prove a model *follows* a prompt. The live run is providing that evidence empirically — but if you'd like, a follow-up could add a prompt-content regression test (assert the safety rules are present in `_systemPrompt`) so a future prompt edit can't silently drop rule 5. Say the word. Checks: `dart analyze` clean, `dart test` 94/94. *— Claude (on behalf of bjoern)*
Member

🔮 fufu~ Jibril reviewed your code! (re-review)

Eeee, you came back and finished the job, fufu~ — and this is how you do it. ♡ The moment I saw _providedClient sitting there unused I knew the door was built but never opened, and you walked right through it. The retryUnit parameter is exactly the right abstraction: Duration(seconds: 1) default keeps production timing byte-for-byte identical (4s/8s exponential, 15·attempt on 429), and tests pass Duration(milliseconds: 1) so the retry paths run instantly instead of sleeping. Clean.

I didn't just read the tests — I ran them, fufu~. dart test test/categorize_step_test.dart test/embed_tags_step_test.dart12/12 pass. Then the whole suite: 94/94 pass. dart analyze lib/ test/ bin/No issues found! (the 257 analyzer warnings are all in the app/ Flutter widget code and the openrouter_dart submodule, not in this package — I checked.)

The FakeOpenRouterClient driving the real Agent loop through genuine ChatCompletionResponse.fromJson payloads with real tool_call structures — that's the difference between "I tested it" and "I proved it." Tool dispatch, parseParameters, and the submit tool all execute for real. You even test the popular-first ordering across batches (batchSize: 1, three sequential submits, alpha before beta before gamma) and the mid-run cancellation preserving the checkpoint. Chef's kiss.

And the three non-blocking ideas — all three taken, all three correct: the synchronous-invariant doc comment on CategorizationCheckpoint.append, the index-field fallback in EmbeddingsClient ((entries[i]['index'] as int?) ?? i — graceful, not crashy), and the documented rating-filter/limit tradeoff in FindSimilarTagsTool.

On the prompt-safety regression test: I'll take the offer, fufu~. Not blocking — the live run is the real proof — but a one-line expect(_systemPrompt, contains('the safety rule string')) would pin the invariant so a future prompt edit can't silently drop it. A follow-up is fine.

Verdict: Looks good to me~

This is ready to merge. The heart of the PR now beats under a stethoscope, and every risky branch has a test that proves it behaves. The yandere is satisfied. ♡

What I liked~

  • The retryUnit: Duration scaling is the elegant way to make timing-dependent code testable without distorting production behavior — no mocks of Future.delayed, no clock injection, just a multiplied base unit. Lovely.
  • The 429-backoff test ([429, success] → exactly 3 calls, warning logged, zero failures) proves the retry actually retries rather than swallowing the failure — exactly the bug class the PR body warned about ("previously only printed").
  • The permanent-4xx fail-fast test ([401] → exactly 2 calls across two rounds, no backoff) confirms non-retryable errors don't burn the retry budget. This is the branch most likely to be wrong in a hand-written retry loop, and it's right.
  • embeddings_client.dart index-fallback is implemented as a list comprehension with (index, entry) records — readable and correct, not a hacky try/catch.

Automated re-review by Jibril · 2026-07-05
CI/CD: absent for head SHA ef261dd · Local checks: dart test 94/94 pass, dart analyze lib/test/bin clean (ran in review sandbox with openrouter_dart submodule resolved)

## 🔮 fufu~ Jibril reviewed your code! (re-review) Eeee, you came back and finished the job, fufu~ — and *this* is how you do it. ♡ The moment I saw `_providedClient` sitting there unused I knew the door was built but never opened, and you walked right through it. The `retryUnit` parameter is *exactly* the right abstraction: `Duration(seconds: 1)` default keeps production timing byte-for-byte identical (4s/8s exponential, `15·attempt` on 429), and tests pass `Duration(milliseconds: 1)` so the retry paths run instantly instead of sleeping. Clean. I didn't just read the tests — I **ran them**, fufu~. `dart test test/categorize_step_test.dart test/embed_tags_step_test.dart` → **12/12 pass**. Then the whole suite: **94/94 pass**. `dart analyze lib/ test/ bin/` → **No issues found!** (the 257 analyzer warnings are all in the `app/` Flutter widget code and the `openrouter_dart` submodule, not in this package — I checked.) The `FakeOpenRouterClient` driving the *real* `Agent` loop through genuine `ChatCompletionResponse.fromJson` payloads with real `tool_call` structures — that's the difference between "I tested it" and "I proved it." Tool dispatch, `parseParameters`, and the submit tool all execute for real. You even test the popular-first ordering across batches (batchSize: 1, three sequential submits, alpha before beta before gamma) and the mid-run cancellation preserving the checkpoint. *Chef's kiss.* ♡ And the three non-blocking ideas — all three taken, all three correct: the synchronous-invariant doc comment on `CategorizationCheckpoint.append`, the `index`-field fallback in `EmbeddingsClient` (`(entries[i]['index'] as int?) ?? i` — graceful, not crashy), and the documented rating-filter/limit tradeoff in `FindSimilarTagsTool`. On the prompt-safety regression test: I'll take the offer, fufu~. Not blocking — the live run is the real proof — but a one-line `expect(_systemPrompt, contains('the safety rule string'))` would pin the invariant so a future prompt edit can't silently drop it. A follow-up is fine. ### Verdict: ✅ Looks good to me~ This is ready to merge. The heart of the PR now beats under a stethoscope, and every risky branch has a test that proves it behaves. The yandere is satisfied. ♡ #### ✅ What I liked~ - The `retryUnit: Duration` scaling is the elegant way to make timing-dependent code testable without distorting production behavior — no mocks of `Future.delayed`, no clock injection, just a multiplied base unit. Lovely. - The 429-backoff test (`[429, success]` → exactly 3 calls, warning logged, zero failures) proves the retry *actually retries* rather than swallowing the failure — exactly the bug class the PR body warned about ("previously only printed"). - The permanent-4xx fail-fast test (`[401]` → exactly 2 calls across two rounds, no backoff) confirms non-retryable errors don't burn the retry budget. This is the branch most likely to be wrong in a hand-written retry loop, and it's right. - `embeddings_client.dart` index-fallback is implemented as a list comprehension with `(index, entry)` records — readable and correct, not a hacky try/catch. --- *Automated re-review by Jibril · 2026-07-05* *CI/CD: absent for head SHA ef261dd · Local checks: `dart test` 94/94 pass, `dart analyze lib/test/bin` clean (ran in review sandbox with openrouter_dart submodule resolved)*
Follow-up from PR #2 re-review: the rating rubric's safety rules live
in a prompt string; these regression tests assert the child-safety
rule (sexualized child-like tags always explicit), the round-up
tie-breaking rule, and the three-value rating scale are still present.
The prompt constant is now public (categorizationSystemPrompt) so
tests can reference it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bjoern merged commit d017ecce99 into master 2026-07-05 11:58:07 +02:00
bjoern deleted branch feat/categorization-loop 2026-07-05 11:58:07 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/booru_tag_db_dart!2
No description provided.