-
Notifications
You must be signed in to change notification settings - Fork 0
architecture
This page documents the high-level architecture of AI-Hints and what each module in addon/ does. For user-facing configuration see Configuration and Configuration Reference; for the on-disk format see Data & Storage Format; for runtime files see Data Storage, Files & State; for the JavaScript bridge see Frontend (JavaScript) Reference.
AI-Hints is a single Python package loaded by Anki that combines:
-
A multi-provider AI client (
addon/ai_client.py) that talks to OpenAI-compatible endpoints, Anthropic, Gemini, Groq, OpenRouter and any Custom Provider the user adds, with model fallback, lingering-on-timeout, blacklist cooldowns, per-key rotation, and depth-aware JSON repair. -
A reviewer integration (
addon/reviewer_hooks.py) that hooks the Anki reviewer (front, back, undo/redo, shortcuts, bleed guard), injects the AI-Hints UI viaweb/template.js, and pushes/pulls data through thepycmdbridge. -
A batch queue (
addon/batch_manager.py) that scans decks (with a per-deck incremental cursor) and runs multithreaded generation in the background. -
A card parser (
addon/card_parser.py) that finds the hidden AI-Hints JSON block in a note, parses the field content (cloze-aware), and is depth-aware so legacy raw-HTML payloads cannot corrupt fields. -
A Qt configuration dialog (
addon/config_ui/) built as a Python multiple-inheritance mixin stack β each tab is its ownXxxTabMixinclass, the mainConfigDialoginherits them all and sharesself. -
A mobile sync path (
addon/mobile_sync.py) that copies the lightweightweb/template.jsinto the Anki media folder as_ai_hints_template.jsso AnkiDroid / AnkiMobile / AnkiWeb can render the data without a local add-on ("Zero-Addon" architecture). - Profile-scoped sidecar files for the blacklist, pre-generation cache, per-deck batch cursors, orphan-hint scan state, batch state, and rotating log (see storage.md Β§ 5 for log details).
Data flow at a glance:
ββββββββββββββββ pycmd ββββΆ JavaScript ββββββββββββββββββββ
β Anki UI / β bridge template.js β Reviewer / β
β Config UI β ββββββββββββββββββββββββΆ β Mobile WebView β
ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β addon/ Python β
β reviewer_hooks βββΆ ai_client βββΆ chat provider (HTTPS) β
β β β β² β
β β βΌ β fallback / linger / blacklist β
β β batch_manager ββΆ (multithread) β
β β β β
β βΌ βΌ β
β card_parser βββ in-note JSON block β
β β
β config_io ββββΆ addon/meta.json β
β logger βββΆ <profile>/ai_hints_bin/ai_hints.log β
β sidecars βββΆ <profile>/ai_hints_bin/*.json (atomic) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Entry point. Anki imports this once.
- Registers all reviewer / profile / sync hooks.
- Performs the one-time config migration on profile open (
config_versionbumping, sidecar-file migration from the oldaddon/location,meta.jsonstartup backup tometa.json.bak). - Calls
rebind_file_logging()so the rotating file handler points at the profile-scopedai_hints_bin/ai_hints.log.
The heart of the add-on. Owns:
-
PROVIDER_ORDER,MODEL_SUGGESTIONS,MODEL_FALLBACKS,LEGACY_MODEL_REPLACEMENTSβ all intentionally empty (models come from Fetch Models or are typed by the user; any pre-shipped list rots fast). -
FETCHED_DEPRECATED_MODELSβ per-session cache of model ids the provider's own API marked as deprecated. -
_DEPRECATION_MARKER_KEYSβ the set of field names (deprecation,deprecated,is_deprecated,expires_at,expiresAt,expiration,expirationTimestamp) used to detect live deprecation in OpenRouter/Azure/GitHub-style responses. -
_model_id(item)β small helper that extracts a model id from a list-models entry by tryingidβmodel_idβmodelβname(covers providers like AIHubMix that don't follow the OpenAI schema). -
_collect_deprecated_items(items)β collects the set of model ids marked deprecated by the provider's own API. -
AIClientclass:- Constructor stores
self.configand therequest_timeout/batch_request_timeout/pregen_request_timeout(per-flow base budgets; explicit per-model/per-provider overrides extend but never shrink). -
fetch_models(provider)β single source of truth for Fetch Models:-
local/local_providersfirst βGET {base_url}/models. - Custom providers (
config["custom_providers"][name]) β ifmodels_urlis set use it as-is, otherwise rewrite aβ¦/chat/completionsurltoβ¦/models. Custom provider lookups are case-insensitive. - Built-in
openrouter/gemini/groqand the OpenAI-compatible set (openai,deepseek,mistral,nvidia,sambanova,cerebras,grok). - Filters out embeddings / OCR / moderation / TTS / realtime-audio and experimental
labsmodels via_chat_only_modelsso the fallback list doesn't accumulate always-failing entries.
-
-
_is_provider_ready(provider)β readiness check; the fallback for custom providers without a saved model is to attemptfetch_modelsand accept the result if non-empty. -
_call_*paths β one per provider family:-
_call_anthropic,_call_gemini(and_call_gemini_batch_generate_content),_call_openai_compatible(for the built-ins),_call_custom_provider(for user-added endpoints), and the top-levelgenerate_hints/generate_optionsentrypoints.
-
-
Linger-on-timeout β when a read timeout occurs the request is re-dispatched in the background with an extended deadline (
3 Γ request_timeout, clamped 180β900s,timeout_linger_secondsoverrides). All four inner provider loops spawn the lingering retry on every model β including the first. Race policy ispriority(higher-priority late result wins) orfirst(first usable result wins), vialinger_race_policy. -
Blacklist / cooldown β
_mark_combo_failedadds a(provider, model, api_key)triple toFAILED_COMBOS_CACHEwith a streak-based delay (_cooldown_seconds() Γ streak) and persists viablacklist.json. Model-test runs (log_context.source == "model_test") and offline environments are explicitly skipped so a settings-page test can never poison production cooldowns. -
Key rotation β
_available_api_keys(provider)returns all keys for the provider; per-model loops filter out only the keys currently on cooldown, so multiple healthy keys keep working. -
Transient provider skip β
_skip_transient_provider_errortreats429/503as provider-wide outages during normal generation: the provider is added to_generation_skipped_providersand parked forPROVIDER_OUTAGE_COOLDOWN_SECONDS(60) in the in-memoryPROVIDER_UNAVAILABLE_UNTILmap, and the outer fallback loop immediately tries the next provider (_provider_temporarily_unavailablere-bails until the cooldown lapses). Later generations retry. Gated by_skip_provider_on_transient_outage, which is only enabled inside live generation loops β model tests keep per-key rotation for diagnosis and never mark a provider down. -
Per-thread client in batch mode β batch workers construct their own
AIClient;_request_provider/_request_modelare per-request instance state and must not be shared across threads. -
Reasoning-model content recovery β
_extract_contentand_parse_generation_resultunwrap a top-leveldataenvelope (Cline BYOK) and fall back tomessage.reasoning/message.reasoning_details[*].textwhencontentis empty, so reasoning models don't get reported as "no parseable hints".
- Constructor stores
The longest file. Owns:
- Reviewer hooks:
on_show_question,on_show_answer,reviewer_did_show_question,answerCardhooks, undo (Ctrl+Alt+Z/Ctrl+Alt+Shift+Z), and shortcut wiring. -
Bleed guard β
_apply_results_to_cardre-pushes finished payloads to the frontend 400 ms after the redraw via the identity-checked_push_hint_data_to_frontend; the data lands exactly once on the right card or no-ops if the user moved on. Gated by the[BLEED]/[BLEED-WRITE]debug log lines (see storage.md Β§ 5.3). -
Moved-on generations β clicking Generate and advancing to the next card before the request finishes no longer throws the result away. The bleed-guard suppresses the UI update only; the payload is saved silently (
update_ui=False) and the pre-generation buffer is refilled. -
Per-attempt ownership (generation tokens) β every
generate_hints()run claims a globally unique token for its card (_next_generation_token).on_donechecks_generation_is_current(card_id, token)first and returns immediately if the attempt was canceled (cancel_hintsdrops the token) or superseded by a newer generation, so a slow or lingering completion can never write over a newer result or clear the newer attempt's generating state. The token is retired once the callback takes ownership; the global serial guarantees a later attempt cannot reuse it. -
No-content bail-out without a skip marker β when
get_note_content()yields neither front nor back (a cloze that is momentarily missing while editing, or while Anki reconciles a new cloze),generate_hints()logs and returns without writing anything. It previously persisted{"_skipped": true}, which could leave a freshly created cloze permanently skipped. Pre-generation still calls_trigger_next_pregeneration()so the buffer chain continues. -
Pregen source validation β new pregen cache entries carry
_pregen_front/_pregen_back(stripped before any note write)._pregen_data_matches_cardreloads the card and compares them against the current content before reuse; stale entries are dropped and legacy entries without the snapshot still apply. A cache hit is also refused while a fresh generation for the same card is already in flight. -
Pregen refills β every successful generation (manual or auto) triggers
_trigger_next_pregeneration(). -
Stale cloze detection β
_srcsnapshot stored at generation time, compared against the current cloze text; manually-edited hints/options are not mistaken for stale data. - Hotmouse compatibility β suspends the Review Hotmouse add-on while the Alt+click "Generate with a specific model" popup is open.
-
Provider overrides β
provider_overridesconfig lets a per-card (or per-deck) override reroute generation to a specific provider/model. -
JSON / Parse path β
find_hints_blockis depth-aware and refuses oversized / deeply nested payloads via_safe_loads()(256 KB / 100 levels) before parsing.
- Persistent batch queue (
ai_hints_batch_state.json). -
Watchdog β releases a batch pass when a provider thread lingers after all queued cards have been dispatched. Its 45s grace period is measured from the moment a thread becomes the lone survivor (not from pass start), so a normal "waiting for peers" thread is not mislabeled as hung. After release the pass waits a bounded window for still-in-flight requests to land (
_await_workers_settled), and verification does not requeue cards whose request is still running β preventing duplicate (billed) generations. -
Multithreaded workers β
multithread_providers(default ON) gives each provider worker its ownAIClient; per-workerAIClientis mandatory. -
Cooldown-provider exit β when a worker's models are all blacklisted/on cooldown, it exits the pass if a peer is actively
Processing(redundant idle thread otherwise), and a lone cooldown-stalled provider caps its wait atbatch_request_timeout + 60sbefore ceding to the verification pass β a dead key set can't pin the pass open. Providers are re-created each pass, so exiting just shrinks the idle fleet. -
Per-deck fast-scan cursor β
deck_last_scan_nidis adeck_name -> max note idcursor advanced only after a full, eligible pass (no cards dropped to the safety limit and not a "Selected Cards" selection). New note ids are resolved in Python and queried via the validnid:1,2,3comma-list form because Anki'snid:>/nid:1-5syntax is rejected on 26.x. -
Linger / rate-limit integration β uses the same
_mark_combo_failedrules, but usesis_batch = Trueso the longerbatch_request_timeoutbase budget is honored and per-model/per-provider overrides only extend, never shrink. -
Atomic sidecar writes β every sidecar is written via temp-file +
os.replace.
- Locates the hidden
<div class="ai-hints-json">block in a note's field. -
Depth-aware scanner β replaces non-greedy
.*?</div>regexes with a stack-based scanner; legacy raw-HTML payloads cannot corrupt fields on update/clear, and unterminated blocks are skipped instead of swallowing the whole field. -
Stale cloze handling β keyed
cNentries whose{{cN::β¦}}tag is missing are purged automatically on save (orphan purge). -
Cloze isolation β cloze cards ignore keyed JSON belonging to another cloze ordinal (
c2card with onlyc1data β no AI data shown). -
Prefers manual edits β
_srcis the immutable source of truth; manually editedcorrect_answer/optionsare not treated as stale.
- The merge-safe
meta.jsonwriter. Two paths:-
addonManager.writeConfig(default, non-pretty) β serialized through a module-level lock; the on-disk config is the baseline, incoming keys win only when explicitly present, andapi_keysalways keeps on-disk values for any key the incoming snapshot leaves empty. -
write_pretty_config_preserve_keysβ same merge-safe baseline, pretty-printed; copies the previousmeta.jsontometa.json.bakbefore every overwrite (in addition to the startup backup).
-
-
read_meta_config()β reads directly fromaddon/meta.jsoninstead of Anki's name-basedgetConfig()so a package-name mismatch can never silently fall back to the default template. - Every save is logged with
[addonManager(preserve-merge)](or[direct-file(preserve-merge)]) pluson_disk_keys,written_keys,api_keys,scan_cursorscounts so any future config-loss event is immediately diagnosable from the log.
- Single canonical file handler; the path is resolved via
_log_path()to<profile>/ai_hints_bin/ai_hints.log. -
rebind_file_logging()is called fromaddon/__init__.pywhen the profile opens, so the on-disk file, the Logs tab, and Clear Log all share the same path. -
RotatingFileHandlerwithmaxBytes=5*1024*1024,backupCount=3β see storage.md Β§ 5 for the full file/level/prefix reference. -
log_contextβ a small context object carryingsource(model_test/ batch / pregen / lingering / etc.). The client consults it to skip linger retries, skip blacklist increments, and apply flow-specific timeouts.
- One-click install / remove of
_ai_hints_template.jsinto the Anki media folder. - Waits up to 2 minutes for the sync/profile to become available (so One-Click Install right after Remove from All Cards doesn't fail with a generic "Failed to sync script file to media folder.").
- Raw
bytes(notBytesIO) tocol.media.write_data, with a fallback to a plain file write if the media-tracker API rejects or lacks the call.
- The lightweight JavaScript renderer used in the desktop reviewer and on mobile.
- A single unified script; no separate mobile build. Reads runtime globals (theme, cloze ordinal, mobile config), receives data via
pycmd, and calls back to Python for show-answer, copy, regenerate, skip, and Undo/Redo. - Documented in detail at Frontend (JavaScript) Reference.
The settings dialog uses a Python Multiple-Inheritance Mixin pattern. Each tab is implemented as a standalone class XxxTabMixin: in its own file. The main ConfigDialog in main_dialog.py inherits from all of them:
class ConfigDialog(QDialog,
GeneralTabMixin,
ProvidersTabMixin,
AdvancedTabMixin,
ShortcutsTabMixin,
BatchTabMixin,
SupportTabMixin,
LogTabMixin,
MobileTabMixin):This means every mixin method shares the same self (including self.config, self.tabs, all widget refs) with no awkward cross-references or parameter passing. Adding a new tab means:
- Create
addon/config_ui/tab_xxx.pywithclass XxxTabMixin. - Add it to the inheritance list in
main_dialog.py. - Call
self._create_xxx_tab()insidesetup_ui().
| File | Tab | Purpose |
|---|---|---|
main_dialog.py |
β | Dialog shell, save/load, timers, tab routing, custom-provider data model (custom_providers_data), on_fetch_models / on_add_custom / on_edit_custom handlers, mobile install. |
tab_general.py |
General | Master toggles (generate hints/options), system prompt, mathjax format, auto-show defaults (front + back), pre-generation, debug logging, log auto-clear, support links. |
tab_providers.py |
AI Providers | API keys, Fetch / Fetch All, per-provider model + fallback list, Local provider, global Advanced Global Fallback Priority. |
tab_advanced.py |
Advanced | Per-deck scanning controls, additional system instructions, raw JSON editor, internal state JSON. |
tab_shortcuts.py |
Shortcuts | Keyboard shortcut bindings. |
tab_batch.py |
Batch | Batch tab β initiate queue, dedicated pause/resume toggle, per-deck source, force-full-scan checkbox, multithread toggle, batch logs, batch status table, per-job progress. |
tab_mobile.py |
Mobile | One-click mobile install/remove, mobile config (auto-show, font size, emojis, extra buttons), test card. |
tab_support.py |
Support | About / donation links. |
tab_logs.py |
Logs | Live log viewer β streamed from ai_hints.log in a background thread; Level + Source + free-text filters; matched / total line counts; Clear Log. |
widgets.py |
β |
ProviderRowWidget, CustomProviderDialog, ADDON_PACKAGE constant (__name__.split(".")[0]), and a small temp_config helper that injects custom_providers / local_providers so Fetch / Test works for in-memory custom providers before the dialog is saved. |
- Scoped compatibility patches for two specific third-party add-ons (Anki Terminator's webview and PiperTTS bulk-generation). Both install only when the host add-on is detected; otherwise no-op. See
anki_terminator_patch.pyandtts_addon_patch.py.
-
addon/json_repair/β robust AI response JSON parser; refreshed viaupdate_deps.py. -
addon/latex_fixer/β LaTeX/MathJax normalization engine; a Git submodule, butupdate_deps.pysyncs its core files without managing submodule pointers manually. -
addon/Support/β support / donation assets.
Every generation (explicit review, pregen, batch, and, with override_model, the per-card picker) funnels through AIClient.generate_hints / generate_options β _generate(). The same nested tier loop runs the show, so a fix applied here fixes every flow.
Fallbacks are a three-level onion, innermost first:
-
Key rotation (model level) β
_call_*loops the provider's API keys for the model, skipping only the keys currently on cooldown (_available_api_keys). Multiple healthy keys keep working; a failing key is rotated to the next one. -
Model fallbacks (provider level) β
_call_providerwalks the provider's enabled fallback list in order (first row = active model, then fallbacks). A blacklisted model is skipped, not tried. -
Provider fallbacks (generation level) β one of two top loops in
_generate():-
Global flat list (
use_global_model_priority): iteratesglobal_model_priorityβ explicit(provider, model)rows top-to-bottom β when it is enabled and nooverride_provider/test is active. -
Standard per-provider list:
_candidate_providers(primary_provider)yields ready providers in priority order (primary first, then fallbacks); for each,_call_providerruns its internal tier-2 loop.only_this_provider(batch) restricts it to the single primary.
-
Global flat list (
A generation succeeds at the first tier that produces a usable result and walks no further. Aggregation of skip conditions down the layers: each outer loop re-checks network_failed_providers, _generation_skipped_providers (transient outage), _provider_temporarily_unavailable (60s cooldown), disabled providers, disabled models, readiness, and the blacklist before calling anything.
A read timeout never throws the request away β it is kept alive in the background while fallback continues (_LingerPool):
-
Trigger β a timeout at any tier:
HTTPError408/504, or aURLError/ timeout exception matched by_is_read_timeout_error, including on the first model of a provider. Pure read timeouts are never blacklisted (slow β broken). -
Re-dispatch β
_LingerPool.spawn(order, provider, model)re-issues the same request on a daemon thread with the extended deadline_linger_timeout():timeout_linger_secondsif set, else3 Γthe effective request timeout clamped to[180, 900]s. The lingering copy clearsmodel_timeouts/provider_timeoutsand pins bothrequest_timeoutandpregen_request_timeoutto the linger budget, so a per-model/per-provider override can never cut a lingering attempt short. -
Hooks β both top loops (global flat list and per-provider) host the pool in
_active_lingerwhile walking candidates; the inner per-provider model loops consult it, so a timeout absorbed at tier 1/2 still reaches the pool. Before starting the next candidate the loop offers any finished result viaclaim_ready(max_order)and, on success, prefers a higher-priority attempt still in flight viawait_for_any(max_order). -
Result selection β
_claim_bestreturns the earliest-order(highest-priority) ready result instantly without waiting.wait_for_anyblocks up tolinger_timeout + 15s, draining to the earliest finished attempt; it aborts early on Emergency Stop, network loss, or when no attempts remain pending, and polls everyPOLL_INTERVAL(0.5s). -
Race policy β
linger_race_policy:"priority"(default) makes a fresh success yield to still-running higher-priority attempts for their extended deadline (amber "β³ Waiting for higher-priority modelβ¦", stoppable);"first"settles on the first usable result instead. -
Rescue β when every foreground candidate has failed (
last_exceptionset orhas_pending()β the pending-only branch matters for single-candidate flows like batch'sonly_this_provider), generation waits out the lingering attempts instead of returning empty:all candidates failed; using late result from β¦. -
Scope & config β
linger_on_timeout(review/pregen, default on),timeout_linger_seconds(override),linger_race_policy("priority"/"first"),batch_linger_on_timeout(batch, default off β a lingering retry could otherwise outlive the job by minutes holding a thread/HTTP client). Disabled for single-model tests (log_context.source == "model_test");cancel()discards results on close/stop.
generate_hints / generate_options
β
βΌ
Tier 3: for provider [or (provider,model) row in global list] in priority order:
ββ disabled / network-failed / generation-skipped / cooling-down β SKIP provider
ββ provider not ready / model disabled / model blacklisted β SKIP row
βΌ
Tier 2: for model in provider's enabled fallback list (model_test honor override):
β
βΌ
Tier 1: for api_key in provider's keys (skip keys on cooldown):
β
ββ HTTP 429 / 503 (normal generation)
β β _skip_transient_provider_error(): provider parked 60s
β (PROVIDER_UNAVAILABLE_UNTIL) + added to _generation_skipped_providers;
β returns empty result β the enclosing tier-3 loop moves to the NEXT
β provider/row. The provider is retried on a later generation.
β β no per-key or per-model burning happens
β
ββ HTTP 429 (model_test)
β β rotate to next key with rate-limit backoff sleep; on last key re-raise
β (diagnosis keeps working; a test NEVER marks the provider down)
β
ββ HTTP 408 / 504 or read timeout (URLError/TIMEOUT/ReadTimeout)
β β model_timed_out: request re-dispatched as a background LINGER retry
β (3Γ extended deadline, clamped 180β900s) in _LingerPool; tier 2/3
β fallback continues immediately. Pure read timeouts are NEVER
β blacklisted (slow β broken).
β
ββ other HTTP error (401 / 400 / 422 / 5xx β¦)
β β _extract_retry_delay() + _mark_combo_failed(): streak-based cooldown
β (_cooldown_seconds() Γ streak, persisted in blacklist.json);
β next key β next model β next provider.
β
ββ network unreachable (socket.gaierror / ECONNREFUSED / DNS)
β β provider added to network_failed_providers for this run; in batch the
β worker parks on π Offline; in review the outer loop tries the next
β provider. Real failures still cool down; a false-positive verdict can
β be bypassed via β‘ Force Start or ignore_network_checks.
β
ββ odd-but-good response shape
β β _extract_content() unwraps a top-level data envelope and falls back to
β message.reasoning / reasoning_details before giving up; malformed JSON
β goes through json_repair. Only then is a reply considered unparseable.
β
ββ unparseable response
β warning + _mark_combo_failed() β next model in the list
On partial success at any tier, linger_race_policy: "priority" (default) may hold the result while a still-running attempt from an EARLIER (higher-priority, usually smarter) row runs out its extended deadline β amber "Waiting for higher-priority modelβ¦" β and its late result wins over the later success (_LingerPool.claim_ready / wait_for_any). Set "first" to return the first usable result immediately instead. On total failure across all tiers, the generator waits out any lingering attempts (rescue: "all candidates failed; using late result from β¦") and only then returns empty or re-raises last_exception, which the caller surfaces as a UI error / batch retry notice.
| Exception / status | Classification | Handling |
|---|---|---|
429 Too Many Requests / 503 Service Unavailable, normal generation |
Provider-wide transient outage (TRANSIENT_PROVIDER_ERROR_CODES) |
Skip provider for 60s (PROVIDER_OUTAGE_COOLDOWN_SECONDS); move to next provider; retried next generation. No per-key/model burning. |
429 during a model test |
Rate limited | Rotate keys with backoff sleep (_rate_limit_backoff_seconds()); last key re-raises. Never marks provider down. |
408 Request Timeout / 504 Gateway Timeout / read-timeout exception |
Timeout (_is_read_timeout_error) |
Spawn linger retry (extended deadline) + continue fallback; never blacklisted. |
Other HTTPError (401/400/5xx β¦) |
Hard failure |
_extract_retry_delay() + _mark_combo_failed() β streak-based cooldown; next key/model/provider. |
Host unreachable (DNS / refused / gaierror) |
Network failure |
_is_host_unreachable_error() β provider marked network-failed (batch parks π Offline); next provider in review; blacklist skipped when offline (see _is_offline_environment). |
| Unparseable JSON / empty content | Quality failure |
_extract_content reasoning recovery β json_repair β warning + _mark_combo_failed(); next model. |
U+FFFD replacement chars in output |
Corrupt output | Detected and discarded; fallback retries next model instead of writing garbage. |
Guards: read timeouts never blacklist; model-test runs can never poison production cooldowns (log_context.source == "model_test"); the transient-provider skip is gated on _skip_provider_on_transient_outage, which is only enabled inside live generation loops.
-
All Qt UI code runs on the main thread. Background work uses
threading.Thread+mw.taskman.run_on_main(). -
No blocking I/O inside
__init__or tab constructors β defer withQTimer.singleShot(0, ...). -
Per-thread
AIClientin batch mode β each batch worker constructs its ownAIClient; per-request state is not thread-safe. -
Worker threads must not touch the collection β verification and final-stats passes hop to the main thread via
taskman. -
Atomic state writes β every sidecar (
blacklist.json,pregen_cache.json,batch_scan_cursors.json,orphan_scan_state.json,ai_hints_batch_state.json) is written via temp-file +os.replace.
- No hardcoded model names. See conventions in AGENTS.md.
-
Respect
is_batch/log_context.source. Test endpoints and model-blacklisting must skip lingering retries and never poison production cooldowns. -
merge-safe config writes. See storage.md Β§ 1 and
config_io.py. - Profile-scoped storage. See storage.md for the canonical paths and sidecar layout.
-
Bleed diagnostics. Enable
debug_loggingand grep for[BLEED]/[BLEED-WRITE]lines.
- Compatible with Anki 25.x (Qt 6, PyQt 6, Python 3.10+).
- All Qt UI code must run on the main thread. Background work uses
threading.Thread+mw.taskman.run_on_main(). - No blocking I/O inside
__init__or tab constructors β defer withQTimer.singleShot(0, ...). - Keep
ADDON_PACKAGEderived from__name__.split(".")[0](not hardcoded) to support both dev and production installs. - Atomic state writes only β temp file +
os.replacefor every sidecar JSON. - No comments unless asked.