NoRefund is three layers, each with one job, talking to each other in one direction only:
frontend/ (React UI)
│ calls window.pywebview.api.<method>()
▼
desktop/ (pywebview shell + JS bridge)
│ calls plain functions, gets dataclasses back
▼
core/ (pure logic: parsing, tokenizing, costing, self-host math)
core/ never imports from desktop/ or frontend/, and desktop/ never contains
business logic — it only marshals arguments in and results out. This split is why every
calculation in the app is unit-testable without a GUI: see
Mathematics for what those calculations actually compute.
Every module here is a set of plain functions and dataclasses —
no side effects, no network calls (with two explicit, user-triggered exceptions below),
no filesystem writes except through core/paths.py.
This is deliberate: a function that only transforms its arguments into a return value can
be tested with a plain assert, no mocking required.
| Module | Responsibility |
|---|---|
parsing.py |
Extract plain text from PDF/PPTX/DOCX/TXT/MD |
tokenization.py |
Tokenizer backends (tiktoken, HuggingFace) — one per model family |
costing.py |
Cost & context-window math (details) |
selfhost.py |
VRAM-fit math for self-hosting (details) |
quantization.py |
Bits-per-weight / KV-cache-bytes-per-element constant tables |
compare.py |
Multi-model comparison for one document or piece of text |
portfolio.py |
Business-volume cost projection (calls/month → $/month) |
service.py |
Orchestrates parsing → tokenizing → costing for a single analysis |
export.py |
Pure string builders for CSV/Markdown export |
report/ |
PDF/HTML report rendering, both built from one shared ReportModel |
models_registry.py |
Loads/queries config/default_models.yaml |
architectures.py |
Loads/queries config/model_architectures.yaml |
hardware_registry.py |
Loads/queries config/hardware.yaml |
resources/ |
Tokenizer cache inventory — what's downloaded, where, how big |
currency.py |
USD → display-currency conversion |
settings.py |
User preferences, persisted as JSON |
secrets.py |
API tokens (e.g. HuggingFace), stored via the OS keychain — never in settings.json |
paths.py |
Packaging-safe path resolution (bundled resources vs. platform data/config/log dirs) |
The only network calls anywhere in core/ are
resources.download_tokenizer() and
currency.fetch_exchange_rates() — both run only
when a user explicitly clicks a "Download" or "Refresh rates" button. This is the
technical backbone of the "your documents never leave your machine" claim: nothing else
in the codebase can reach the network at all.
| Module | Responsibility |
|---|---|
app.py |
Window creation and lifecycle |
api.py |
The js_api bridge — one method per use case, reachable from JS as window.pywebview.api.<name> |
dto.py |
Converts core/ dataclasses into JSON-safe structures |
jobs.py |
Background job tracking (progress, cancellation, results) for long-running analyses |
Every Api method in api.py is wrapped with @_guard, so an exception raised inside
core/ reaches JavaScript as a structured {ok: False, error} object instead of an
opaque promise rejection — frontend/src/lib/bridge.ts
unwraps this envelope so React views never have to think about it; they just get a
result or throw.
Results cross the bridge through dto.to_jsonable(),
which recursively walks a core/ dataclass and turns it into plain dicts/lists/strings
that json.dumps accepts. Field names are preserved exactly — the TypeScript
interfaces in frontend/src/lib/types.ts mirror every
Python dataclass one field at a time. Nothing enforces this match at compile time, so a
contract test — test_dataclass_fields_match_ts_interface in
tests/test_desktop_api.py — parses both sides and
fails the build if they drift. If you add or rename a field on a core/ dataclass
that crosses the bridge, you must update the matching TypeScript interface in the same
change, or this test will fail.
Owns all presentation: formatting, colour thresholds, labels, sort order, empty states. It never computes a cost or a fit verdict from scratch — with one deliberate exception:
| Path | Responsibility |
|---|---|
src/lib/bridge.ts |
The only file where any is allowed — the JS side of the bridge boundary |
src/lib/types.ts |
TypeScript mirror of every core/ dataclass that crosses the bridge |
src/lib/costing.ts |
The one sanctioned duplication of core/ logic — lets the Calculator view recompute cost live as you type, without a bridge round-trip per keystroke. Kept in parity with costing.py by costing.test.ts |
src/views/ |
One folder per feature: Calculator, Parser, Compare, FitCheck, Registry, Resources |
src/components/app/ |
App-specific composite components (Sidebar, Header, ModelSelect, ProcessingDialog, …) |
src/components/ui/ |
Low-level primitives (button, dialog, table, tabs, …) |
src/hooks/ |
useJob, useBridge, useSettings, useTheme, … |
- User drops a file onto the Parser view →
frontendcallswindow.pywebview.api.analyze_file(path, model_id). desktop/api.py'sApi.analyze_file(wrapped in@_guard) callscore/service.py's orchestration function.service.pycallscore/parsing.extract_text(), thencore/tokenization's backend for the chosen model, thencore/costing.py's functions — all pure, all synchronous.- The resulting
AnalysisResultdataclass goes throughdto.to_jsonable()and back across the bridge as JSON. frontend/src/lib/bridge.tsunwraps the{ok, error}envelope; the view renders the typed result.
For a document large enough to need chunking, or a batch of files, the same core call
runs inside a background job (desktop/jobs.py)
so the UI can show progress and support cancellation instead of blocking on one long
js_api call.