The inference service declared 'image: psyc-trainer' but no build: stanza,
so 'docker compose build inference' was a silent no-op and 'compose up
inference' tried to pull from a registry that doesn't exist (denied).
Added build context pointing at Dockerfile.train so future production
deploys can rebuild it via the compose lifecycle instead of needing a
manual 'docker build' on the side.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-discovered fixes after first deploy:
- CISA's CDN was 403'ing the "psyc/0.1 (defensive CTI; hackathon
prototype)" User-Agent from the cloud.neuronetz.ai exit IP. Switched
to a Mozilla-compatible UA that identifies us honestly while passing
the CDN's UA filters. Overridable via PSYC_HTTP_USER_AGENT.
- fetch-all aborted on the first HTTPStatusError, so a CISA hiccup
killed the threatfox/malware-bazaar/otx legs that come after. The
outer loop now catches any exception per-source, logs a skip, and
moves on. Single-source failures no longer poison the rest of the
pull.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On AnD0R the reverse-proxy lives on the 'backend' docker network; on
cloud.neuronetz.ai it's 'neuronetz_default'. With a hardcoded name the
cockpit ended up on a network the prod proxy couldn't see and routing
silently dropped. Network is now overridable via PSYC_PROXY_NETWORK in
.env (default 'backend' keeps dev working).
On prod, set PSYC_PROXY_NETWORK=neuronetz_default in .env before the
next compose up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the two env vars nginxproxy/acme-companion looks for to issue +
auto-renew the TLS cert for psyc.neuronetz.ai. LETSENCRYPT_EMAIL is
interpolated from the prod .env (LETSENCRYPT_EMAIL=...) with a sensible
fallback so dev / local deploys don't fail on the variable being unset.
.env.example documents the var.
Requires the proxy stack to (a) have acme-companion alongside
nginx-proxy with shared certs/vhost.d/html volumes and (b) publish :443.
psyc-side change only — no app code touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reinstating the auto known_hosts entry on first deploy. Clear scope:
host trust (TOFU known_hosts entry) is automated — same as
'ssh -o StrictHostKeyChecking=accept-new' would do; identity keypairs
(~/.ssh/id_*) are never generated/copied/modified by deploy.sh.
PSYC_SKIP_HOST_TRUST=1 disables the auto-trust step if you'd rather
verify fingerprints manually.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User asked the script not to touch their SSH config. Reverted the
auto-ssh-keyscan; the script now only READS ~/.ssh/known_hosts (via
ssh-keygen -F) and, when the entry is missing, exits with explicit
manual instructions for verifying the host key and registering an
identity key in Gitea. Identical behavior on the happy path; clearer
diagnostics on the unhappy path; zero modification of ~/.ssh anywhere.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A fresh prod box has never SSH'd to gitea.neuronetz.ai before, so the
first 'git clone' failed with 'Host key verification failed'. The
script now parses the git remote URL to extract host+port, and on the
prod box does an ssh-keyscan into ~/.ssh/known_hosts before the clone
when the entry is missing. TOFU — if you want to verify the fingerprint
out-of-band, pre-populate known_hosts manually and the script will see
the entry and skip the scan.
Also: if the clone still fails after the host key is trusted (likely a
missing SSH key on Gitea side), the script now prints a clear hint
pointing at where to register it. Supports both ssh://user@host:port/
and user@host: URL forms.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/deploy.sh pushes the current branch to origin, ssh's into the
prod box (neuronetz@cloud.neuronetz.ai:/home/neuronetz/docker-public/
neuro-psyc by default — overridable via env vars), clones-or-pulls,
ensures the external 'backend' docker network exists, runs docker
compose up -d --build (+ --profile gpu if PSYC_PROD_GPU=1), and then
verifies the cockpit is healthy both on prod-internal :8767 and at the
public URL — so the script ends knowing whether the page is up.
Refuses to touch prod's .env (warns + copies .env.example if missing,
so you can edit it manually). Never transfers data/ or adapters
(gitignored; prod fetches its own corpus). Color output, idempotent,
safe to re-run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Click a container, switch, or the host in the graph: the node gets a
pulsing accent highlight and a detail panel below renders its full
logical overview — for a container, image/status/per-network IPv4+MAC+
gateway + published ports + all ports; for a switch, driver/scope/
subnet/gateway/internal + attached-container table; for the host, OS/
CPUs/container counts. Cross-jumps in the spec tables click through to
the related node (the graph re-selects too). Click the × button or any
empty graph area to deselect.
Drag is distinguished from click by a 4px move threshold so dragging
nodes doesn't accidentally open the panel. HTML escaped end-to-end.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three layout modes (Force/Hierarchical/Radial) switch the topology graph
between organic force-directed, host→switches→containers tiers, and a
radial host-in-center wheel. Animated traffic flow on edges via
stroke-dashoffset marching: wires to running containers flow cyan,
host↔switch uplinks pulse slower in violet, container→host publish
edges flash fastest in amber-dashed; dead edges (exited containers) fade.
"traffic flow" checkbox kills the animation globally; layout choice
auto-fixes nodes (drag still overrides). Respects prefers-reduced-motion.
/admin/docker now opts into a wide layout via a new body_class block on
base.html — content area uncaps, SVG sized min(74vh, 880px), network
cards get wider min columns. Other pages keep the 1280px reading cap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New tecnativa/docker-socket-proxy sidecar exposes only GET on
containers/networks/info/ping; POST and DELETE are blocked. The cockpit
queries it over the backend network — /var/run/docker.sock is never
mounted into a web-facing container.
cockpit/docker_view.py normalizes the daemon view: containers carry
per-network IP/MAC + published_ports; networks carry subnet/gateway from
IPAM; host_info pulls /info (degrades gracefully). topology() returns
the combined snapshot.
/admin/docker (admin-gated): a force-directed graph (pure SVG +
vanilla JS, ~280 lines) renders the complete setup — a host node,
switch nodes with subnet labels colored by driver, container nodes
colored by state, member wires labeled with the container's IP on that
network, uplinks from non-internal switches to the host labeled with
the gateway, and dashed publish-edges from containers to the host for
their published ports. Drag to rearrange, scroll to zoom, re-settle
kicks the physics. Below the graph: containers table + grouped network
cards as a textual mirror. 12 docker_view tests; verified live (32
containers, 11 switches, real subnets + gateways).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Once you're signed in, the topbar gains:
- An "Admin" nav link (cyan-accented, with a small shield-lock SVG)
appearing only when an admin session exists.
- A glowing chip on the right showing "● ADMIN · <who>" with a
separated lock button (⏻) for sign-out. Pops in with a scale-bounce
entrance, then a slow box-shadow pulse so it stays noticeable without
being annoying. Respects prefers-reduced-motion.
- Both rendered via request.session in base.html, so every page gets
them automatically with no per-route plumbing.
Removed the now-redundant in-panel "lock ⏻" from admin.html — the
topbar owns logout, the admin page header shows enrolled count instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the single shared admin secret with named per-member
enrollments — no more "one key for everyone". First visit bootstraps an
'owner' slot; further members are added from inside the admin panel,
each scanning their own QR. Login accepts a code matching any active
member and records who got in. Offboarding is a per-member revoke: that
person's codes stop immediately, everyone else is unaffected, nobody
re-enrolls. Old single-secret state migrates to an 'owner' member.
Admin panel gains an Access Control table (member, enrolled, last used,
revoke) + add-member form that shows the new QR once. 7 tests including
revocation isolation; verified the full lifecycle live (bootstrap → add
→ authenticate → revoke → rejected while owner persists).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaced the plain login box with a proper restricted-zone screen:
ambient breathing radial backdrop, frosted glass card with a glowing
top accent + animated HUD corner brackets, an SVG shield-lock emblem in
a pulsing glow ring, a SECURE CHANNEL status line with a blinking dot,
the QR in a white frame with a sweeping cyan scanline, a large
OTP-style monospace code field, and a gradient UNLOCK button. Honors
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A hidden /admin path (not in nav) protected by a TOTP secret you enroll
by scanning a QR into Google Authenticator / Authy, then entering the
rotating 6-digit code. adminauth.py persists the secret + session key
under DATA_DIR (gitignored); the QR only renders until first successful
verification so the provisioning secret isn't perpetually exposed.
SessionMiddleware carries a 60-min admin session. This becomes the
secured control center the rest of the system gets built into.
Verified end-to-end: gate renders QR, the live code authenticates and
sets the session, the dashboard renders only with the session cookie,
a wrong code is rejected, and an unauthenticated request never leaks
the dashboard. Deps: pyotp, qrcode[pil], itsdangerous.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Surfaces the stage-23 index in the UI. New /lookup page: paste any
indicator (IP/domain/URL/hash/CVE) → red KNOWN-BAD banner with the
matching cases/feeds/severities, or green clean banner. New
/export/blocklist endpoint returns deduplicated plain-text indicator
lists (all or high+ severity) for firewall/DNS/SIEM ingestion, linked
from a download table on the lookup page. Lookup added to topbar nav.
Verified live: lookup of a real corpus IP returns the OTX case;
8.8.8.8 returns clean; blocklist endpoint emits 26 high-severity IPs
with a descriptive header line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Worker Mesh Classifier badge was hardcoded "psyc-v4" — it kept
claiming the old model even after the v5 swap, making it look like
nothing had changed. Now derived dynamically from the inference
server's reported adapter via inference.adapter_name(); shows the real
version (psyc-v5) or "rules" when the server is down, so it can't go
stale on the next swap.
Also refreshed the cases page intro/help text to name all six feeds
(URLhaus, CISA KEV, Feodo, ThreatFox, MalwareBazaar, OTX) instead of
the original three.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
v5 trained on 598 ex/task (20× v4's 30), with --defang-frac 0.5 over
the new ThreatFox + MalwareBazaar + OTX corpus. Final train_loss 0.3225
vs v4's 0.7397 (56% reduction), 60m20s wall clock on a 3090.
Live eval before swap:
- severity (botnet, ONLINE): v4 high / v5 high — tied, both correct
- ioc_extraction with defanged input (hxxps://, [.], (.), [dot]):
v4 kept hxxps:// in output (failed canonicalization)
v5 returned canonical https:// — defang training paid off
- ioc_extraction on real OTX-style prose (never trained on this shape):
v5 cleanly extracted 2 domains + 1 IP + 1 SHA256 + 1 CVE
Cockpit /api/inference-status confirms the swap:
{"online":true,"adapter":"/data/adapters/psyc-v5/final"}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /pulses/subscribed endpoint enumerates every curated feed a fresh
account is auto-subscribed to. On its own that's enough to 504 from
OTX's backend regardless of client timeout. Narrowing by
modified_since=now-7d brings the response back to a single-second fetch.
Also: _http now accepts params + per-call timeout overrides (OTX uses
120s). The CLI --limit still slices post-fetch.
Verified live: 10 OTX pulse-cases ingested, each carrying real
paragraph-form descriptions (Mirai, macOS Stealer, FlowerStorm PhaaS,
Vidar v1.5, manufacturing intrusion) — exactly the real-prose source
the IOC extractor's been missing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real CTI prose defangs IOCs (1[.]2[.]3[.]4, hxxp://, evil[dot]com) so they
don't auto-link in email/chat. A model trained only on canonical inputs
will fail to extract them.
New lines/defang.py: defang_ip, defang_domain, defang_url, defang_text —
four dot-styles ([.], (.), [dot], {.}) plus protocol defanging
(http→hxxp, https→hxxps). Each occurrence picks its style independently
since real advisories don't keep one style across paragraphs.
train.BuildOptions adds defang_frac (default 0.0) and seed; build()
threads options + a seeded Random through the example builders so
the augmentation is reproducible. Only _ex_ioc_extraction reads it
today — output stays canonical so the model learns messy→canonical.
CLI: train-build and train-build-all gain --defang-frac and --seed.
8 new tests including a frac=1.0 / output-canonical integration check.
The pipeline runs but is dormant at defang_frac=0.0 — psyc-v5 dataset
build will set 0.5 once OTX cases land.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Live test against abuse.ch revealed two issues with the stage-19 wiring:
- ThreatFox returns `ioc` (not `ioc_value`) and `first_seen` (not
`first_seen_utc`) — older field names from stale docs. Parser now reads
the real names and falls back to the old aliases defensively. Also
captures `malware_malpedia` (per-family writeup URL) and
`threat_type_desc` for richer downstream prose.
- MalwareBazaar's API expects form-encoded bodies, unlike ThreatFox's
JSON. Extended _http with form_body=; MB fetcher switched to it.
Verified live: 10 ThreatFox cases landed with mixed botnet/malware
classification (4/6 split from threat_type signal — first real
incident-type diversity from a single feed). 10 MalwareBazaar cases
landed with sha256+sha1 hash observables and exe/file_type metadata.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CERT-Bund (authority) requires_approval by default; PSYC_REQUIRE_APPROVAL=1
forces every routable submission through the queue. Courier branches at
execute_routes: approval-required → freeze payload + enqueue, no HTTP; else
submit directly as before. Approve dispatches the frozen payload to mock-cert
and writes the ledger row (detail=approved_by=…); reject writes a ledger row
with the reviewer's reason. CLI: queue / approve / reject. Cockpit /queue
page with POST approve / reject and counts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three load-bearing operational pieces before any new features:
* .env.example committed, .env gitignored — per-developer API keys
(THREATFOX_AUTH_KEY, OTX_API_KEY, NVD_API_KEY) ready for the registrations
ahead; python-dotenv loads it in the venv CLI; compose picks it up via
env_file: .env on the cockpit service.
* Cockpit /api/inference-status endpoint + a topbar status chip that polls it
on page load — "model · live" green when up, "model · offline" amber when
the inference server is unreachable. No more manual checking. Compose also
gains a healthcheck on the inference service (applies on next recreate).
* New `psyc backup` command — tars the audit trail (db + sealed packages +
recipient keys + ledger + datasets) to data/backups/psyc-data-<ts>.tar.gz.
Excludes the HF model cache, mock-cert receipts, and the re-trainable
adapters — the goal is the irrecoverable evidence, not bulk artifacts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removed the neuronetz.ai hub link from the topbar; the footer now reads
"powered by neuronetz.ai". Topbar keeps the NN-sc app icon only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bumped from 32px to 44px so it reads at the scale of the brand logo beside it,
with a faint cyan glow to seat it in the cockpit chrome.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
psyc carries its neuronetz application identity — the NN-sc (Security/Control)
icon replaces the plain text family tag in the topbar. The icon was cropped
from the design-kit NN-* grid and its baked-in checkerboard flood-filled to
transparent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
38 tests covering the pure worker-line logic: Classifyline rules, Routeline
TLP/country/incident-type gates, Sealine seal/unseal round-trip, Proofline
confidence scoring, Mapline CVEResolver escalation, Trainline dataset
well-posedness (the v1/v3 input-signal bugs are now regression-guarded), and
the Scoutline feed parsers. pytest added as a dev extra.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mapline gains kev_cve_set() (the known-exploited CVE set, derived from the
already-ingested KEV cases) and resolve_cves() — flags any of a case's CVEs
that are known-exploited and escalates a non-KEV case's severity to HIGH when
one surfaces. Folded into map-case / map-all / demo.
Honest limit: only KEV-sourced cases carry CVEs today, so the cross-check is
largely self-referential until a CVE-bearing source or model extraction feeds
CVEs into other cases — the escalation path is verified against a synthetic case.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New lines/proof.py: IOCChecker validates observables are well-formed,
FreshnessChecker ages observed_at into new/recent/stale/resurfaced,
ConfidenceScorer sets the Admiralty source-reliability code and an overall
confidence level. Fills case.confidence (previously left at defaults).
CLI prove-case / prove-all; folded into psyc demo. Logo glow strengthened
to a solid-cyan drop-shadow so it reads against the dark topbar.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cyber feel, kept restrained: a faint cyan grid on the app background, HUD
corner-brackets on every panel, a soft glow behind page titles. Each cockpit
view gains a collapsible help block — how to use it, what you're seeing, and
why it matters — alongside the one-line purpose intro.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Logo background stripped to transparent (it was a flattened export with the
checkerboard baked in) so the cyan drop-shadow glow now hugs the shield.
Space Grotesk for headings + chrome (the neuronetz family typeface); data
stays monospace. Topbar gains the family framing — an "NN-sc · Security"
tag and a link to the neuronetz.ai hub. Every cockpit view now carries a
one-line page-intro explaining its purpose.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
psyc now runs as a single docker compose stack — cockpit + mock-cert +
(gpu-profile) inference — on the shared external `backend` network, fronted
by nginx-proxy as psyc.neuronetz.ai. Replaces the venv processes + one-off
docker run. MOCK_CERT_BASE and INFERENCE_URL are now env-configurable
(PSYC_MOCK_CERT_URL / PSYC_INFERENCE_URL) so the cockpit reaches the other
services by compose service name. Restart policies + healthchecks. deploy.md
rewritten to match.
Verified: cockpit serves directly and via the proxy; the full
scout→…→courier→ledger chain runs over the compose network.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lean python:3.12-slim platform image (cockpit + CLI + workers, 214 MB — no GPU,
no model). docker-compose.yml runs cockpit + mock-cert on a persistent
psyc-data volume. DATA_DIR is now overridable via PSYC_DATA_DIR so the
container's data path is explicit. docs/deploy.md covers Proxmox hosting,
first-run ingestion, and the honest caveats — no built-in auth (deploy behind
the perimeter), the GPU model server is separate, egress-proxy config.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
psyc demo now closes with cockpit links pointing at the Worker Mesh and
reports whether the live model server is up. README rewritten to current
state — Worker Mesh, inference server, model-in-operation, the three
services, accurate code layout. Adds docs/demo.md, a one-page run-sheet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Classifier bot in the Worker Mesh now shows the real fine-tuned model's
severity verdict beside the rule's. cockpit/inference.py calls serve_model.py
over HTTP; if the server is down it returns None and the bot silently falls
back to rules — the mesh never breaks. SEVERITY_INSTRUCTION + severity_features
are shared from lines/train.py so the live prompt matches what the model
trained on. The model is now genuinely in operation, not animation over rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/serve_model.py — FastAPI in the CUDA container, loads base Qwen3.5-4B
+ a psyc adapter once and serves POST /infer. Lets the cockpit (no torch in
its venv) put a real fine-tuned model behind a Worker Mesh bot over HTTP.
Dockerfile.train gains a fastapi + uvicorn layer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
_ex_severity_classification copied only URLhaus's `url_status` into the task
input, so Feodo botnet cases lost the online/offline signal their label
depends on — v3 severity eval stuck at 7/8 with one unlearnable example.
The input now carries a normalized `status` (url_status or status), matching
the field classify.py already uses for the label.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the passive journey timeline with an active worker mesh: seven robot
agents (Scout, Classifier, Mapper, Sealer, Router, Courier, Ledger), each with
a geometric SVG body, glowing antenna + reactor core in its own accent colour,
expressive awake/asleep faces, and an idle float. A case token travels the
conduit; as it reaches each bot the bot wakes (activation ring + work-flash),
performs its action, and speaks its real answer in a speech bubble. Asleep
bots are steps that did not occur for this case. Replay button re-runs it.
Every answer is real persisted data — the bots animate, they do not fake.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New /cases/{id}/journey view tells a case's story as it moved through psyc:
Detected → Classified → Located → Sealed → Routed → Submitted → Recorded.
Each beat is reconstructed from real persisted state (classification, sealed
package, planned routes, ledger rows) — a replay of recorded events, not a
script; beats that did not happen render as "pending". CSS-staggered reveal
with pulsing timeline nodes, on-brand cyan/navy, replay button.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The ExampleBuilder guard checked urls/domains/ips/hashes but not cves, so
CISA KEV cases (CVE is their only observable) were silently dropped from the
ioc_extraction dataset. Now they produce CVE-extraction examples.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scoutline is now a source registry: urlhaus, cisa-kev, feodo. CISA KEV brings
exploit/CVE cases, Feodo Tracker brings botnet C2 cases — real incident-type
variety beyond URLhaus's malware monotone. Classifyline is source-aware
(feed tag → incident type; ransomware-flagged KEV → critical). CLI gains
fetch-cisa-kev, fetch-feodo, fetch-all. Both new feeds are keyless public
download feeds (verified).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ioc_extraction ExampleBuilder now embeds every IOC into the advisory text so
the extraction task is answerable from the input (v1 asked the model to
"extract" a URL that was never given). /train page distinguishes trained /
training… / not-started, and renders a per-step loss bar chart. Dockerfile no
longer bakes the training script — scripts/ is mounted at run time so edits
take effect without a 21 GB rebuild (this is why psyc-v2's loss capture was
silently skipped on its first run).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New /train route lists built JSONL datasets (examples, size) and trained
adapters with their base model, hyperparameters, dataset provenance, and
loss history. train_qlora.py now records train_loss + per-step loss_history
into training_meta.json so future runs surface a loss curve in the cockpit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Training and eval now run clean on the unsloth 2026.5.2 / transformers v5 /
torch 2.10 stack. Fixes: pytorch/pytorch base image (sidesteps the nvidia/cuda
apt-signature failure and the torch download), correct base-model slug
unsloth/Qwen3.5-4B, TRL SFTConfig API. Adds scripts/eval_adapter.py — runs
dataset rows through base+adapter with structured (transformers-v5) message
content and Qwen3.5 thinking-mode stripping.
First v1 adapter: loss 2.10 -> 0.32 over 3 epochs. Eval surfaced an ill-posed
ioc_extraction dataset (output URL not present in input) — to be fixed in the
ExampleBuilder before the next training run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dockerfile.train builds a CUDA 12.4 + unsloth container that consumes the
Trainline JSONL datasets and emits a LoRA adapter at data/adapters/<run>/final.
Defaults target a 24 GB GPU (Qwen3.5-4B-Instruct-bnb-4bit, r=16, bf16, 3 epochs,
effective batch 8). README documents the build + run workflow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>