Compare commits

..

No commits in common. "172ec324f2e32d4e5a230c9da8af526c21055f6b" and "9979b794342e086093fad7a353831273d1aef68e" have entirely different histories.

45 changed files with 4236 additions and 17842 deletions

View File

@ -1,20 +1,7 @@
# TIP Changelog # TIP Changelog
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."} {"d":"2026-05-13","t":"FIX","m":"BlogLLM model version sync: dashboard FO_BlogLLM card now dynamically reflects the active Ollama model via /api/blog/llm/status (was hardcoded to fo-blog-v7). TIP ecosystem.config.js OLLAMA_LLM_MODEL + BLOG_LLM_MODEL bumped fo-blog-v7 → fo-blog-v10 (Mac Studio Magatama training adopted 2026-05-13 00:33 UTC). tip-api restarted, PM2 state saved."}
{"d":"2026-05-14","t":"FEAT","m":"LinkedIn Distribution Status: Blog tab shows DRY_RUN badge, posted/dry_run/skipped/failed counters, history table with live URN links. GET /api/blog/linkedin/history reads blog_linkedin_distribution table + detects DRY_RUN mode from ecosystem config."}
{"d":"2026-05-14","t":"FEAT","m":"MCP Server: 2 new tools — find_equivalences (search 63k+ verified cross-brand mappings with confidence filter, returns FX alternatives + competitor matches formatted for LLM) + get_price_history (392k+ obs, daily series, per-vendor min/max/avg, cheapest source identification). Total: 21 MCP tools."}
{"d":"2026-05-14","t":"FIX","m":"Blog from URL: SPA-aware content extraction. fetchUrlContent() now extracts OG/meta tags (og:title, og:description, name=description, og:site_name) as fallback for JavaScript SPAs. Returns spaDetected=true when body text < 300 chars. from-url endpoint skips gatherBlogData() product injection when SPA detected prevents fo-blog model from defaulting to optical networking domain on non-networking URLs. additionalContext now includes explicit SPA warning + meta content. generated_by in pipeline UPDATE uses active model name (no more hardcoded 'fo-blog-engine-v7'). Dashboard shows SPA warning toast + spa_detected field in response."}
{"d":"2026-05-14","t":"FEAT","m":"Blog Engine: URL → Blog feature. POST /api/blog/from-url fetches any URL server-side (20s timeout, redirect-follow), strips scripts/nav/footer/SVG, extracts readable text (~5000 chars) + page title, passes as structured additional_context to the 16-step FO blog pipeline. Dashboard: new '🔗 Blog aus URL generieren' panel with URL input (Enter key supported), Blog-Typ selector, loading state, and char count confirmation. Same pollBlogLlm() polling reused for step progress."}
{"d":"2026-05-14","t":"UI","m":"Switch modal Flexoptix section: (1) Speed formatting fixed — 1600.00G → 1.6T, 400G clean integer (fmtSpeed() helper, ≥1000 Gbps → T). (2) Lagerbestand badges added per transceiver row: DE-Lager (green), Global-Lager (blue), Zulauf with ETA date (yellow). Data sourced from stock_observations via LEFT JOIN LATERAL in getFlexoptixSuggestions(). Badges hidden when quantities are null/0 (scraper not yet populating Flexoptix warehouse columns — shows automatically once scraper is extended)."}
{"d":"2026-05-14","t":"FEAT","m":"Stock velocity API: GET /api/stock/velocity (paginated, filterable by vendor_id/confidence/stockout_days/min_sell_rate/part_number) + GET /api/stock/velocity/:id (per-product velocity summary + sell/zulauf event history). Both routes live in packages/api/src/routes/stock.ts, compiled + deployed to tip-api PM2 id 24, port 3201."}
{"d":"2026-05-14","t":"DATA","m":"Demo data cleanup: deleted 2133 demo rows from reorder_signals (is_demo_data=true). Stock observation coverage expanded: atgbics.ts + optcore.ts now call upsertStockObservation after each price observation (binary in/out stock, confidence=1). FS.com scraper already runs 3x daily from Mac (02:00/10:00/18:00) with full DE-Lager/Global-Lager/Nachlieferung breakdown. Competitor stock audit: QSFPTEK (confidence=2, real quantities), FS.COM (confidence=3, per-warehouse breakdown) are highest fidelity; ATGBICS/Optcore added at confidence=1 (binary); sfpcables/prolabs/wiitek hardcode or lack stock — not added."}
{"d":"2026-05-13","t":"FIX","m":"BlogLLM model version sync: dashboard FO_BlogLLM card now dynamically reflects the active Ollama model via /api/blog/llm/status (was hardcoded to fo-blog-v7). TIP ecosystem.config.js OLLAMA_LLM_MODEL + BLOG_LLM_MODEL bumped fo-blog-v7 → fo-blog-v10 (Mac Studio Magatama training adopted 2026-05-13 00:33 UTC). Persisted /opt/tip/blog-llm-settings.json overrode env — also updated. tip-api restarted, PM2 state saved."}
{"d":"2026-05-13","t":"FEAT","m":"BlogLLM auto-discovery: client.ts now probes Ollama at startup + every 10 min, reconciles configured fo-blog-vN against actual available tags, auto-falls to highest available version when configured model no longer exists. Magatama-aware sort: base 'fo-blog-vN' tag wins over '-rM' revisions within same N (matches Magatama adoption convention where -rM is intermediate adapter save, base is production alias). New POST /api/blog/llm/refresh-discovery endpoint for manual trigger. Eliminates 3-step manual sync after every Magatama training."}
{"d":"2026-05-13","t":"FIX","m":"Ollama Modelfile bug for fo-blog-v10: Mac Studio adoption registered model with template '{{.Prompt}}' instead of Qwen2.5 chat template — model returned empty responses to /api/chat. Recreated fo-blog-v10 via Ollama /api/create with correct ChatML template ({{- if .System}}<|im_start|>system ... <|im_end|>...), num_ctx=8192, stop=<|im_end|>, temperature=0.3. Smoke test: 45 tokens generated cleanly. Magatama-side adoption logic should be patched to emit correct template by default."}
{"d":"2026-05-13","t":"DATA","m":"Competitive naming sanitization + Anti-Naming-Policy training: (1) Sanitization sweep across all 244 JSONL training files: 97 Fs.com/FiberStore replacements with neutral 'unnamed third-party MSA-compatible vendor' across 15 active files (fo_blogllm, tip_llm pools + RunPod exports + historical RunPod pod-runs). All affected files backed up to .bak-fs-final/.bak-YYYYMMDD-HHMMSS. Post-sanitization verification: 0 assistant-content mentions of competitor brands across all 5 lanes (fo_blogllm, pulso_llm, tip_llm, magatamallm, contact_llm). Remaining FS mentions live only in system-prompt prohibition lists (anti-naming policy) and one magatamallm user-message context for legitimate internal SKU-matching research. (2) Anti-Naming-Policy training pairs added: 4 deep pairs for fo_blogllm (third-party market analysis, procurement strategy, coherent component stack), 3 pairs for pulso_llm (competitor-inquiry deflection, price-compare without naming, internal sales guidance), 1 pair for tip_llm (public research blog output with neutral language). All new system prompts contain explicit COMPETITIVE NAMING POLICY clause forbidding named mentions of Fs.com/FiberStore/Approved Networks/Cablexa/ProLabs/FluxLight + component suppliers Accelink/InnoLight/Lumentum/Coherent/II-VI/Eoptolink/Source Photonics. Switch and router OEMs (Cisco/Arista/Juniper/Nokia/Ciena/HPE/Dell/Mellanox/Extreme/Huawei) explicitly permitted as integration partners. Post-rebuild manifests: fo_blogllm 18757 effective, pulso_llm 3242 effective, tip_llm 2181 effective."}
{"d":"2026-05-13","t":"DATA","m":"FO_BlogLLM training corpus deep-quality expansion: 8 new training files in pulso_llm pool with 22 long-form (700-1000 word) blog pairs targeting fo-blog-v10 failure modes. Categories: (1) Connector Authority — MPO Type A/B/C polarity, IEC 61300-3-35 endface inspection, MPO-12 vs MPO-16, LC vs MPO architecture mapping; (2) Transceiver Taxonomy — full 100G/400G/800G variant matrix with reach, connector, lane structure, IEEE clauses; (3) Coherent Depth — coherent vs direct-detect crossover, OSNR engineering for ZR+, FEC types (cFEC/oFEC) and pre-FEC BER reality; (4) Power & Reach Ground Truth — accurate per-module power numbers 2026, OTDR commissioning workflow; (5) Operations Troubleshooting — pre-FEC BER climbs diagnostic walkthrough, module detection / coding mismatch fixes; (6) Topic Adherence — exact MPO Connector Survival Guide blog (the test prompt that failed in v10), Fiber Inspection Probes, Cable Routing for spine-leaf; (7) Standards Map — IEEE 802.3ba/cd/cu/df clause map, CMIS register layout; (8) Myth Corrections — DR ≠ Long Reach, LR vs ER vs ZR taxonomy, MPO-parallel vs LC-WDM architecture. All pairs include IEEE/OIF/MSA citations, real datasheet-equivalent numbers (TX/RX power, sensitivity, power consumption per module class). Pool now: 17936 train + 2018 eval = 19954 total after dedupe (123 duplicates removed). Next fo_blogllm training run picks up automatically."}
{"d":"2026-05-13","t":"FIX","m":"Magatama Mac Studio adoption template root cause patched: /opt/magatama/packages/fine-tuner/train.py register_ollama() built Modelfiles without TEMPLATE directive (only FROM/SYSTEM/PARAMETER) — Ollama defaulted to '{{.Prompt}}' which silently breaks /api/chat. Both modelfile_lines blocks (GGUF and fallback) now include the Qwen2.5 ChatML TEMPLATE plus full PARAMETER set (temperature 0.3 (was 0.1), top_p 0.9, num_ctx 8192, stop <|im_end|>). End-to-end test against Ollama API confirmed: model registers + /api/chat returns expected tokens. Future fo-blog-vN trainings (and any Magatama lane via Mac Studio path) will no longer produce silent-failure models. Backup at /opt/magatama/packages/fine-tuner/train.py.bak-20260513-153306. Local checkout synced. The other adoption path (/opt/llm-gateway/.../converter.py used by RunPod artifact import) already had TEMPLATE correct — no change there."}
{"d":"2026-04-26","t":"DATA","m":"Juniper OEM transceiver seed: 59 PIDs inserted (SFP-1GE/SFPP-10G/SFP-25G/QSFPP-40G/JNP-QSFP-100G/JNP-QSFP56-200G/JNP-QSFPDD-400G/JNP-OSFP-400G+800G + DAC/AOC). Scheduler: daily 04:15."} {"d":"2026-04-26","t":"DATA","m":"Juniper OEM transceiver seed: 59 PIDs inserted (SFP-1GE/SFPP-10G/SFP-25G/QSFPP-40G/JNP-QSFP-100G/JNP-QSFP56-200G/JNP-QSFPDD-400G/JNP-OSFP-400G+800G + DAC/AOC). Scheduler: daily 04:15."}
{"d":"2026-04-26","t":"FIX","m":"BlueOptics scraper: force HTTP/1.1 via Node.js https.get() to bypass empty-body HTTP/2 server bug; updated catalog path to /Transceivers_1 (changed 2026)."} {"d":"2026-04-26","t":"FIX","m":"BlueOptics scraper: force HTTP/1.1 via Node.js https.get() to bypass empty-body HTTP/2 server bug; updated catalog path to /Transceivers_1 (changed 2026)."}
{"d":"2026-04-26","t":"DATA","m":"Cisco TMG scraper: upsert logic fixed (market_status EOL + temp_range IND normalization). Full run in progress: 300+ switches, 15000+ compat matches written to switch_transceiver_compat."} {"d":"2026-04-26","t":"DATA","m":"Cisco TMG scraper: upsert logic fixed (market_status EOL + temp_range IND normalization). Full run in progress: 300+ switches, 15000+ compat matches written to switch_transceiver_compat."}

163
package-lock.json generated
View File

@ -11,11 +11,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"dependencies": {
"pdf-parse": "^1.1.4"
},
"devDependencies": { "devDependencies": {
"@types/pdf-parse": "^1.1.5",
"tsx": "^4.19", "tsx": "^4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
@ -1666,16 +1662,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
@ -1685,16 +1671,6 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@ -1911,12 +1887,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -2068,23 +2038,6 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/byte-counter": { "node_modules/byte-counter": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz", "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz",
@ -2379,21 +2332,6 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -4284,68 +4222,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mute-stream": { "node_modules/mute-stream": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@ -4379,12 +4255,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@ -4698,22 +4568,6 @@
"through": "~2.3" "through": "~2.3"
} }
}, },
"node_modules/pdf-parse": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
"license": "MIT",
"dependencies": {
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pg": { "node_modules/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@ -5509,14 +5363,6 @@
"stream-chain": "^2.2.5" "stream-chain": "^2.2.5"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -5728,12 +5574,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -6236,15 +6076,12 @@
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"multer": "^2.1.1",
"pdf-parse": "^1.1.4",
"pg": "^8.13.1", "pg": "^8.13.1",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^2.1.0",
"@types/pg": "^8.11.11", "@types/pg": "^8.11.11",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View File

@ -25,12 +25,8 @@
"url": "https://github.com/renefichtmueller/transceiver-db" "url": "https://github.com/renefichtmueller/transceiver-db"
}, },
"devDependencies": { "devDependencies": {
"@types/pdf-parse": "^1.1.5",
"tsx": "^4.19", "tsx": "^4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
},
"dependencies": {
"pdf-parse": "^1.1.4"
} }
} }

View File

@ -10,22 +10,19 @@
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"express": "^5.1.0",
"pg": "^8.13.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"multer": "^2.1.1", "express-rate-limit": "^7.5.0",
"pdf-parse": "^1.1.4",
"pg": "^8.13.1",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^2.1.0",
"@types/pg": "^8.11.11", "@types/pg": "^8.11.11",
"tsx": "^4.19.0", "@types/cors": "^2.8.17",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"tsx": "^4.19.0"
} }
} }

View File

@ -1,709 +0,0 @@
import type { TrainingLesson } from "./types";
export const formFactorLessons: TrainingLesson[] = [
{
id: "ff-sfp-family",
category: "form-factors",
title: "SFP Family: SFP to SFP-DD",
title_de: "SFP-Familie: SFP bis SFP-DD",
level: "beginner",
duration_min: 15,
summary:
"The SFP family covers 1G through 100G in a compact single-lane or dual-lane pluggable. Learn the physical and electrical differences between SFP, SFP+, SFP28, SFP56, and SFP-DD.",
summary_de:
"Die SFP-Familie umfasst 1G bis 100G in einem kompakten ein- oder zweilanigen Pluggable. Lernen Sie die physikalischen und elektrischen Unterschiede zwischen SFP, SFP+, SFP28, SFP56 und SFP-DD.",
tags: ["sfp", "sfp+", "sfp28", "sfp-dd", "form-factor"],
sections: [
{
heading: "SFP Physical Characteristics",
heading_de: "Physikalische Eigenschaften des SFP",
blocks: [
{
type: "p",
text: "All SFP variants share the same mechanical housing: 56.5mm long × 13.4mm wide × 8.5mm tall. They all use the same MSA-defined edge connector and latch mechanism, which is why an SFP+ port can physically accept an SFP module (though data rate won't match). The key differences are in the electrical interface, EEPROM identifier, and maximum power consumption.",
text_de:
"Alle SFP-Varianten teilen dasselbe mechanische Gehaeuse: 56,5 mm lang × 13,4 mm breit × 8,5 mm hoch. Sie verwenden alle denselben MSA-definierten Kantenstecker und Verriegelungsmechanismus, weshalb ein SFP+-Port physikalisch ein SFP-Modul aufnehmen kann (die Datenrate stimmt jedoch nicht ueberein). Die wesentlichen Unterschiede liegen in der elektrischen Schnittstelle, dem EEPROM-Identifikator und dem maximalen Stromverbrauch.",
},
{
type: "callout",
variant: "warning",
text: "Physical compatibility does NOT equal data rate compatibility. An SFP module in an SFP28 port will only link at 1G. The port must be configured for the correct speed. Never assume inserting any SFP-sized module will work at the port's rated speed.",
text_de:
"Physikalische Kompatibilitaet bedeutet NICHT Datenraten-Kompatibilitaet. Ein SFP-Modul in einem SFP28-Port wird nur mit 1G verbinden. Der Port muss fuer die korrekte Geschwindigkeit konfiguriert werden.",
},
],
},
{
heading: "SFP Family Comparison",
heading_de: "SFP-Familie im Vergleich",
blocks: [
{
type: "table",
headers: ["Form Factor", "Speed", "Electrical Lanes", "Max Power", "SFF Standard", "Introduced"],
headers_de: ["Formfaktor", "Geschwindigkeit", "Elektrische Lanes", "Max. Leistung", "SFF-Standard", "Eingefuehrt"],
rows: [
["SFP", "up to 4.25 Gbps", "1×NRZ", "≤1.0W", "SFF-8472", "2003"],
["SFP+", "up to 10.3 Gbps", "1×NRZ", "≤2.5W (Class 3)", "SFF-8431", "2006"],
["SFP28", "up to 28 Gbps", "1×NRZ", "≤3.5W", "SFF-8402", "2015"],
["SFP56", "up to 56 Gbps", "1×PAM4 (or 2×NRZ)", "≤3.5W", "SFF-8024", "2019"],
["SFP-DD", "up to 100 Gbps", "2×PAM4", "≤5.0W", "SFF-8024", "2019"],
],
},
{
type: "p",
text: "SFP (Small Form-factor Pluggable) was defined in the original SFP MSA (2003) for rates up to 4.25 Gbps, covering 1GbE, 2GFC, 4GFC, and SONET/SDH. SFP+ (Enhanced SFP) extended this to 10 Gbps for 10GbE and 8GFC/10GFC. The same mechanical housing with a faster electrical interface.",
text_de:
"SFP (Small Form-factor Pluggable) wurde in der urspruenglichen SFP-MSA (2003) fuer Raten bis 4,25 Gbps definiert, fuer 1GbE, 2GFC, 4GFC und SONET/SDH. SFP+ (Enhanced SFP) erweiterte dies auf 10 Gbps fuer 10GbE und 8GFC/10GFC. Dasselbe mechanische Gehaeuse mit einer schnelleren elektrischen Schnittstelle.",
},
{
type: "callout",
variant: "tip",
text: "SFP-DD (Double Density) adds a second row of contacts to the bottom of the standard SFP connector, enabling 2 electrical lanes = 2×50G PAM4 = 100G total. SFP-DD cages are NOT backward compatible with standard SFP.",
text_de:
"SFP-DD (Double Density) fuegt eine zweite Kontaktreihe am Boden des Standard-SFP-Steckers hinzu und ermoeglicht 2 elektrische Lanes = 2×50G PAM4 = 100G gesamt. SFP-DD-Kaefige sind NICHT abwaertskompatibel zu Standard-SFP.",
},
],
},
{
heading: "Connector Types Used with SFP",
heading_de: "Mit SFP verwendete Steckertypen",
blocks: [
{
type: "ul",
items: [
"LC duplex: standard for most fiber SFP variants (1G, 10G, 25G)",
"LC simplex: for BiDi modules (single fiber bidirectional, e.g., 1000BASE-BX10, 10GBASE-BX)",
"RJ-45: for copper SFP/SFP+ (1000BASE-T, 10GBASE-T) — these are active and consume more power",
"No connector (copper DAC): DAC (Direct Attach Copper) is a twinax cable with SFP+ or SFP28 heads — NOT an optical module",
],
items_de: [
"LC-Duplex: Standard fuer die meisten Glasfaser-SFP-Varianten (1G, 10G, 25G)",
"LC-Simplex: fuer BiDi-Module (eine Faser bidirektional, z. B. 1000BASE-BX10, 10GBASE-BX)",
"RJ-45: fuer Kupfer-SFP/SFP+ (1000BASE-T, 10GBASE-T) — diese sind aktiv und verbrauchen mehr Leistung",
"Kein Stecker (Kupfer-DAC): DAC (Direct Attach Copper) ist ein Twinax-Kabel mit SFP+- oder SFP28-Koepfen — KEIN optisches Modul",
],
},
],
},
{
heading: "Choosing the Right SFP Variant",
heading_de: "Die richtige SFP-Variante waehlen",
blocks: [
{
type: "ul",
items: [
"SFP (1G): access switches to end devices, management ports, storage replication",
"SFP+ (10G): most common for server access, ToR switches, campus aggregation uplinks",
"SFP28 (25G): high-performance server access, AI/GPU clusters, NVMe storage",
"SFP56 (50G): emerging, used in dense 50G server deployments",
"SFP-DD (100G): high-density 100G panels and patch points",
],
items_de: [
"SFP (1G): Access-Switches zu Endgeraeten, Management-Ports, Storage-Replikation",
"SFP+ (10G): am haeufigsten fuer Server-Zugang, ToR-Switches, Campus-Aggregations-Uplinks",
"SFP28 (25G): Hochleistungs-Server-Zugang, KI/GPU-Cluster, NVMe-Storage",
"SFP56 (50G): aufkommend, in dichten 50G-Server-Deployments verwendet",
"SFP-DD (100G): Hochdichte 100G-Panels und Patch-Punkte",
],
},
],
},
],
quiz: [
{
id: "ff-sfp-q1",
lesson: "ff-sfp-family",
q: "What is the maximum data rate of an SFP+ module?",
q_de: "Was ist die maximale Datenrate eines SFP+-Moduls?",
options: ["1 Gbps", "4.25 Gbps", "10.3 Gbps", "25 Gbps"],
options_de: ["1 Gbps", "4,25 Gbps", "10,3 Gbps", "25 Gbps"],
answer: 2,
explanation:
"SFP+ (Enhanced SFP) supports up to 10.3125 Gbps — covering 10GbE and 10GFC. SFP is limited to 4.25 Gbps; SFP28 covers up to 28 Gbps.",
explanation_de:
"SFP+ (Enhanced SFP) unterstuetzt bis zu 10,3125 Gbps — fuer 10GbE und 10GFC. SFP ist auf 4,25 Gbps begrenzt; SFP28 erreicht bis zu 28 Gbps.",
},
{
id: "ff-sfp-q2",
lesson: "ff-sfp-family",
q: "SFP-DD achieves 100G by using how many electrical lanes?",
q_de: "Wie viele elektrische Lanes verwendet SFP-DD, um 100G zu erreichen?",
options: ["1 lane at 100G PAM4", "2 lanes at 50G PAM4 each", "4 lanes at 25G NRZ each", "8 lanes at 12.5G NRZ each"],
options_de: [
"1 Lane mit 100G PAM4",
"2 Lanes mit je 50G PAM4",
"4 Lanes mit je 25G NRZ",
"8 Lanes mit je 12,5G NRZ",
],
answer: 1,
explanation:
"SFP-DD adds a second row of contacts for 2 lanes total. Each lane carries 50G PAM4, yielding 2×50G = 100G.",
explanation_de:
"SFP-DD fuegt eine zweite Kontaktreihe fuer insgesamt 2 Lanes hinzu. Jede Lane traegt 50G PAM4, ergibt 2×50G = 100G.",
},
{
id: "ff-sfp-q3",
lesson: "ff-sfp-family",
q: "Can you insert an SFP module into an SFP28 port?",
q_de: "Koennen Sie ein SFP-Modul in einen SFP28-Port einfuehren?",
options: [
"No — the connector shape is different",
"Yes — physically compatible, but the port will only run at 1G",
"Yes — the port automatically adapts to full 25G",
"No — SFP modules use a different latch mechanism",
],
options_de: [
"Nein — die Steckerform ist unterschiedlich",
"Ja — physisch kompatibel, aber der Port laeuft nur mit 1G",
"Ja — der Port passt sich automatisch auf 25G an",
"Nein — SFP-Module verwenden einen anderen Verriegelungsmechanismus",
],
answer: 1,
explanation:
"All SFP variants share the same mechanical housing, so an SFP physically fits in an SFP28 slot. However, the port will negotiate at the module's rated speed (1G for SFP), not 25G.",
explanation_de:
"Alle SFP-Varianten teilen dasselbe mechanische Gehaeuse, daher passt ein SFP physisch in einen SFP28-Steckplatz. Der Port verhandelt jedoch auf die Nenngeschwindigkeit des Moduls (1G fuer SFP), nicht 25G.",
},
{
id: "ff-sfp-q4",
lesson: "ff-sfp-family",
q: "What connector does a BiDi SFP module use?",
q_de: "Welchen Stecker verwendet ein BiDi-SFP-Modul?",
options: ["LC duplex (2 fibers)", "LC simplex (1 fiber)", "SC duplex (2 fibers)", "MPO-12"],
options_de: ["LC-Duplex (2 Fasern)", "LC-Simplex (1 Faser)", "SC-Duplex (2 Fasern)", "MPO-12"],
answer: 1,
explanation:
"BiDi (Bidirectional) modules transmit and receive on different wavelengths on the same single fiber — requiring only an LC simplex connector.",
explanation_de:
"BiDi-Module senden und empfangen auf verschiedenen Wellenlaengen ueber dieselbe einzelne Faser — daher genuegt ein LC-Simplex-Stecker.",
},
],
},
{
id: "ff-qsfp-family",
category: "form-factors",
title: "QSFP Family: QSFP to QSFP-DD",
title_de: "QSFP-Familie: QSFP bis QSFP-DD",
level: "beginner",
duration_min: 15,
summary:
"QSFP is the dominant form factor for 40G, 100G, 200G, and 400G networking. Understand the full QSFP family, from QSFP+ to the 800G QSFP-DD800.",
summary_de:
"QSFP ist der dominante Formfaktor fuer 40G-, 100G-, 200G- und 400G-Netzwerke. Verstehen Sie die vollstaendige QSFP-Familie, von QSFP+ bis zum 800G QSFP-DD800.",
tags: ["qsfp", "qsfp28", "qsfp-dd", "40g", "100g", "400g", "form-factor"],
sections: [
{
heading: "QSFP Physical Overview",
heading_de: "Physikalischer Ueberblick des QSFP",
blocks: [
{
type: "p",
text: "QSFP (Quad Small Form-factor Pluggable) uses a wider housing than SFP — approximately 72.4mm × 18.4mm. It has 4 electrical transmit lanes and 4 receive lanes, enabling higher aggregate bandwidth than SFP. The QSFP connector uses 38 signal contacts compared to SFP's 20.",
text_de:
"QSFP (Quad Small Form-factor Pluggable) verwendet ein breiteres Gehaeuse als SFP — ca. 72,4 mm × 18,4 mm. Es hat 4 elektrische Sende-Lanes und 4 Empfangs-Lanes, was eine hoehere Bandbreite als SFP ermoeglicht. Der QSFP-Stecker hat 38 Signalkontakte im Vergleich zu 20 bei SFP.",
},
{
type: "callout",
variant: "info",
text: "QSFP-DD is backward compatible with QSFP28 in most modern switch ports. The QSFP-DD cage accepts QSFP28 modules and will run them at 100G. Always verify with your switch vendor's compatibility matrix.",
text_de:
"QSFP-DD ist in den meisten modernen Switch-Ports abwaertskompatibel zu QSFP28. Der QSFP-DD-Kaefig nimmt QSFP28-Module auf und betreibt sie mit 100G. Stets die Kompatibilitaetsmatrix des Switch-Herstellers pruefen.",
},
],
},
{
heading: "QSFP Family Comparison",
heading_de: "QSFP-Familie im Vergleich",
blocks: [
{
type: "table",
headers: ["Form Factor", "Speed", "Lanes (Tx+Rx)", "Max Power", "Standard", "Typical Connector"],
headers_de: ["Formfaktor", "Geschwindigkeit", "Lanes (Tx+Rx)", "Max. Leistung", "Standard", "Typischer Stecker"],
rows: [
["QSFP+", "40 Gbps", "4×10G NRZ", "≤4W (Class 4)", "SFF-8436", "LC duplex or MPO-12"],
["QSFP28", "100 Gbps", "4×25G NRZ", "≤4W (Class 4)", "SFF-8665/SFF-8636", "LC duplex or MPO-12"],
["QSFP56", "200 Gbps", "4×50G PAM4", "≤6W (Class 6)", "SFF-8024", "LC duplex or MPO-12"],
["QSFP-DD", "400 Gbps", "8×50G PAM4", "≤12W (Class 8)", "CMIS 4.0", "LC duplex or MPO-16"],
["QSFP-DD800", "800 Gbps", "8×100G PAM4", "≤20W (Ext. Class 8)", "CMIS 5.0", "LC duplex or MPO-16"],
],
},
],
},
{
heading: "Breakout Configurations",
heading_de: "Breakout-Konfigurationen",
blocks: [
{
type: "p",
text: "QSFP modules support breakout — splitting one high-speed port into multiple lower-speed ports. This is common when connecting servers (25G SFP28) to a spine switch (100G QSFP28 port). A breakout cable has one QSFP28 on one end and 4×SFP28 on the other.",
text_de:
"QSFP-Module unterstuetzen Breakout — Aufteilung eines Hochgeschwindigkeitsports in mehrere langsamere Ports. Dies ist ueblich beim Anschluss von Servern (25G SFP28) an einen Spine-Switch (100G QSFP28-Port). Ein Breakout-Kabel hat ein QSFP28 auf einer Seite und 4×SFP28 auf der anderen.",
},
{
type: "table",
headers: ["Source Module", "Breakout Result", "Cable Type", "Common Use Case"],
headers_de: ["Quell-Modul", "Breakout-Ergebnis", "Kabeltyp", "Typischer Anwendungsfall"],
rows: [
["QSFP28 (100G)", "4×SFP28 (25G)", "MPO-12 to 4×LC duplex DAC or AOC", "Spine→Server 100G to 4×25G"],
["QSFP28 (100G)", "2×QSFP+ (2×40G)", "Optical breakout", "100G migration, legacy 40G switches"],
["QSFP-DD (400G)", "2×QSFP28 (2×200G)", "Cable or optics", "400G leaf to dual 200G servers"],
["QSFP-DD (400G)", "4×QSFP28 (4×100G)", "Breakout cable", "Spine to multiple 100G leaf switches"],
["QSFP-DD800 (800G)", "2×QSFP-DD (2×400G)", "Breakout optics", "800G spine to dual 400G leaves"],
],
},
{
type: "callout",
variant: "tip",
text: "Breakout cables are usually DAC (Direct Attach Copper, ≤5m) or AOC (Active Optical Cable, ≤100m). For longer breakout runs, use a QSFP28 SR4 on one end and 4×SFP28 on the other with a patch panel and MPO-to-LC cassette.",
text_de:
"Breakout-Kabel sind meist DAC (Direct Attach Copper, ≤5m) oder AOC (Active Optical Cable, ≤100m). Fuer laengere Breakout-Verbindungen ein QSFP28 SR4 auf einer Seite und 4×SFP28 auf der anderen Seite mit Patchfeld und MPO-zu-LC-Kassette verwenden.",
},
],
},
{
heading: "QSFP-DD: The 400G Workhorse",
heading_de: "QSFP-DD: Das 400G-Arbeitstier",
blocks: [
{
type: "p",
text: "QSFP-DD (Double Density) adds a second row of 8 contacts below the original 8 QSFP contacts, enabling 8 electrical lanes in the same width housing. Each lane carries 50G PAM4, yielding 400G total. QSFP-DD is the dominant form factor in 400G hyperscale data centers.",
text_de:
"QSFP-DD (Double Density) fuegt eine zweite Reihe von 8 Kontakten unterhalb der urspruenglichen 8 QSFP-Kontakte hinzu, was 8 elektrische Lanes im gleichen Gehaeuse ermoeglicht. Jede Lane traegt 50G PAM4, ergibt 400G gesamt. QSFP-DD ist der dominante Formfaktor in 400G-Hyperscale-Rechenzentren.",
},
{
type: "ul",
items: [
"QSFP-DD cage is physically backward compatible with QSFP28, QSFP+, and QSFP modules",
"Power: standard Class 8 = 12W, Extended Class 8 = 14W, up to ~20W for some ZR modules",
"Cage requires heatsink; high-power ZR modules may need active cooling clip",
"CMIS 4.0 or 5.0 management interface — more complex than SFF-8636",
],
items_de: [
"QSFP-DD-Kaefig ist physisch abwaertskompatibel zu QSFP28, QSFP+ und QSFP-Modulen",
"Leistung: Standard Klasse 8 = 12W, Erweiterte Klasse 8 = 14W, bis zu ~20W fuer manche ZR-Module",
"Kaefig benoetigt Kuehlkoerper; Hochleistungs-ZR-Module benoetigen moeglicherweise aktiven Kuehlklemme",
"CMIS 4.0 oder 5.0 Management-Schnittstelle — komplexer als SFF-8636",
],
},
],
},
],
quiz: [
{
id: "ff-qsfp-q1",
lesson: "ff-qsfp-family",
q: "How many electrical lanes does a QSFP-DD module have?",
q_de: "Wie viele elektrische Lanes hat ein QSFP-DD-Modul?",
options: ["4 lanes (same as QSFP28)", "6 lanes", "8 lanes (2 rows)", "16 lanes"],
options_de: ["4 Lanes (wie QSFP28)", "6 Lanes", "8 Lanes (2 Reihen)", "16 Lanes"],
answer: 2,
explanation:
"QSFP-DD has 8 lanes (two rows of 4 contacts each). Each lane carries 50G PAM4 for a total of 400G.",
explanation_de:
"QSFP-DD hat 8 Lanes (zwei Reihen mit je 4 Kontakten). Jede Lane traegt 50G PAM4 fuer insgesamt 400G.",
},
{
id: "ff-qsfp-q2",
lesson: "ff-qsfp-family",
q: "A QSFP-DD cage in a new switch — can you insert a QSFP28 module?",
q_de: "Ein QSFP-DD-Kaefig in einem neuen Switch — koennen Sie ein QSFP28-Modul einfuehren?",
options: [
"No — different physical size",
"Yes — QSFP-DD is backward compatible with QSFP28",
"Yes — but only if configured in 4-lane mode",
"No — different management interface prevents it",
],
options_de: [
"Nein — unterschiedliche physische Groesse",
"Ja — QSFP-DD ist abwaertskompatibel zu QSFP28",
"Ja — aber nur im 4-Lane-Modus konfiguriert",
"Nein — unterschiedliche Managementschnittstelle verhindert es",
],
answer: 1,
explanation:
"QSFP-DD cages are designed to accept QSFP28, QSFP+, and QSFP modules for backward compatibility. The port will run at 100G with a QSFP28 module.",
explanation_de:
"QSFP-DD-Kaefige sind fuer die Aufnahme von QSFP28-, QSFP+- und QSFP-Modulen fuer Abwaertskompatibilitaet ausgelegt. Der Port laeuft mit 100G bei einem QSFP28-Modul.",
},
{
id: "ff-qsfp-q3",
lesson: "ff-qsfp-family",
q: "A QSFP28 100G module split into 4×SFP28 — this is called what?",
q_de: "Ein QSFP28 100G-Modul aufgeteilt in 4×SFP28 — wie nennt man das?",
options: ["Multiplexing", "Breakout", "Trunking", "Bonding"],
options_de: ["Multiplexing", "Breakout", "Trunking", "Bonding"],
answer: 1,
explanation:
"Breaking a high-speed port (e.g., 100G QSFP28) into multiple lower-speed ports (e.g., 4×25G SFP28) is called 'breakout'. Breakout cables (DAC or AOC) are used for short distances.",
explanation_de:
"Das Aufteilen eines Hochgeschwindigkeitsports (z. B. 100G QSFP28) in mehrere langsamere Ports (z. B. 4×25G SFP28) heisst 'Breakout'. Breakout-Kabel (DAC oder AOC) werden fuer kurze Distanzen verwendet.",
},
{
id: "ff-qsfp-q4",
lesson: "ff-qsfp-family",
q: "What is the maximum power of a QSFP-DD module in Extended Class 8?",
q_de: "Was ist die maximale Leistung eines QSFP-DD-Moduls in Extended Class 8?",
options: ["4W", "8W", "12W", "Up to ~20W"],
options_de: ["4W", "8W", "12W", "Bis zu ~20W"],
answer: 3,
explanation:
"Standard QSFP-DD Class 8 is 12W. Extended Class 8 can reach up to ~20W — needed for 400G-ZR coherent modules with DSP ASICs.",
explanation_de:
"Standard QSFP-DD Klasse 8 ist 12W. Erweiterte Klasse 8 kann bis zu ~20W erreichen — benoetigt fuer 400G-ZR Coherent-Module mit DSP-ASICs.",
},
{
id: "ff-qsfp-q5",
lesson: "ff-qsfp-family",
q: "Which QSFP form factor is rated for 800G?",
q_de: "Welcher QSFP-Formfaktor ist fuer 800G ausgelegt?",
options: ["QSFP56", "QSFP-DD", "QSFP-DD800", "QSFP-XD"],
options_de: ["QSFP56", "QSFP-DD", "QSFP-DD800", "QSFP-XD"],
answer: 2,
explanation:
"QSFP-DD800 is the 800G version of QSFP-DD, using 8×100G PAM4 lanes with Extended Class 8 power (up to ~20W).",
explanation_de:
"QSFP-DD800 ist die 800G-Version von QSFP-DD mit 8×100G PAM4-Lanes und Extended Class 8-Leistung (bis zu ~20W).",
},
],
},
{
id: "ff-connectors",
category: "form-factors",
title: "Fiber Connectors and Polarity",
title_de: "Glasfaserstecker und Polaritaet",
level: "beginner",
duration_min: 12,
summary:
"Master the connector types used in fiber optic networks — LC, SC, MPO/MTP — and understand the critical concept of polarity that determines whether an MPO link works correctly.",
summary_de:
"Beherrschen Sie die in Glasfasernetzwerken verwendeten Steckertypen — LC, SC, MPO/MTP — und verstehen Sie das kritische Konzept der Polaritaet, das bestimmt, ob eine MPO-Verbindung korrekt funktioniert.",
tags: ["lc", "sc", "mpo", "mtp", "polarity", "connector"],
sections: [
{
heading: "LC, SC, and Other Common Connectors",
heading_de: "LC, SC und andere gaengige Stecker",
blocks: [
{
type: "table",
headers: ["Connector", "Ferrule Diameter", "Form Factor", "Typical Use", "Key Characteristic"],
headers_de: ["Stecker", "Ferrule-Durchmesser", "Formfaktor", "Typische Anwendung", "Wesentliches Merkmal"],
rows: [
["LC (Lucent Connector)", "1.25 mm", "Duplex clip", "SFP, SFP+, SFP28, QSFP28 LR4", "Most common in data centers"],
["SC (Subscriber Connector)", "2.5 mm", "Push-pull, duplex", "Telecom, older switches, GPON OLT", "Large, but very low loss"],
["FC (Ferrule Connector)", "2.5 mm", "Threaded", "Instruments, OTDR test ports", "Mechanical stability"],
["ST (Straight Tip)", "2.5 mm", "Bayonet", "Legacy MMF, campus LANs", "Older, being phased out"],
["MPO/MTP", "Multi-fiber", "Push-pull (12-32 fibers)", "SR4, SR8, parallel SMF (DR4)", "High-density, used with breakout"],
],
},
{
type: "p",
text: "LC connectors are the de facto standard for modern transceiver connections. The 1.25mm ferrule fits two connectors into the same panel space as one SC, enabling higher port density. The plastic housing clip snaps into the port and must be pressed to release — always check for the latch before pulling.",
text_de:
"LC-Stecker sind der De-facto-Standard fuer moderne Transceiver-Verbindungen. Die 1,25-mm-Ferrule ermoeglicht zwei Stecker im gleichen Panelplatz wie ein SC, was eine hoehere Portdichte ergibt. Der Kunststoffgehaeuse-Clip rastet im Port ein und muss zum Loesen gedrueckt werden.",
},
{
type: "callout",
variant: "warning",
text: "APC (Angled Physical Contact) connectors have a green boot and 8° angled ferrule — they MUST NOT be mated with UPC (flat) connectors. Always verify connector type before plugging. Mixing APC and UPC causes high return loss and can damage the module.",
text_de:
"APC-Stecker (Angled Physical Contact) haben einen gruenen Mantel und eine 8°-Winkelferrule — sie DUERFEN NICHT mit UPC-Steckern (flach) gekoppelt werden. Immer den Steckertyp pruefen. Das Mischen von APC und UPC verursacht hohe Rueckflussdaempfung und kann das Modul beschaedigen.",
},
],
},
{
heading: "MPO/MTP Connectors",
heading_de: "MPO/MTP-Stecker",
blocks: [
{
type: "p",
text: "MPO (Multi-fiber Push-On) is defined in IEC 61754-7. It carries multiple fibers in one ferrule — commonly 12, 16, or 24. MTP is the brand name for high-precision MPO connectors from US Conec, offering lower insertion loss. MTP Elite provides ultra-low insertion loss (≤0.15 dB per mating) suitable for 400G.",
text_de:
"MPO (Multi-fiber Push-On) ist in IEC 61754-7 definiert. Es traegt mehrere Fasern in einer Ferrule — ueblicherweise 12, 16 oder 24. MTP ist der Markenname fuer hochpraezise MPO-Stecker von US Conec mit geringerem Einfuegungsverlust. MTP Elite bietet ultra-niedrigen Einfuegungsverlust (≤0,15 dB pro Kopplung) fuer 400G.",
},
{
type: "table",
headers: ["Fiber Count", "Standard Use Case", "Compatible Transceiver"],
headers_de: ["Faseranzahl", "Standard-Anwendungsfall", "Kompatibler Transceiver"],
rows: [
["MPO-12 (12 fibers)", "40GBASE-SR4, 100GBASE-SR4, 400GBASE-DR4", "QSFP+, QSFP28, QSFP-DD (parallel SMF)"],
["MPO-16 (16 fibers)", "400GBASE-SR8, 800GBASE-SR8", "QSFP-DD, OSFP"],
["MPO-24 (24 fibers)", "2× 100GBASE-SR4 (breakout), trunk cabling", "2× QSFP28"],
["MPO-32 (32 fibers)", "High-density trunk cabling", "Multiple modules via breakout"],
],
},
],
},
{
heading: "Polarity — The Most Common MPO Mistake",
heading_de: "Polaritaet — Der haeufigste MPO-Fehler",
blocks: [
{
type: "p",
text: "In MPO links, the transmit fibers of one end must connect to the receive fibers of the other end. The way fibers are arranged in the MPO connector determines whether this works correctly — this arrangement is called 'polarity'. Get it wrong and every port shows 'link down'.",
text_de:
"In MPO-Verbindungen muessen die Sendefasern eines Endes mit den Empfangsfasern des anderen Endes verbunden werden. Die Anordnung der Fasern im MPO-Stecker bestimmt, ob dies korrekt funktioniert — diese Anordnung heisst 'Polaritaet'. Wenn sie falsch ist, zeigt jeder Port 'Link Down'.",
},
{
type: "table",
headers: ["Polarity Type", "Fiber Arrangement", "When to Use"],
headers_de: ["Polaritaetstyp", "Faseranordnung", "Wann verwenden"],
rows: [
["Type A (Straight)", "Fiber 1 connects to Fiber 1 on opposite end", "With type B breakout cassettes or flip patch cords"],
["Type B (Reversed)", "Fiber 1 connects to Fiber 12 on opposite end", "Most common trunk cable; works with standard breakout cassettes"],
["Type C (Pair-flipped)", "Pairs of fibers (1-2) swap positions", "For 40GBASE-SR4 with specific cabling systems"],
],
},
{
type: "callout",
variant: "key",
text: "Wrong polarity is the #1 cause of 'link down' on first MPO installation. Always document your polarity scheme before cabling. Most modern data centers standardize on Type B trunk cables with Type A cassettes, which produces correct polarity without manual flipping.",
text_de:
"Falsche Polaritaet ist die haeufigste Ursache fuer 'Link Down' bei der ersten MPO-Installation. Immer das Polaritaetsschema vor der Verkabelung dokumentieren. Die meisten modernen Rechenzentren standardisieren auf Type-B-Trunk-Kabel mit Type-A-Kassetten, was korrekte Polaritaet ohne manuelles Spiegeln erzeugt.",
},
],
},
],
quiz: [
{
id: "ff-conn-q1",
lesson: "ff-connectors",
q: "What is the ferrule diameter of an LC connector?",
q_de: "Was ist der Ferrule-Durchmesser eines LC-Steckers?",
options: ["2.5 mm", "1.25 mm", "0.9 mm", "3.5 mm"],
options_de: ["2,5 mm", "1,25 mm", "0,9 mm", "3,5 mm"],
answer: 1,
explanation:
"LC connectors have a 1.25mm ferrule, half the size of SC/FC/ST (2.5mm). This allows higher port density — two LC in the same panel space as one SC.",
explanation_de:
"LC-Stecker haben eine 1,25-mm-Ferrule, halb so gross wie SC/FC/ST (2,5 mm). Das ermoeglicht hoehere Portdichte — zwei LC im gleichen Panelplatz wie ein SC.",
},
{
id: "ff-conn-q2",
lesson: "ff-connectors",
q: "An APC connector has a green boot. What does this indicate?",
q_de: "Ein APC-Stecker hat einen gruenen Mantel. Was zeigt das an?",
options: [
"It is a multimode fiber connector",
"The ferrule is angled at 8° — not compatible with flat UPC connectors",
"It is certified for 10G and above",
"It contains a shutter for laser safety",
],
options_de: [
"Es ist ein Mehrmodefaser-Stecker",
"Die Ferrule ist um 8° geneigt — nicht kompatibel mit flachen UPC-Steckern",
"Es ist fuer 10G und hoeher zertifiziert",
"Es enthaelt einen Verschluss fuer Lasersicherheit",
],
answer: 1,
explanation:
"APC (Angled Physical Contact) connectors have an 8° angled ferrule that reflects backreflections away from the fiber. Green boot = APC. Never mate APC with UPC.",
explanation_de:
"APC (Angled Physical Contact)-Stecker haben eine 8°-Winkelferrule, die Rueckreflexionen von der Faser weglenkt. Gruener Mantel = APC. APC niemals mit UPC koppeln.",
},
{
id: "ff-conn-q3",
lesson: "ff-connectors",
q: "400GBASE-SR8 requires which MPO fiber count?",
q_de: "Welche MPO-Faseranzahl erfordert 400GBASE-SR8?",
options: ["MPO-8", "MPO-12", "MPO-16", "MPO-24"],
options_de: ["MPO-8", "MPO-12", "MPO-16", "MPO-24"],
answer: 2,
explanation:
"400GBASE-SR8 uses 8 transmit + 8 receive MMF lanes, requiring MPO-16 connectors (16 fibers per MPO).",
explanation_de:
"400GBASE-SR8 verwendet 8 Sende- und 8 Empfangs-MMF-Lanes und benoetigt MPO-16-Stecker (16 Fasern pro MPO).",
},
{
id: "ff-conn-q4",
lesson: "ff-connectors",
q: "What causes 'link down' on first MPO installation in most cases?",
q_de: "Was verursacht in den meisten Faellen 'Link Down' bei der ersten MPO-Installation?",
options: [
"Incorrect fiber attenuation specification",
"Wrong polarity — Tx fibers connected to Tx instead of Rx",
"Insufficient laser power in the module",
"Incompatible EEPROM version",
],
options_de: [
"Falsche Faseraempfungsspezifikation",
"Falsche Polaritaet — Sendefasern mit Sendefasern statt Empfangsfasern verbunden",
"Ungenuegend Laserleistung im Modul",
"Inkompatible EEPROM-Version",
],
answer: 1,
explanation:
"Wrong polarity means Tx fibers connect to the Tx side of the opposite module instead of Rx — both modules can only transmit or only receive. Polarity must be verified before or after cabling.",
explanation_de:
"Falsche Polaritaet bedeutet, dass Sendefasern mit der Sendeseite des gegenueberliegenden Moduls verbunden werden statt mit Empfangsseite — beide Module koennen nur senden oder nur empfangen. Polaritaet muss vor oder nach der Verkabelung geprueft werden.",
},
],
},
{
id: "ff-osfp-nextgen",
category: "form-factors",
title: "OSFP and Next-Generation Form Factors",
title_de: "OSFP und naechste Formfaktor-Generationen",
level: "intermediate",
duration_min: 10,
summary:
"OSFP was created for 800G and high-power coherent modules where QSFP-DD's thermal design is insufficient. Learn OSFP specifications, limitations, and where the industry is headed with co-packaged optics.",
summary_de:
"OSFP wurde fuer 800G und hochleistungsstarke Coherent-Module entwickelt, bei denen das thermische Design von QSFP-DD ungenuegend ist. Lernen Sie OSFP-Spezifikationen, Einschraenkungen und wohin die Branche mit Co-packaged Optics geht.",
tags: ["osfp", "800g", "co-packaged", "form-factor", "next-gen"],
sections: [
{
heading: "Why OSFP Was Needed",
heading_de: "Warum OSFP benoetigt wurde",
blocks: [
{
type: "p",
text: "QSFP-DD supports up to 1214W in standard configurations. As 800G modules and coherent ZR modules demand 2035W, the QSFP-DD thermal envelope becomes insufficient. OSFP (Octal SFP) was designed with a larger footprint to accommodate higher power budgets and more efficient heatsinking.",
text_de:
"QSFP-DD unterstuetzt bis zu 1214W in Standardkonfigurationen. Da 800G-Module und Coherent-ZR-Module 2035W benoetigen, wird das thermische QSFP-DD-Envelope ungenuegend. OSFP (Octal SFP) wurde mit einem groesseren Footprint entwickelt, um hoehere Leistungsbudgets und effizientere Waermeableitung zu ermoeglichen.",
},
{
type: "table",
headers: ["Spec", "QSFP-DD", "OSFP"],
headers_de: ["Spezifikation", "QSFP-DD", "OSFP"],
rows: [
["Electrical lanes", "8", "8"],
["Max speed", "400G (800G-DD800)", "800G"],
["Max power (std)", "12W (Class 8)", "15W (Class 1)"],
["Max power (ext)", "~20W (Ext. Class 8)", "35W (Class 9)"],
["Width", "18.4 mm", "22.6 mm"],
["Length", "72.4 mm", "107.8 mm"],
["Backward compat", "QSFP28, QSFP+", "No backward compat"],
["MSA body", "QSFP-DD MSA", "OSFP MSA"],
],
},
{
type: "callout",
variant: "warning",
text: "OSFP and QSFP-DD slots are physically incompatible. Never try to insert one into the other. When choosing a switch for 400G-ZR or 800G coherent, verify which slot type it has — some switches offer both types on different port groups.",
text_de:
"OSFP- und QSFP-DD-Steckplaetze sind physisch nicht kompatibel. Niemals eines in das andere einstecken versuchen. Beim Auswaehlen eines Switches fuer 400G-ZR oder 800G Coherent prufen, welchen Steckplatztyp er hat — einige Switches bieten beide Typen in verschiedenen Portgruppen.",
},
],
},
{
heading: "Where OSFP Is Used",
heading_de: "Wo OSFP eingesetzt wird",
blocks: [
{
type: "ul",
items: [
"800G direct-detect: 800GBASE-SR8, 800GBASE-DR8 — datacenter spine switches",
"400G-ZR coherent at high density: where 35W per port is needed and QSFP-DD thermal is insufficient",
"Hyperscaler leaf-spine at 800G (Meta, Google building 800G fabrics with OSFP)",
"Service provider core routers with 800G line cards",
],
items_de: [
"800G Direct-Detect: 800GBASE-SR8, 800GBASE-DR8 — Rechenzentrum-Spine-Switches",
"400G-ZR Coherent bei hoher Dichte: wo 35W pro Port benoetigt werden und QSFP-DD thermisch ungenuegend ist",
"Hyperscaler Leaf-Spine bei 800G (Meta, Google bauen 800G-Fabrics mit OSFP)",
"Service Provider Core-Router mit 800G Line Cards",
],
},
],
},
{
heading: "Co-Packaged Optics: The Next Frontier",
heading_de: "Co-packaged Optics: Die naechste Grenze",
blocks: [
{
type: "p",
text: "Beyond pluggables, the industry is developing Co-Packaged Optics (CPO) — where the optical engines are integrated directly onto the switch ASIC package, eliminating the electrical copper lanes between ASIC and pluggable cage. This dramatically reduces power and signal loss.",
text_de:
"Jenseits von Pluggables entwickelt die Branche Co-packaged Optics (CPO) — wo die optischen Engines direkt in das Switch-ASIC-Paket integriert werden, sodass die elektrischen Kupferleitungen zwischen ASIC und Pluggable-Kaefig entfallen. Das reduziert Leistung und Signalverlust erheblich.",
},
{
type: "ul",
items: [
"CPO: optical engine co-packaged with ASIC on the same PCB substrate",
"On-Board Optics (OBO): optics on the PCB, not co-packaged with ASIC",
"Silicon Photonics: using CMOS fab processes to build optical components in silicon",
"Timeline: CPO in hyperscale production 20252027; pluggables will remain dominant for 510+ years",
],
items_de: [
"CPO: optische Engine co-packaged mit ASIC auf demselben PCB-Substrat",
"On-Board Optics (OBO): Optiken auf der Leiterplatte, nicht mit dem ASIC co-packaged",
"Silicon Photonics: Verwendung von CMOS-Fab-Prozessen zum Aufbau optischer Komponenten in Silizium",
"Timeline: CPO in Hyperscale-Produktion 20252027; Pluggables bleiben 510+ Jahre dominant",
],
},
{
type: "callout",
variant: "info",
text: "For enterprise networks and most service providers, pluggable form factors (QSFP-DD, OSFP) will remain the standard for the foreseeable future. CPO is primarily a hyperscaler concern at 2025+ timescales.",
text_de:
"Fuer Enterprise-Netzwerke und die meisten Service-Provider bleiben Pluggable-Formfaktoren (QSFP-DD, OSFP) auf absehbare Zeit der Standard. CPO ist hauptsaechlich ein Hyperscaler-Thema ab 2025+.",
},
],
},
],
quiz: [
{
id: "ff-osfp-q1",
lesson: "ff-osfp-nextgen",
q: "What is the maximum power consumption of an OSFP Class 9 module?",
q_de: "Was ist der maximale Stromverbrauch eines OSFP Klasse 9 Moduls?",
options: ["12W", "20W", "35W", "50W"],
options_de: ["12W", "20W", "35W", "50W"],
answer: 2,
explanation:
"OSFP Class 9 supports up to 35W per port — enabling 800G coherent DSP modules and high-power ZR+ implementations.",
explanation_de:
"OSFP Klasse 9 unterstuetzt bis zu 35W pro Port — fuer 800G Coherent-DSP-Module und Hochleistungs-ZR+-Implementierungen.",
},
{
id: "ff-osfp-q2",
lesson: "ff-osfp-nextgen",
q: "Can OSFP modules be inserted into QSFP-DD cages?",
q_de: "Koennen OSFP-Module in QSFP-DD-Kaefige eingesteckt werden?",
options: [
"Yes — same electrical interface",
"Yes — with an adapter",
"No — physically incompatible, different width",
"Only for 400G modules",
],
options_de: [
"Ja — gleiche elektrische Schnittstelle",
"Ja — mit einem Adapter",
"Nein — physisch inkompatibel, unterschiedliche Breite",
"Nur fuer 400G-Module",
],
answer: 2,
explanation:
"OSFP (22.6mm wide) and QSFP-DD (18.4mm wide) are physically incompatible. The larger OSFP slot exists specifically to support higher power without backtracking compatibility.",
explanation_de:
"OSFP (22,6 mm breit) und QSFP-DD (18,4 mm breit) sind physisch inkompatibel. Der groessere OSFP-Steckplatz existiert speziell zur Unterstuetzung hoeherer Leistung.",
},
{
id: "ff-osfp-q3",
lesson: "ff-osfp-nextgen",
q: "What is Co-Packaged Optics (CPO)?",
q_de: "Was sind Co-packaged Optics (CPO)?",
options: [
"A new form factor that combines SFP and QSFP in one slot",
"Optical engines integrated directly onto the switch ASIC package",
"A type of coherent module for 400G-ZR",
"Copper DAC cables with optical connectors at the ends",
],
options_de: [
"Ein neuer Formfaktor, der SFP und QSFP in einem Steckplatz kombiniert",
"Optische Engines, die direkt in das Switch-ASIC-Paket integriert sind",
"Eine Art Coherent-Modul fuer 400G-ZR",
"Kupfer-DAC-Kabel mit optischen Steckern an den Enden",
],
answer: 1,
explanation:
"CPO integrates optical transceivers directly with the switch ASIC on the same package, eliminating the copper electrical lanes between ASIC and pluggable cage that limit bandwidth at 1.6T+.",
explanation_de:
"CPO integriert optische Transceiver direkt mit dem Switch-ASIC auf demselben Paket und eliminiert die elektrischen Kupferleitungen zwischen ASIC und Pluggable-Kaefig, die bei 1,6T+ die Bandbreite begrenzen.",
},
],
},
];

View File

@ -1,772 +0,0 @@
import type { TrainingLesson } from "./types";
export const infrastructureLessons: TrainingLesson[] = [
{
id: "infra-mmf",
category: "infrastructure",
title: "Multimode Fiber: OM1 through OM5",
title_de: "Mehrmodefaser: OM1 bis OM5",
level: "beginner",
duration_min: 12,
summary:
"Multimode fiber is the backbone of short-reach data center connections. Learn the differences between OM grades, what each supports, and how to choose the right fiber for your deployment.",
summary_de:
"Mehrmodefaser ist die Grundlage kurzreichweitiger Rechenzentrumsverbindungen. Lernen Sie die Unterschiede zwischen OM-Graden, was jede unterstuetzt und wie Sie die richtige Faser fuer Ihre Installation auswaehlen.",
tags: ["mmf", "om3", "om4", "om5", "fiber", "multimode"],
sections: [
{
heading: "What is Multimode Fiber?",
heading_de: "Was ist Mehrmodefaser?",
blocks: [
{
type: "p",
text: "Multimode fiber (MMF) has a large core (50µm or 62.5µm) that allows multiple modes (light paths) to propagate simultaneously. This makes it easier to couple light into the fiber and allows the use of inexpensive VCSEL (Vertical Cavity Surface Emitting Laser) sources at 850nm. The tradeoff: modal dispersion limits bandwidth and reach compared to single-mode fiber.",
text_de:
"Mehrmodefaser (MMF) hat einen grossen Kern (50 µm oder 62,5 µm), der mehrere Moden (Lichtpfade) gleichzeitig propagieren laesst. Das erleichtert die Lichteinkopplung und erlaubt den Einsatz guenstiger VCSEL-Quellen (Vertical Cavity Surface Emitting Laser) bei 850 nm. Der Kompromiss: Modendispersion begrenzt Bandbreite und Reichweite im Vergleich zu Einzelmodefaser.",
},
{
type: "table",
headers: ["OM Grade", "Core Diameter", "Jacket Color", "Bandwidth (850nm)", "Max Reach 10G", "Max Reach 100G", "Status"],
headers_de: ["OM-Grad", "Kerndurchmesser", "Mantelfarbe", "Bandbreite (850 nm)", "Max. Reichweite 10G", "Max. Reichweite 100G", "Status"],
rows: [
["OM1", "62.5 µm", "Orange", "200 MHz·km", "33 m", "Not supported", "Legacy — do not install"],
["OM2", "50 µm", "Orange", "500 MHz·km", "82 m", "Not supported", "Legacy — do not install"],
["OM3", "50 µm", "Aqua", "2000 MHz·km", "300 m", "70 m (SR4)", "Active — suitable for most DCs"],
["OM4", "50 µm", "Aqua or Violet", "4700 MHz·km", "400 m", "100 m (SR4)", "Active — recommended"],
["OM5", "50 µm", "Lime Green", "28000 MHz·km (at 953nm)", "400 m", "150 m (SWDM4)", "Active — 400G capable"],
],
},
{
type: "callout",
variant: "warning",
text: "Never mix OM1 (62.5µm) patch leads with OM3/OM4 (50µm) trunk cabling. The core size mismatch causes a significant insertion loss penalty (typically 35 dB extra loss) that will fail the link budget.",
text_de:
"Niemals OM1-Patchkabel (62,5 µm) mit OM3/OM4-Stammleitungen (50 µm) mischen. Die Kerngrossendifferenz verursacht einen erheblichen Einfuegungsverlustzuschlag (typisch 35 dB), der das Linkbudget zum Scheitern bringt.",
},
],
},
{
heading: "OM3 vs OM4: Which Should You Install?",
heading_de: "OM3 vs OM4: Was soll installiert werden?",
blocks: [
{
type: "p",
text: "For new installations, OM4 is the clear choice. It costs only slightly more than OM3 but provides significantly higher bandwidth and longer reach at 40G and 100G. For existing OM3 installations, it is not necessary to rip and replace — OM3 is sufficient for most data center distances (≤70m for 100G SR4).",
text_de:
"Fuer Neuinstallationen ist OM4 die klare Wahl. Es kostet nur geringfuegig mehr als OM3, bietet aber deutlich hoehere Bandbreite und laengere Reichweite bei 40G und 100G. Fuer bestehende OM3-Installationen ist ein Austausch nicht notwendig — OM3 genuegt fuer die meisten Rechenzentrumsabstaende (≤70 m fuer 100G SR4).",
},
{
type: "callout",
variant: "tip",
text: "OM5 adds support for SWDM (Short Wavelength Division Multiplexing) at 4 wavelengths (850, 880, 910, 950nm), enabling 100G on a single OM5 fiber pair. If you are planning for 400G over MMF, OM5 is the only option.",
text_de:
"OM5 fuegt Unterstuetzung fuer SWDM (Short Wavelength Division Multiplexing) mit 4 Wellenlaengen (850, 880, 910, 950 nm) hinzu und ermoeglicht 100G auf einem einzigen OM5-Faserpaar. Wenn Sie 400G ueber MMF planen, ist OM5 die einzige Option.",
},
],
},
{
heading: "Bend-Insensitive MMF",
heading_de: "Biegeunempfindliche MMF",
blocks: [
{
type: "p",
text: "Standard MMF is sensitive to tight bends — bending below the minimum bend radius causes light to escape the fiber core, increasing attenuation. Bend-insensitive OM3 (BI-OM3) and OM4 (BI-OM4) use a special coating that maintains performance at tighter bends, making them ideal for structured cabling in cabinets and bend-intensive routes.",
text_de:
"Standard-MMF ist empfindlich gegenueber engen Biegungen — Biegen unterhalb des Mindestbiegeradius fuehrt dazu, dass Licht den Faserkern verlaesst, was die Daempfung erhoet. Biegeunempfindliche OM3 (BI-OM3) und OM4 (BI-OM4) verwenden eine spezielle Beschichtung, die die Leistung bei engeren Biegungen aufrechterhaelt.",
},
{
type: "ul",
items: [
"Standard OM3/OM4: minimum bend radius 7.5mm (deployed), 30mm (installed)",
"Bend-insensitive OM3/OM4: minimum bend radius 2.0mm for short runs",
"Recommended for cabinet cable management, tight conduit runs, and under-floor routing",
"Cost premium: ~1020% over standard OM3/OM4 — usually worth it for complex routes",
],
items_de: [
"Standard OM3/OM4: Mindestbiegeradius 7,5 mm (verlegt), 30 mm (installiert)",
"Biegeunempfindliche OM3/OM4: Mindestbiegeradius 2,0 mm fuer kurze Laeufe",
"Empfohlen fuer Schrankkabelmanagement, enge Rohrleitungen und Unterbodenverlegung",
"Kostenaufschlag: ~1020 % gegenueber Standard-OM3/OM4 — lohnt sich meist bei komplexen Wegen",
],
},
],
},
],
quiz: [
{
id: "infra-mmf-q1",
lesson: "infra-mmf",
q: "What is the maximum reach of 100GBASE-SR4 over OM4 fiber?",
q_de: "Welche maximale Reichweite hat 100GBASE-SR4 ueber OM4-Faser?",
options: ["70 m", "100 m", "150 m", "300 m"],
options_de: ["70 m", "100 m", "150 m", "300 m"],
answer: 1,
explanation: "100GBASE-SR4 reaches 100m on OM4. On OM3, it's limited to 70m.",
explanation_de: "100GBASE-SR4 erreicht 100 m auf OM4. Auf OM3 ist es auf 70 m begrenzt.",
},
{
id: "infra-mmf-q2",
lesson: "infra-mmf",
q: "What colour jacket identifies OM5 fiber?",
q_de: "Welche Mantelfarbe kennzeichnet OM5-Faser?",
options: ["Orange", "Aqua (blue-green)", "Lime Green", "Yellow"],
options_de: ["Orange", "Aqua (blau-gruen)", "Gelbgruen (Lime)", "Gelb"],
answer: 2,
explanation: "OM5 is identified by a lime green jacket. Aqua is OM3/OM4. Orange is OM1/OM2. Yellow is single-mode.",
explanation_de: "OM5 wird durch einen gelbgruenen Mantel identifiziert. Aqua ist OM3/OM4. Orange ist OM1/OM2. Gelb ist Einzelmodefaser.",
},
{
id: "infra-mmf-q3",
lesson: "infra-mmf",
q: "What happens when you mix OM1 (62.5µm) patch cords with OM4 (50µm) trunk cable?",
q_de: "Was passiert, wenn OM1-Patchkabel (62,5 µm) mit OM4-Stammkabel (50 µm) gemischt werden?",
options: [
"No impact — fibers auto-adapt",
"35 dB additional insertion loss due to core size mismatch",
"The connector will not physically mate",
"The fiber will operate at OM1 bandwidth only",
],
options_de: [
"Kein Einfluss — Fasern passen sich automatisch an",
"35 dB zusaetzlicher Einfuegungsverlust durch Kerngrossendifferenz",
"Der Stecker kann physisch nicht gekoppelt werden",
"Die Faser arbeitet nur mit OM1-Bandbreite",
],
answer: 1,
explanation:
"Mismatching core sizes causes significant insertion loss (typically 35 dB) at the coupling point, which can exceed the module's link budget and cause link failure.",
explanation_de:
"Das Mischen von Kerngrossenmassen verursacht erheblichen Einfuegungsverlust (typisch 35 dB) an der Kupplungsstelle, was das Linkbudget des Moduls ueberschreiten und zu Verbindungsausfaellen fuehren kann.",
},
{
id: "infra-mmf-q4",
lesson: "infra-mmf",
q: "OM5 fiber enables 400G over MMF using which technology?",
q_de: "Welche Technologie ermoeglicht OM5-Faser fuer 400G ueber MMF?",
options: [
"PAM4 on a single 850nm wavelength",
"SWDM4 — 4 wavelengths from 850 to 950nm",
"CWDM4 — same as single-mode",
"Coherent optics on MMF",
],
options_de: [
"PAM4 auf einer einzigen 850-nm-Wellenlaenge",
"SWDM4 — 4 Wellenlaengen von 850 bis 950 nm",
"CWDM4 — wie bei Einzelmodefaser",
"Coherent-Optik auf MMF",
],
answer: 1,
explanation:
"OM5 supports SWDM (Short Wavelength Division Multiplexing) with 4 wavelengths (850, 880, 910, 950nm), enabling 100G or 400G on a single OM5 fiber pair with SWDM4 modules.",
explanation_de:
"OM5 unterstuetzt SWDM (Short Wavelength Division Multiplexing) mit 4 Wellenlaengen (850, 880, 910, 950 nm) und ermoeglicht 100G oder 400G auf einem einzigen OM5-Faserpaar mit SWDM4-Modulen.",
},
],
},
{
id: "infra-smf",
category: "infrastructure",
title: "Single-Mode Fiber: OS1, OS2, and Specialty Types",
title_de: "Einzelmodefaser: OS1, OS2 und Spezialtypen",
level: "beginner",
duration_min: 12,
summary:
"Single-mode fiber carries signals over campus, metro, and long-haul distances. Understand the OS grades, ITU-T classifications, and how to choose the right fiber for long-reach applications.",
summary_de:
"Einzelmodefaser uebertraegt Signale ueber Campus-, Metro- und Langstrecken. Verstehen Sie OS-Grade, ITU-T-Klassifizierungen und wie Sie die richtige Faser fuer Langstreckenanwendungen auswaehlen.",
tags: ["smf", "os1", "os2", "g652", "g654", "single-mode", "fiber"],
sections: [
{
heading: "Single-Mode Fiber Fundamentals",
heading_de: "Einzelmodefaser-Grundlagen",
blocks: [
{
type: "p",
text: "Single-mode fiber (SMF) has a very small core (typically 9µm) that supports only one propagation mode at a given wavelength. This eliminates modal dispersion, resulting in much lower attenuation and much longer reach than multimode fiber. SMF requires laser sources (not LEDs) and precision connectors, making it more expensive than MMF for short links.",
text_de:
"Einzelmodefaser (SMF) hat einen sehr kleinen Kern (typischerweise 9 µm), der nur eine Ausbreitungsmode bei einer gegebenen Wellenlaenge unterstuetzt. Das eliminiert Modendispersion und ergibt wesentlich geringere Daempfung und viel laengere Reichweiten als Mehrmodefaser. SMF erfordert Laserquellen (keine LEDs) und Praezisionsstecker, was sie fuer kurze Verbindungen teurer macht als MMF.",
},
{
type: "table",
headers: ["Grade", "Standard", "Loss (1310nm)", "Loss (1550nm)", "Jacket Color", "Application"],
headers_de: ["Grad", "Standard", "Verlust (1310 nm)", "Verlust (1550 nm)", "Mantelfarbe", "Anwendung"],
rows: [
["OS1", "IEC 60793-2-50", "≤1.0 dB/km", "≤1.0 dB/km", "Yellow", "Indoor tight-buffered cable"],
["OS2", "IEC 60793-2-50", "≤0.4 dB/km", "≤0.3 dB/km", "Yellow", "Outdoor loose-tube cable, lower loss"],
],
},
{
type: "callout",
variant: "info",
text: "OS1 and OS2 use the same 9µm core — the difference is the cable construction and resulting loss spec. OS2 (loose-tube outdoor) achieves ≤0.3 dB/km at 1550nm; OS1 (indoor tight-buffered) allows up to 1.0 dB/km. Always use OS2 for inter-building or outdoor runs.",
text_de:
"OS1 und OS2 verwenden denselben 9-µm-Kern — der Unterschied ist die Kabelkonstruktion und resultierende Verlustspezifikation. OS2 (Loose-Tube-Aussenbereich) erreicht ≤0,3 dB/km bei 1550 nm; OS1 (Innenbereich eng gepuffert) erlaubt bis zu 1,0 dB/km. Immer OS2 fuer Gebaeude- oder Aussenverbindungen verwenden.",
},
],
},
{
heading: "ITU-T Fiber Classifications",
heading_de: "ITU-T Faserklassifizierungen",
blocks: [
{
type: "table",
headers: ["ITU-T Type", "Common Name", "Attenuation", "Key Property", "Typical Application"],
headers_de: ["ITU-T-Typ", "Haeufiger Name", "Daempfung", "Wesentliche Eigenschaft", "Typische Anwendung"],
rows: [
["G.652D", "Standard SMF", "0.38 dB/km @1310nm", "Most widely deployed", "Campus, metro, LAN, WAN"],
["G.654E", "Ultra-Low Loss (ULL)", "≤0.17 dB/km @1550nm", "Lowest attenuation", "Submarine, long-haul coherent"],
["G.657A1/A2", "Bend-insensitive SMF", "Same as G.652", "Tight bend tolerance", "Indoor, FTTH, cabinet routing"],
["G.655", "Non-zero dispersion", "~0.35 dB/km", "Reduced nonlinear effects", "Legacy DWDM (being replaced by G.652D+DCM)"],
],
},
{
type: "callout",
variant: "key",
text: "For 400G and 800G coherent applications over long distances, G.654E (Ultra-Low Loss) fiber dramatically increases reach. If you are quoting coherent modules for a customer, ask about their fiber type — G.652D vs G.654E can mean the difference between a workable link budget and a failing one.",
text_de:
"Fuer 400G und 800G Coherent-Anwendungen ueber lange Distanzen erhoet G.654E-Faser (Ultra-Low Loss) die Reichweite erheblich. Wenn Sie einem Kunden Coherent-Module anbieten, nach dem Fasertyp fragen — G.652D vs G.654E kann den Unterschied zwischen einem funktionierenden Linkbudget und einem fehlschlagenden bedeuten.",
},
],
},
{
heading: "Chromatic and Polarization Mode Dispersion",
heading_de: "Chromatische und Polarisationsmodendispersion",
blocks: [
{
type: "p",
text: "At 10G and below, dispersion is rarely a problem on standard G.652D fiber. At 100G and above, two types of dispersion become important: Chromatic Dispersion (CD) — different wavelengths travel at slightly different speeds — and Polarization Mode Dispersion (PMD) — the two polarization states of a single-mode signal travel at different speeds.",
text_de:
"Bei 10G und darunter ist Dispersion auf Standard-G.652D-Faser selten ein Problem. Bei 100G und hoeher werden zwei Dispersionstypen wichtig: Chromatische Dispersion (CD) — verschiedene Wellenlaengen propagieren mit leicht unterschiedlichen Geschwindigkeiten — und Polarisationsmodendispersion (PMD) — die beiden Polarisationszustaende eines Einzelmode-Signals propagieren mit verschiedenen Geschwindigkeiten.",
},
{
type: "ul",
items: [
"CD (Chromatic Dispersion): 17 ps/(nm·km) on G.652D at 1550nm — compensated by DCM (Dispersion Compensating Module) or coherent DSP",
"PMD: legacy older fiber may have high PMD (>0.5 ps/√km) — problematic at 100G+; modern fiber <0.1 ps/√km",
"Coherent 100G/400G/ZR: DSP handles both CD and PMD electronically — no physical DCM needed",
"Direct-detect 100G (DR, FR, LR): no CD/PMD compensation — limited to moderate distances",
],
items_de: [
"CD (Chromatische Dispersion): 17 ps/(nm·km) auf G.652D bei 1550 nm — kompensiert durch DCM (Dispersion Compensating Module) oder Coherent-DSP",
"PMD: aeltere Legacy-Faser kann hohe PMD haben (>0,5 ps/√km) — problematisch bei 100G+; moderne Faser <0,1 ps/√km",
"Coherent 100G/400G/ZR: DSP behandelt sowohl CD als auch PMD elektronisch — kein physisches DCM benoetigt",
"Direct-Detect 100G (DR, FR, LR): keine CD/PMD-Kompensation — auf maessige Distanzen begrenzt",
],
},
],
},
],
quiz: [
{
id: "infra-smf-q1",
lesson: "infra-smf",
q: "What is the key difference between OS1 and OS2 single-mode fiber?",
q_de: "Was ist der wesentliche Unterschied zwischen OS1- und OS2-Einzelmodefaser?",
options: [
"OS1 has a 9µm core; OS2 has a 50µm core",
"OS1 is indoor tight-buffered with higher loss; OS2 is outdoor loose-tube with lower loss",
"OS1 supports 10G; OS2 supports 100G",
"OS1 is single-mode; OS2 is multimode",
],
options_de: [
"OS1 hat einen 9-µm-Kern; OS2 einen 50-µm-Kern",
"OS1 ist innen eng gepuffert mit hoeherer Daempfung; OS2 ist aussen loose-tube mit geringerer Daempfung",
"OS1 unterstuetzt 10G; OS2 unterstuetzt 100G",
"OS1 ist Einzelmode; OS2 ist Mehrmode",
],
answer: 1,
explanation:
"Both OS1 and OS2 have 9µm cores. OS1 (indoor tight-buffered) allows up to 1.0 dB/km loss. OS2 (outdoor loose-tube) achieves ≤0.3 dB/km at 1550nm — much lower loss.",
explanation_de:
"Beide OS1 und OS2 haben 9-µm-Kerne. OS1 (innen eng gepuffert) erlaubt bis zu 1,0 dB/km Verlust. OS2 (aussen loose-tube) erreicht ≤0,3 dB/km bei 1550 nm — viel geringerer Verlust.",
},
{
id: "infra-smf-q2",
lesson: "infra-smf",
q: "Which ITU-T fiber type has the lowest attenuation — suitable for submarine and ultra-long-haul?",
q_de: "Welcher ITU-T-Fasertyp hat die geringste Daempfung — geeignet fuer Unterwasser und Ultra-Langstrecke?",
options: ["G.652D (Standard SMF)", "G.657A2 (Bend-insensitive)", "G.654E (Ultra-Low Loss)", "G.655 (Non-zero dispersion)"],
options_de: [
"G.652D (Standard SMF)",
"G.657A2 (Biegeunempfindlich)",
"G.654E (Ultra-Low Loss)",
"G.655 (Nicht-null Dispersion)",
],
answer: 2,
explanation:
"G.654E (Ultra-Low Loss fiber) achieves ≤0.17 dB/km at 1550nm — the lowest attenuation of any standard fiber. Used for submarine cables and coherent long-haul systems.",
explanation_de:
"G.654E (Ultra-Low Loss-Faser) erreicht ≤0,17 dB/km bei 1550 nm — die geringste Daempfung aller Standardfasern. Verwendet fuer Unterwasserkabel und Coherent-Langstreckensysteme.",
},
{
id: "infra-smf-q3",
lesson: "infra-smf",
q: "At 100G+ speeds, which dispersion type can be compensated electronically by coherent DSP?",
q_de: "Bei 100G+ Geschwindigkeiten welchen Dispersionstyp kann Coherent-DSP elektronisch kompensieren?",
options: [
"Neither — physical DCM modules are always required",
"CD only, not PMD",
"Both Chromatic Dispersion (CD) and Polarization Mode Dispersion (PMD)",
"PMD only, not CD",
],
options_de: [
"Keinen — physische DCM-Module sind immer erforderlich",
"Nur CD, nicht PMD",
"Sowohl chromatische Dispersion (CD) als auch Polarisationsmodendispersion (PMD)",
"Nur PMD, nicht CD",
],
answer: 2,
explanation:
"Coherent 100G/400G/ZR modules include a DSP that electronically compensates both CD and PMD. This eliminates the need for physical DCM (Dispersion Compensating Modules) that were required for earlier 10G systems.",
explanation_de:
"Coherent 100G/400G/ZR-Module enthalten einen DSP, der sowohl CD als auch PMD elektronisch kompensiert. Das eliminiert die Notwendigkeit physischer DCM (Dispersion Compensating Modules), die fuer frueherer 10G-Systeme erforderlich waren.",
},
],
},
{
id: "infra-link-budget",
category: "infrastructure",
title: "Link Budget Calculation",
title_de: "Linkbudget-Berechnung",
level: "intermediate",
duration_min: 15,
summary:
"Link budget is the most fundamental calculation in fiber optic design — it determines whether a link will work reliably. Learn to calculate link budget step by step with real examples.",
summary_de:
"Das Linkbudget ist die grundlegendste Berechnung im Glasfaserdesign — es bestimmt, ob eine Verbindung zuverlaessig funktioniert. Lernen Sie, das Linkbudget Schritt fuer Schritt mit echten Beispielen zu berechnen.",
tags: ["link-budget", "attenuation", "power-budget", "fiber", "calculation"],
sections: [
{
heading: "The Link Budget Equation",
heading_de: "Die Linkbudget-Gleichung",
blocks: [
{
type: "p",
text: "A link budget calculates whether the optical power reaching the receiver is sufficient for reliable operation. The module's transmit power (TX min) must exceed the receiver sensitivity (RX sens) by enough margin to overcome all fiber losses in the path.",
text_de:
"Ein Linkbudget berechnet, ob die am Empfaenger ankommende optische Leistung fuer zuverlaessigen Betrieb ausreicht. Die Sendeleistung des Moduls (TX min) muss die Empfaengerempfindlichkeit (RX sens) um genug Spielraum uebertreffen, um alle Faserverluste im Pfad auszugleichen.",
},
{
type: "formula",
text: "Link Budget (dB) = TX_min (dBm) RX_sensitivity (dBm)\nMargin (dB) = Link Budget Total Path Loss\nRequired: Margin ≥ 3 dB (minimum), ≥ 4 dB recommended",
desc: "TX_min = minimum guaranteed TX power from datasheet; RX_sensitivity = minimum received power at target BER (10⁻¹²); Total Path Loss = fiber loss + connector losses + splice losses + any other elements",
desc_de:
"TX_min = garantierte Mindest-TX-Leistung laut Datenblatt; RX_sensitivity = mindest empfangene Leistung bei Ziel-BER (10⁻¹²); Gesamtpfadverlust = Faserverlust + Steckerverluste + Spleissverluste + sonstige Elemente",
},
{
type: "callout",
variant: "key",
text: "Always use the MINIMUM values from the datasheet for TX power and MAXIMUM values for path loss when calculating. This ensures your link works even under worst-case conditions (aging, temperature extremes, manufacturing tolerances).",
text_de:
"Immer die MINDEST-Werte aus dem Datenblatt fuer TX-Leistung und MAXIMAL-Werte fuer Pfadverlust verwenden. Das stellt sicher, dass die Verbindung auch unter Worst-Case-Bedingungen (Alterung, Temperaturaenderungen, Fertigungstoleranzen) funktioniert.",
},
],
},
{
heading: "Typical Loss Values",
heading_de: "Typische Verlustswerte",
blocks: [
{
type: "table",
headers: ["Component", "Loss per Event", "Notes"],
headers_de: ["Komponente", "Verlust pro Ereignis", "Hinweise"],
rows: [
["OS2 SMF (1310nm)", "0.35 dB/km", "G.652D typical; use 0.4 for budget"],
["OS2 SMF (1550nm)", "0.25 dB/km", "G.652D typical; use 0.3 for budget"],
["OM3 (850nm)", "3.5 dB/km", "Much higher than SMF"],
["OM4 (850nm)", "3.0 dB/km", "Typical"],
["LC/PC or LC/UPC connector mating", "0.30.5 dB", "Use 0.5 dB for budget"],
["LC/APC connector mating", "0.10.3 dB", "Better return loss"],
["Fusion splice", "0.050.15 dB", "Good splice; use 0.1 dB"],
["Mechanical splice", "0.20.5 dB", "Avoid where possible"],
["Patch panel (2 connectors)", "0.61.0 dB total", "2× connector losses"],
["MPO-to-LC cassette (12-fiber)", "0.71.5 dB", "Per breakout connection"],
],
},
],
},
{
heading: "Worked Example: 10GBASE-LR over 8km",
heading_de: "Berechnetes Beispiel: 10GBASE-LR ueber 8 km",
blocks: [
{
type: "p",
text: "Scenario: We have an 8km OS2 single-mode fiber run between two buildings. The path goes through 2 patch panels (4 connectors total) and has 3 fusion splices. We want to use 10GBASE-LR modules. Can the link work?",
text_de:
"Szenario: Wir haben eine 8-km-OS2-Einzelmodefaserverbindung zwischen zwei Gebaeuden. Der Pfad durchlaeuft 2 Patchfelder (4 Stecker insgesamt) und hat 3 Fusionsspleisse. Wir moechten 10GBASE-LR-Module verwenden. Funktioniert die Verbindung?",
},
{
type: "code",
text: "10GBASE-LR Datasheet:\n TX Output Power (min): -8.2 dBm\n RX Sensitivity (max): -14.4 dBm\n Link Budget = -8.2 - (-14.4) = 6.2 dB\n\nPath Loss Calculation:\n Fiber: 8 km × 0.4 dB/km = 3.2 dB\n Connectors: 4 × 0.5 dB = 2.0 dB\n Splices: 3 × 0.1 dB = 0.3 dB\n Total Path Loss = 5.5 dB\n\nMargin = Link Budget - Path Loss\n = 6.2 - 5.5 = 0.7 dB\n\nResult: MARGINAL — only 0.7 dB margin.\nRecommendation: Clean connectors, verify splice quality.\nAlternative: Use a module with higher TX power (Extended Reach).",
},
{
type: "callout",
variant: "warning",
text: "A margin of 0.7 dB is dangerously thin. Dirty connectors add 0.53 dB, making this link unreliable. Always target ≥3 dB margin. If the margin is under 3 dB, upgrade the module TX power, reduce connector count, or shorten the path.",
text_de:
"Ein Spielraum von 0,7 dB ist gefaehrlich gering. Schmutzige Stecker fuegen 0,53 dB hinzu und machen diese Verbindung unzuverlaessig. Immer ≥3 dB Spielraum anstreben. Wenn der Spielraum unter 3 dB liegt, Modul-TX-Leistung erhoehen, Steckeranzahl reduzieren oder Pfad verkuerzen.",
},
],
},
{
heading: "Overpower: The Short Link Problem",
heading_de: "Ueberleistung: Das Kurzstrecken-Problem",
blocks: [
{
type: "p",
text: "For very short links, the received power may EXCEED the receiver's maximum input power, causing saturation and bit errors. This is called 'overpower'. It commonly occurs when using a long-reach module (e.g., LR, ZR) on a very short fiber run (e.g., 1m within a cabinet).",
text_de:
"Bei sehr kurzen Verbindungen kann die empfangene Leistung die maximale Eingangsleistung des Empfaengers UEBERSCHREITEN, was zu Saettigung und Bitfehlern fuehrt. Das nennt sich 'Ueberleistung'. Es tritt haeufig auf, wenn ein Langstreckenmodul (z. B. LR, ZR) auf einer sehr kurzen Faserverbindung verwendet wird (z. B. 1 m im Schrank).",
},
{
type: "ul",
items: [
"Symptom: link comes up but shows high BER / input errors — same as underpower",
"Diagnosis: check RX power via DOM — if above RX max, overpower is the cause",
"Solution: use inline fiber attenuator (e.g., 5 dB, 10 dB, LC/LC in-line)",
"Tip: for same-rack connections, use SR modules (designed for short reach) or DAC/AOC cables",
],
items_de: [
"Symptom: Verbindung kommt hoch, zeigt aber hohe BER/Eingabefehler — wie Unterleistung",
"Diagnose: RX-Leistung ueber DOM pruefen — wenn oberhalb RX max, ist Ueberleistung die Ursache",
"Loesung: Inline-Faser-Daempfungsglied verwenden (z. B. 5 dB, 10 dB, LC/LC In-line)",
"Tipp: Fuer Verbindungen im gleichen Rack SR-Module (fuer kurze Reichweite ausgelegt) oder DAC/AOC-Kabel verwenden",
],
},
],
},
],
quiz: [
{
id: "infra-lb-q1",
lesson: "infra-link-budget",
q: "A module has TX min = -5 dBm and RX sensitivity = -18 dBm. What is the link budget?",
q_de: "Ein Modul hat TX min = -5 dBm und RX-Empfindlichkeit = -18 dBm. Wie gross ist das Linkbudget?",
options: ["5 dB", "13 dB", "18 dB", "23 dB"],
options_de: ["5 dB", "13 dB", "18 dB", "23 dB"],
answer: 1,
explanation:
"Link Budget = TX_min RX_sens = -5 (-18) = 13 dB. This means the path can have up to 13 dB of total loss before the link fails.",
explanation_de:
"Linkbudget = TX_min RX_sens = -5 (-18) = 13 dB. Das bedeutet, der Pfad kann bis zu 13 dB Gesamtverlust haben, bevor die Verbindung ausfaellt.",
},
{
id: "infra-lb-q2",
lesson: "infra-link-budget",
q: "What minimum margin is recommended for a reliable fiber link?",
q_de: "Welcher Mindestspielraum wird fuer eine zuverlaessige Glasfaserverbindung empfohlen?",
options: ["0 dB (link budget = path loss exactly)", "1 dB", "3 dB", "10 dB"],
options_de: ["0 dB (Linkbudget = Pfadverlust exakt)", "1 dB", "3 dB", "10 dB"],
answer: 2,
explanation:
"A minimum of 3 dB margin is recommended to account for fiber aging, temperature variation, connector degradation, and measurement uncertainty. 4 dB is preferred.",
explanation_de:
"Ein Mindestspielraum von 3 dB wird empfohlen, um Faseralterung, Temperaturschwankungen, Steckerverschlechterung und Messunsicherheit zu beruecksichtigen. 4 dB wird bevorzugt.",
},
{
id: "infra-lb-q3",
lesson: "infra-link-budget",
q: "A link shows high input errors but the fiber appears undamaged. DOM shows RX power is above the maximum receiver input level. What is the issue?",
q_de: "Eine Verbindung zeigt hohe Eingabefehler, aber die Faser erscheint unbeschaedigt. DOM zeigt, dass die RX-Leistung ueber dem maximalen Empfaenger-Eingangsleistungspegel liegt. Was ist das Problem?",
options: [
"The module is defective",
"Overpower — receiver is saturated by too much optical power",
"The fiber is too long",
"FEC mismatch between the two ends",
],
options_de: [
"Das Modul ist defekt",
"Ueberleistung — Empfaenger ist durch zu viel optische Leistung gesaettigt",
"Die Faser ist zu lang",
"FEC-Fehlanpassung zwischen beiden Enden",
],
answer: 1,
explanation:
"Overpower (receiver saturation) causes bit errors just like underpower. If DOM shows RX power above the maximum input level, add an inline fiber attenuator or switch to a short-reach module.",
explanation_de:
"Ueberleistung (Empfaengersiettigung) verursacht Bitfehler wie Unterleistung. Wenn DOM zeigt, dass RX-Leistung ueber dem maximalen Eingangspegel liegt, ein Inline-Faser-Daempfungsglied hinzufuegen oder auf ein Kurzstreckenmodul wechseln.",
},
],
},
{
id: "infra-mpo",
category: "infrastructure",
title: "MPO/MTP Cabling and Polarity",
title_de: "MPO/MTP-Verkabelung und Polaritaet",
level: "intermediate",
duration_min: 12,
summary:
"MPO/MTP cables carry 1232 fibers in one connector and are essential for high-density 40G, 100G, and 400G connections. Master the polarity rules that determine whether your link works.",
summary_de:
"MPO/MTP-Kabel fuehren 1232 Fasern in einem Stecker und sind fuer hochdichte 40G-, 100G- und 400G-Verbindungen unabdingbar. Beherrschen Sie die Polaritaetsregeln, die bestimmen, ob Ihre Verbindung funktioniert.",
tags: ["mpo", "mtp", "polarity", "40g", "100g", "400g", "cabling"],
sections: [
{
heading: "MPO Cable Architecture",
heading_de: "MPO-Kabelarchitektur",
blocks: [
{
type: "p",
text: "A typical data center MPO system consists of: (1) Pre-terminated MPO trunk cables running from patch panel to patch panel, (2) Breakout cassettes that convert MPO to LC duplex ports, and (3) LC duplex patch cords to transceivers. The MPO connector has a 'key up' or 'key down' orientation — this determines fiber numbering and directly affects polarity.",
text_de:
"Ein typisches Rechenzentrum-MPO-System besteht aus: (1) vorkonfekktionierten MPO-Trunk-Kabeln von Patchfeld zu Patchfeld, (2) Breakout-Kassetten, die MPO auf LC-Duplex-Ports konvertieren, und (3) LC-Duplex-Patchkabeln zu Transceivern. Der MPO-Stecker hat eine 'Key-Up'- oder 'Key-Down'-Ausrichtung — das bestimmt die Fasernummerierung und beeinflusst direkt die Polaritaet.",
},
{
type: "callout",
variant: "warning",
text: "The MOST COMMON cause of 'link down' on new 40G/100G MPO installations is incorrect polarity. Before declaring a module faulty, always verify the polarity of the entire MPO path including trunk cable type and cassette type.",
text_de:
"Die HAEUFIGSTE Ursache fuer 'Link Down' bei neuen 40G/100G MPO-Installationen ist falsche Polaritaet. Bevor Sie ein Modul als defekt erklaeren, immer die Polaritaet des gesamten MPO-Pfads einschliesslich Trunk-Kabeltyp und Kassettentyp pruefen.",
},
],
},
{
heading: "Polarity Types",
heading_de: "Polaritaetstypen",
blocks: [
{
type: "table",
headers: ["Type", "Description", "Fiber 1 → Connects to", "Common Application"],
headers_de: ["Typ", "Beschreibung", "Faser 1 → verbindet mit", "Haeufige Anwendung"],
rows: [
["Type A (Straight-through)", "Fiber positions preserved end-to-end", "Fiber 1 at far end", "Used with Type B cassettes or flip patch cord"],
["Type B (Reversed)", "Fiber array flipped — Fiber 1 ↔ Fiber 12", "Fiber 12 at far end", "Most common trunk cable; works with standard Type A cassettes"],
["Type C (Pair-flipped)", "Adjacent fiber pairs swapped: 1↔2, 3↔4...", "Fiber 2 at far end", "Less common; used in specific legacy WDM systems"],
],
},
{
type: "p",
text: "The industry has converged on a standard approach: Type B trunk cables + Type A (straight) breakout cassettes = correct end-to-end polarity for SR4/SR8 transceivers. This is specified in TIA-568-C.0 Method B. Most modern pre-terminated cabling systems are pre-configured for this.",
text_de:
"Die Branche hat sich auf einen Standardansatz geeinigt: Type-B-Trunk-Kabel + Type-A-(Durchgangs-)Breakout-Kassetten = korrekte End-zu-End-Polaritaet fuer SR4/SR8-Transceiver. Das ist in TIA-568-C.0 Methode B spezifiziert. Die meisten modernen vorkonfekktionierten Verkabelungssysteme sind dafuer vorkonfiguriert.",
},
],
},
{
heading: "Verifying Polarity",
heading_de: "Polaritaet pruefen",
blocks: [
{
type: "ul",
items: [
"Use a VFL (Visual Fault Locator) red laser: shine it in fiber 1 at one end and check which fiber lights up at the other end",
"For MPO-12: fiber 1 of transmit should appear at fiber 1 of the receive connector of the far transceiver",
"Use a fiber identifier / polarity checker tool for quick verification",
"Document your polarity scheme before deploying large cable runs — saves hours of debugging",
],
items_de: [
"VFL (Visual Fault Locator) Rotlaser verwenden: Faser 1 an einem Ende beleuchten und pruefen, welche Faser am anderen Ende leuchtet",
"Fuer MPO-12: Faser 1 des Senders sollte an Faser 1 des Empfaengesteckers des entfernten Transceivers erscheinen",
"Faser-Identifier/Polaritaetsprueger-Werkzeug fuer schnelle Verifizierung verwenden",
"Polaritaetsschema vor der Verlegung grosser Kabelmengen dokumentieren — spart Stunden Debugging",
],
},
],
},
],
quiz: [
{
id: "infra-mpo-q1",
lesson: "infra-mpo",
q: "What is the industry standard combination of trunk cable and cassette type for correct SR4 polarity?",
q_de: "Welche Standardkombination aus Trunk-Kabel und Kassettentyp ergibt korrekte SR4-Polaritaet?",
options: [
"Type A trunk + Type A cassette",
"Type B trunk + Type A cassette",
"Type C trunk + Type B cassette",
"Any combination works",
],
options_de: [
"Type-A-Trunk + Type-A-Kassette",
"Type-B-Trunk + Type-A-Kassette",
"Type-C-Trunk + Type-B-Kassette",
"Jede Kombination funktioniert",
],
answer: 1,
explanation:
"TIA-568-C.0 Method B specifies Type B trunk cables with Type A (straight) breakout cassettes for correct end-to-end polarity with parallel-optics transceivers (SR4, SR8).",
explanation_de:
"TIA-568-C.0 Methode B spezifiziert Type-B-Trunk-Kabel mit Type-A-(Durchgangs-)Breakout-Kassetten fuer korrekte End-zu-End-Polaritaet mit Parallel-Optik-Transceivern (SR4, SR8).",
},
{
id: "infra-mpo-q2",
lesson: "infra-mpo",
q: "In a Type B MPO cable, fiber 1 at one end connects to which fiber at the far end?",
q_de: "In einem Type-B-MPO-Kabel verbindet sich Faser 1 an einem Ende mit welcher Faser am anderen Ende?",
options: ["Fiber 1 (straight-through)", "Fiber 6", "Fiber 12 (reversed)", "Fiber 7"],
options_de: ["Faser 1 (Durchgang)", "Faser 6", "Faser 12 (umgekehrt)", "Faser 7"],
answer: 2,
explanation:
"Type B (reversed) cables flip the fiber array: Fiber 1 at one end connects to Fiber 12 at the other end. This is intentional and works correctly when paired with the right cassette type.",
explanation_de:
"Type-B-(umgekehrte) Kabel spiegeln das Faser-Array: Faser 1 an einem Ende verbindet sich mit Faser 12 am anderen Ende. Das ist beabsichtigt und funktioniert korrekt, wenn mit dem richtigen Kassettentyp gepaart.",
},
{
id: "infra-mpo-q3",
lesson: "infra-mpo",
q: "A 40GBASE-SR4 link shows 'link down' immediately after MPO installation. What should you check first?",
q_de: "Eine 40GBASE-SR4-Verbindung zeigt sofort nach der MPO-Installation 'Link Down'. Was sollten Sie zuerst pruefen?",
options: [
"Replace both transceivers",
"Check MPO polarity — TX fibers may be connected to TX instead of RX",
"Check if the switch supports 40G",
"Verify the fiber is OM4 not OM3",
],
options_de: [
"Beide Transceiver austauschen",
"MPO-Polaritaet pruefen — TX-Fasern koennten mit TX statt RX verbunden sein",
"Pruefen, ob der Switch 40G unterstuetzt",
"Verifizieren, ob die Faser OM4 und nicht OM3 ist",
],
answer: 1,
explanation:
"Wrong polarity (TX→TX instead of TX→RX) is the #1 cause of 'link down' with new MPO installations. Use a VFL or polarity checker before concluding the modules are faulty.",
explanation_de:
"Falsche Polaritaet (TX→TX statt TX→RX) ist die haeufigste Ursache fuer 'Link Down' bei neuen MPO-Installationen. VFL oder Polaritaetsprueger verwenden, bevor Module als defekt eingestuft werden.",
},
],
},
{
id: "infra-wavelengths",
category: "infrastructure",
title: "CWDM and DWDM Wavelengths",
title_de: "CWDM- und DWDM-Wellenlaengen",
level: "intermediate",
duration_min: 15,
summary:
"WDM (Wavelength Division Multiplexing) multiplies fiber capacity by carrying multiple wavelengths simultaneously. Learn CWDM and DWDM grids, when to use each, and the amplification technologies that enable long-haul DWDM.",
summary_de:
"WDM (Wavelength Division Multiplexing) multipliziert die Faserkapazitaet, indem mehrere Wellenlaengen gleichzeitig uebertragen werden. Lernen Sie CWDM- und DWDM-Raster, wann welches zu verwenden ist und die Verstaerkertechnologien fuer DWDM-Langstrecken.",
tags: ["cwdm", "dwdm", "wavelength", "wdm", "edfa", "mux"],
sections: [
{
heading: "WDM Concept",
heading_de: "WDM-Konzept",
blocks: [
{
type: "p",
text: "WDM carries multiple data streams on different wavelengths (colors of light) simultaneously on the same fiber. A passive MUX (multiplexer) combines the wavelengths at the transmit end; a DEMUX separates them at the receive end. This multiplies fiber capacity without laying new fiber — extremely cost-effective for existing cable routes.",
text_de:
"WDM uebertraegt mehrere Datenstroeme auf verschiedenen Wellenlaengen (Lichtfarben) gleichzeitig auf derselben Faser. Ein passiver MUX (Multiplexer) kombiniert die Wellenlaengen am Sendeende; ein DEMUX trennt sie am Empfangsende. Das multipliziert die Faserkapazitaet ohne neue Faser zu verlegen — extrem kosteneffizient fuer bestehende Kabelrouten.",
},
],
},
{
heading: "CWDM: Coarse Wavelength Division Multiplexing",
heading_de: "CWDM: Grob-Wellenlaengenmultiplex",
blocks: [
{
type: "p",
text: "CWDM uses 20nm channel spacing, allowing 18 channels from 1270nm to 1610nm. CWDM does not require temperature stabilization (the wider channel spacing tolerates wavelength drift), making CWDM transceivers significantly cheaper than DWDM modules. CWDM signals cannot be amplified with standard EDFA (which only covers the C-band), limiting reach to approximately 80km.",
text_de:
"CWDM verwendet 20-nm-Kanalabstand und erlaubt 18 Kanaele von 1270 nm bis 1610 nm. CWDM erfordert keine Temperaturstabilisierung (der groessere Kanalabstand toleriert Wellenlaengendrift), was CWDM-Transceiver erheblich guenstiger als DWDM-Module macht. CWDM-Signale koennen nicht mit Standard-EDFA verstaerkt werden (der nur das C-Band abdeckt), was die Reichweite auf ca. 80 km begrenzt.",
},
{
type: "table",
headers: ["CWDM Channel", "Wavelength", "ITU Band", "Typical Use"],
headers_de: ["CWDM-Kanal", "Wellenlaenge", "ITU-Band", "Typische Nutzung"],
rows: [
["CWDM-1", "1270 nm", "O-band", "Short reach in transceiver WDM (LR4)"],
["CWDM-2", "1290 nm", "O-band", "Short reach WDM"],
["CWDM-3", "1310 nm", "O-band", "Standard 1G/10G wavelength"],
["CWDM-4", "1330 nm", "O-band", "Metro"],
["CWDM-5", "1350 nm", "O-band", "Metro"],
["CWDM-6", "1370 nm", "E-band (water peak)", "Often avoided — high loss"],
["CWDM-7", "1390 nm", "E-band", "Avoid on non-ZWP fiber"],
["CWDM-8", "1410 nm", "E-band", "Avoid on non-ZWP fiber"],
["CWDM-9", "1430 nm", "S-band", "Metro CWDM systems"],
["CWDM-10", "1450 nm", "S-band", "Metro CWDM systems"],
["CWDM-11", "1470 nm", "S-band", "Metro CWDM"],
["CWDM-12", "1490 nm", "S-band", "PON downstream (GPON)"],
["CWDM-13", "1510 nm", "C-band edge", "Metro"],
["CWDM-14", "1530 nm", "C-band", "Near DWDM C-band"],
["CWDM-15", "1550 nm", "C-band", "Standard ZR/ER wavelength"],
["CWDM-16", "1570 nm", "C-band/L-band", "Metro"],
["CWDM-17", "1590 nm", "L-band", "Metro"],
["CWDM-18", "1610 nm", "L-band", "Metro, monitoring"],
],
},
],
},
{
heading: "DWDM: Dense Wavelength Division Multiplexing",
heading_de: "DWDM: Dicht-Wellenlaengenmultiplex",
blocks: [
{
type: "p",
text: "DWDM uses much tighter channel spacing — 100GHz (0.8nm), 50GHz (0.4nm), or flexgrid down to 12.5GHz — enabling 40, 80, or 160+ channels in the C-band (15301565nm) or L-band (15651625nm). DWDM channels can be amplified by EDFA (Erbium-Doped Fiber Amplifiers), enabling transmission of thousands of kilometers.",
text_de:
"DWDM verwendet viel engere Kanalabstaende — 100GHz (0,8 nm), 50GHz (0,4 nm) oder Flexgrid bis 12,5 GHz — und ermoeglicht 40, 80 oder 160+ Kanaele im C-Band (15301565 nm) oder L-Band (15651625 nm). DWDM-Kanaele koennen durch EDFA (Erbium-dotierte Faserverstaerker) verstaerkt werden und ermoeglichen Uebertragungen ueber Tausende von Kilometern.",
},
{
type: "callout",
variant: "key",
text: "CWDM vs DWDM decision rule: Link under 80km without amplification → CWDM (3× cheaper modules). Link over 80km or requiring amplification → DWDM. Metro networks with 400G coherent ZR → DWDM required.",
text_de:
"CWDM vs DWDM Entscheidungsregel: Verbindung unter 80 km ohne Verstaerkung → CWDM (3× guenstigere Module). Verbindung ueber 80 km oder Verstaerkung erforderlich → DWDM. Metro-Netzwerke mit 400G Coherent ZR → DWDM erforderlich.",
},
],
},
],
quiz: [
{
id: "infra-wdm-q1",
lesson: "infra-wavelengths",
q: "What channel spacing does CWDM use?",
q_de: "Welchen Kanalabstand verwendet CWDM?",
options: ["0.8 nm (100 GHz)", "20 nm", "50 GHz", "1 nm"],
options_de: ["0,8 nm (100 GHz)", "20 nm", "50 GHz", "1 nm"],
answer: 1,
explanation:
"CWDM uses 20nm channel spacing, enabling 18 channels from 1270 to 1610nm. This wider spacing allows uncooled lasers and cheaper modules.",
explanation_de:
"CWDM verwendet 20-nm-Kanalabstand und ermoeglicht 18 Kanaele von 1270 bis 1610 nm. Dieser groessere Abstand erlaubt ungekuehlte Laser und guenstigere Module.",
},
{
id: "infra-wdm-q2",
lesson: "infra-wavelengths",
q: "Why can DWDM links be much longer than CWDM links?",
q_de: "Warum koennen DWDM-Verbindungen viel laenger als CWDM-Verbindungen sein?",
options: [
"DWDM uses lower wavelengths which have less fiber attenuation",
"DWDM channels can be amplified by EDFA in the C-band; CWDM cannot",
"DWDM modules have higher TX power",
"DWDM uses single-mode fiber; CWDM uses multimode",
],
options_de: [
"DWDM verwendet niedrigere Wellenlaengen mit geringerer Faserdeampfung",
"DWDM-Kanaele koennen im C-Band durch EDFA verstaerkt werden; CWDM nicht",
"DWDM-Module haben hoehere TX-Leistung",
"DWDM verwendet Einzelmodefaser; CWDM Mehrmodefaser",
],
answer: 1,
explanation:
"DWDM channels in the C-band (1530-1565nm) can be amplified by EDFA (Erbium-Doped Fiber Amplifiers), enabling transmission over thousands of km. CWDM channels (many outside C-band) cannot use standard EDFA amplification.",
explanation_de:
"DWDM-Kanaele im C-Band (15301565 nm) koennen durch EDFA (Erbium-dotierte Faserverstaerker) verstaerkt werden und ermoeglichen Uebertragungen ueber Tausende von km. CWDM-Kanaele (viele ausserhalb des C-Bandes) koennen keine Standard-EDFA-Verstaerkung nutzen.",
},
{
id: "infra-wdm-q3",
lesson: "infra-wavelengths",
q: "For a 50km metro link requiring just 4 channels, which WDM approach is more cost-effective?",
q_de: "Fuer eine 50-km-Metro-Verbindung mit nur 4 Kanaelen — welcher WDM-Ansatz ist kostenguenstiger?",
options: ["DWDM with 100GHz spacing", "CWDM (20nm spacing, uncooled lasers)", "Coherent ZR", "PAM4 direct detect"],
options_de: [
"DWDM mit 100-GHz-Abstand",
"CWDM (20-nm-Abstand, ungekuehlte Laser)",
"Coherent ZR",
"PAM4 Direct Detect",
],
answer: 1,
explanation:
"CWDM is the right choice for 50km (within the 80km unamplified limit) with few channels. CWDM modules are 3× cheaper than DWDM due to uncooled lasers and wider filter tolerances.",
explanation_de:
"CWDM ist die richtige Wahl fuer 50 km (innerhalb des 80-km-unverstaerkten Limits) mit wenigen Kanaelen. CWDM-Module sind 3× guenstiger als DWDM durch ungekuehlte Laser und groessere Filtertoleranzen.",
},
],
},
];

File diff suppressed because it is too large Load Diff

View File

@ -1,672 +0,0 @@
import type { TrainingLesson } from "./types";
export const switchesLessons: TrainingLesson[] = [
{
id: "sw-compatibility",
category: "switches",
title: "How Switch-Transceiver Compatibility Works",
title_de: "Wie Switch-Transceiver-Kompatibilitaet funktioniert",
level: "beginner",
duration_min: 12,
summary:
"Understand why some switches block third-party transceivers, how vendor locking works via EEPROM, and what options you have to use compatible modules on major platforms.",
summary_de:
"Verstehen Sie, warum manche Switches Drittanbieter-Transceiver blockieren, wie Vendor-Locking ueber EEPROM funktioniert und welche Moeglichkeiten Sie haben, kompatible Module auf grossen Plattformen zu nutzen.",
tags: ["compatibility", "eeprom", "vendor-lock", "cisco", "juniper", "arista"],
sections: [
{
heading: "The EEPROM Vendor ID — How Switches Recognize Modules",
heading_de: "Die EEPROM-Herstellerkennung — Wie Switches Module erkennen",
blocks: [
{
type: "p",
text: "Every optical transceiver contains an EEPROM (non-volatile memory) that stores identification data. The first bytes of the SFP EEPROM (page A0h, bytes 01) contain the 'Identifier' and 'Extended Identifier'. Bytes 2035 store the vendor name in ASCII. Bytes 6883 store the vendor OUI (Organizationally Unique Identifier — the manufacturer's registered MAC prefix).",
text_de:
"Jeder optische Transceiver enthaelt ein EEPROM (nicht-fluechtige Speicher), das Identifikationsdaten speichert. Die ersten Bytes des SFP-EEPROMs (Seite A0h, Bytes 01) enthalten den 'Identifier' und 'Extended Identifier'. Bytes 2035 speichern den Herstellernamen in ASCII. Bytes 6883 speichern den Hersteller-OUI (Organizationally Unique Identifier — das registrierte MAC-Praefix des Herstellers).",
},
{
type: "p",
text: "When a switch boots or a module is inserted, the switch NOS reads these EEPROM fields and compares the vendor name and OUI against an internal whitelist. If the module is not on the whitelist, the switch may block the port, display a warning, or log an error — depending on the NOS and platform configuration.",
text_de:
"Wenn ein Switch bootet oder ein Modul eingesteckt wird, liest das Switch-NOS diese EEPROM-Felder und vergleicht Herstellername und OUI mit einer internen Whitelist. Wenn das Modul nicht auf der Whitelist steht, kann der Switch den Port blockieren, eine Warnung anzeigen oder einen Fehler protokollieren — je nach NOS und Plattformkonfiguration.",
},
{
type: "callout",
variant: "info",
text: "Reputable compatible module vendors (like those sold by FLEXOPTIX) program the EEPROM with the correct OUI and vendor strings for the target platform. This is legal, fully MSA-compliant, and is how 'compatible' modules work — not by hacking, but by following the MSA specification.",
text_de:
"Serioeser Kompatibel-Modulanbieter (wie von FLEXOPTIX verkaufte Module) programmieren das EEPROM mit korrektem OUI und Herstellerstrings fuer die Zielplattform. Das ist legal, vollstaendig MSA-konform und funktioniert so — nicht durch Hacking, sondern durch Einhaltung der MSA-Spezifikation.",
},
],
},
{
heading: "Vendor Lock Comparison by Platform",
heading_de: "Vendor-Lock-Vergleich nach Plattform",
blocks: [
{
type: "table",
headers: ["Vendor/NOS", "Lock Level", "Workaround", "3rd-Party DOM?", "Notes"],
headers_de: ["Hersteller/NOS", "Lock-Level", "Workaround", "3rd-Party DOM?", "Hinweise"],
rows: [
["Cisco IOS/IOS-XE", "Soft lock", "'service unsupported-transceiver'", "Yes (most platforms)", "Warning logged; module works after command"],
["Cisco NX-OS", "Soft lock", "'service unsupported-transceiver'", "Varies by platform", "Some Nexus platforms show limited DOM for 3rd party"],
["Cisco IOS-XR", "Hard lock (some PICs)", "Requires Cisco-coded EEPROM", "Only with Cisco coding", "ASR 9000, NCS-5500 strict on line cards"],
["Juniper Junos (EX/QFX)", "Minimal lock", "None needed", "Yes", "EX/QFX very open; PTX/MX some restrictions"],
["Arista EOS", "No lock", "None needed", "Yes", "Arista is the most open major vendor"],
["Nokia SR OS", "Hard lock", "Nokia-coded modules required", "No for 3rd party", "Nokia requires Nokia part number coding"],
["Huawei VRP", "Hard lock", "Huawei-coded modules required", "Partial", "Very strict on most platforms"],
["Dell OS10", "Soft lock", "Configuration override available", "Yes", "Relatively open"],
["HPE Comware", "Soft lock", "Transceiver override command", "Yes", "Similar to Cisco approach"],
],
},
],
},
{
heading: "What MSA Compliance Means",
heading_de: "Was MSA-Konformitaet bedeutet",
blocks: [
{
type: "p",
text: "MSA (Multi-Source Agreement) compliance means a module meets the physical, electrical, optical, and management interface specifications defined by the relevant MSA document. This does NOT guarantee that the vendor's NOS will accept the module — vendor lock is a software-level decision, not an electrical incompatibility.",
text_de:
"MSA-Konformitaet (Multi-Source Agreement) bedeutet, dass ein Modul die physikalischen, elektrischen, optischen und Management-Schnittstellen-Spezifikationen des relevanten MSA-Dokuments erfuellt. Das garantiert NICHT, dass das NOS des Herstellers das Modul akzeptiert — Vendor Lock ist eine Software-Entscheidung, keine elektrische Inkompatibilitaet.",
},
{
type: "callout",
variant: "key",
text: "Compatible does NOT mean incompatible. MSA-compliant third-party modules with correct EEPROM coding work on 95%+ of platforms. They are electrically and optically identical to OEM. The only risk is a software policy on some NOSes — not hardware damage or performance degradation.",
text_de:
"Kompatibel bedeutet NICHT inkompatibel. MSA-konforme Drittanbieter-Module mit korrekter EEPROM-Kodierung funktionieren auf 95%+ aller Plattformen. Sie sind elektrisch und optisch identisch mit OEM. Das einzige Risiko ist eine Software-Richtlinie auf manchen NOSes — keine Hardwareeinschraenkungen oder Leistungsdegradierung.",
},
],
},
],
quiz: [
{
id: "sw-compat-q1",
lesson: "sw-compatibility",
q: "How does a switch identify whether a transceiver is from the OEM vendor?",
q_de: "Wie identifiziert ein Switch, ob ein Transceiver vom OEM-Hersteller stammt?",
options: [
"By measuring the optical power output",
"By reading the vendor name and OUI from the EEPROM",
"By the color of the module's bail latch",
"By checking the module's serial number against a cloud database",
],
options_de: [
"Durch Messung der optischen Ausgangsleistung",
"Durch Lesen von Herstellername und OUI aus dem EEPROM",
"Durch die Farbe des Verriegelungshebels",
"Durch Pruefen der Seriennummer in einer Cloud-Datenbank",
],
answer: 1,
explanation:
"The switch NOS reads the EEPROM vendor name field (bytes 20-35) and vendor OUI (bytes 68-83) and compares against its internal whitelist to determine if a module is 'approved'.",
explanation_de:
"Das Switch-NOS liest das EEPROM-Herstellernamenfeld (Bytes 2035) und Hersteller-OUI (Bytes 6883) und vergleicht mit der internen Whitelist, um zu bestimmen, ob ein Modul 'genehmigt' ist.",
},
{
id: "sw-compat-q2",
lesson: "sw-compatibility",
q: "Which major switch vendor has NO vendor lock on transceivers?",
q_de: "Welcher grosse Switch-Hersteller hat KEINEN Vendor-Lock fuer Transceiver?",
options: ["Cisco", "Nokia", "Arista", "Huawei"],
options_de: ["Cisco", "Nokia", "Arista", "Huawei"],
answer: 2,
explanation:
"Arista EOS does not implement vendor locking. Any MSA-compliant module with correct EEPROM will work without any configuration changes. Arista also shows DOM for third-party modules.",
explanation_de:
"Arista EOS implementiert keinen Vendor-Lock. Jedes MSA-konforme Modul mit korrektem EEPROM funktioniert ohne Konfigurationsa nderungen. Arista zeigt auch DOM fuer Drittanbieter-Module.",
},
{
id: "sw-compat-q3",
lesson: "sw-compatibility",
q: "On Cisco IOS, what command removes the block on third-party transceivers?",
q_de: "Welcher Befehl entfernt auf Cisco IOS die Blockierung von Drittanbieter-Transceivern?",
options: [
"no transceiver vendor-lock",
"service unsupported-transceiver",
"transceiver allow-all",
"interface override optics",
],
options_de: [
"no transceiver vendor-lock",
"service unsupported-transceiver",
"transceiver allow-all",
"interface override optics",
],
answer: 1,
explanation:
"'service unsupported-transceiver' (global config on IOS/IOS-XE) suppresses the error message and allows the port to operate. It does not affect performance or DOM availability.",
explanation_de:
"'service unsupported-transceiver' (globale Konfiguration auf IOS/IOS-XE) unterdrueckt die Fehlermeldung und erlaubt den Portbetrieb. Es beeinflusst weder Leistung noch DOM-Verfuegbarkeit.",
},
{
id: "sw-compat-q4",
lesson: "sw-compatibility",
q: "MSA compliance guarantees what?",
q_de: "Was garantiert MSA-Konformitaet?",
options: [
"The module will be accepted by any NOS without workaround",
"Physical, electrical, and optical compliance with the MSA standard — not NOS whitelist acceptance",
"Full warranty support from the switch vendor",
"Automatic firmware updates from the switch",
],
options_de: [
"Das Modul wird von jedem NOS ohne Workaround akzeptiert",
"Physikalische, elektrische und optische Konformitaet mit dem MSA-Standard — nicht Whitelist-Akzeptanz des NOS",
"Vollstaendige Garantieunterstuetzung durch den Switch-Hersteller",
"Automatische Firmware-Updates durch den Switch",
],
answer: 1,
explanation:
"MSA compliance verifies the module meets technical specifications. Whether the NOS accepts it is a separate (software policy) question. Reputable vendors also program the EEPROM correctly for the target platform.",
explanation_de:
"MSA-Konformitaet prueft, ob das Modul technische Spezifikationen erfuellt. Ob das NOS es akzeptiert, ist eine separate (Software-Richtlinien-)Frage. Serioeser Anbieter programmieren das EEPROM auch korrekt fuer die Zielplattform.",
},
{
id: "sw-compat-q5",
lesson: "sw-compatibility",
q: "Which platform has the strictest vendor lock — typically requiring vendor-coded modules?",
q_de: "Welche Plattform hat den strengsten Vendor-Lock — erfordert typischerweise herstellerkodierte Module?",
options: ["Arista EOS 7050", "Cisco Catalyst 9300", "Nokia SR OS / Huawei VRP", "Juniper EX4300"],
options_de: ["Arista EOS 7050", "Cisco Catalyst 9300", "Nokia SR OS / Huawei VRP", "Juniper EX4300"],
answer: 2,
explanation:
"Nokia SR OS and Huawei VRP enforce hard locks — modules must be programmed with Nokia/Huawei-specific vendor codes and part numbers to operate. This is unlike Cisco (soft lock with workaround) or Arista (no lock).",
explanation_de:
"Nokia SR OS und Huawei VRP erzwingen harte Locks — Module muessen mit Nokia/Huawei-spezifischen Herstellercodes und Teilenummern programmiert sein. Das unterscheidet sich von Cisco (weicher Lock mit Workaround) oder Arista (kein Lock).",
},
],
},
{
id: "sw-cisco",
category: "switches",
title: "Cisco IOS, NX-OS, and IOS-XR Transceiver Guide",
title_de: "Cisco IOS, NX-OS und IOS-XR Transceiver-Leitfaden",
level: "intermediate",
duration_min: 12,
summary:
"Navigate Cisco's three main NOS platforms — IOS/IOS-XE, NX-OS, and IOS-XR — and understand the specific transceiver acceptance policies, DOM commands, and workarounds for each.",
summary_de:
"Navigieren Sie durch Ciscos drei Haupt-NOS-Plattformen — IOS/IOS-XE, NX-OS und IOS-XR — und verstehen Sie die spezifischen Transceiver-Akzeptanzrichtlinien, DOM-Befehle und Workarounds fuer jede.",
tags: ["cisco", "ios", "nxos", "ios-xr", "nexus", "catalyst"],
sections: [
{
heading: "Cisco Platform Overview",
heading_de: "Cisco Plattform-Ueberblick",
blocks: [
{
type: "table",
headers: ["Platform Family", "NOS", "Typical Use Case", "Lock Level"],
headers_de: ["Plattform-Familie", "NOS", "Typischer Anwendungsfall", "Lock-Level"],
rows: [
["Catalyst 9000 / 3800", "IOS-XE", "Enterprise access/distribution", "Soft — 'service unsupported-transceiver'"],
["Catalyst 6500 / 6880", "IOS / IOS-XE", "Legacy campus core", "Soft — 'service unsupported-transceiver'"],
["Nexus 9000 / 7000 / 5000", "NX-OS", "Data center leaf/spine", "Soft — 'service unsupported-transceiver'"],
["ASR 9000", "IOS-XR", "Service provider edge/core", "Hard on some PICs"],
["NCS 5500 / NCS 540", "IOS-XR", "SP core / peering", "Hard — requires Cisco-coded EEPROM on many PICs"],
["CRS-X / ASR 1000", "IOS-XR / IOS-XE", "Carrier core / WAN aggregation", "Varies by linecard"],
],
},
{
type: "callout",
variant: "info",
text: "IOS and IOS-XE use the same transceiver workaround command. NX-OS uses the same command name. IOS-XR is separate and stricter — always check the specific PIC compatibility before ordering for XR platforms.",
text_de:
"IOS und IOS-XE verwenden denselben Transceiver-Workaround-Befehl. NX-OS verwendet denselben Befehlsnamen. IOS-XR ist separat und strenger — immer die spezifische PIC-Kompatibilitaet pruefen, bevor fuer XR-Plattformen bestellt wird.",
},
],
},
{
heading: "IOS and IOS-XE: The Workaround",
heading_de: "IOS und IOS-XE: Der Workaround",
blocks: [
{
type: "p",
text: "On Catalyst switches running IOS or IOS-XE, inserting an unsupported transceiver generates an error log message and may prevent the port from coming up. The fix is a single global configuration command.",
text_de:
"Auf Catalyst-Switches mit IOS oder IOS-XE erzeugt das Einstecken eines nicht unterstuetzten Transceivers eine Fehlerprotokollmeldung und kann verhindern, dass der Port hochkommt. Die Loesung ist ein einzelner globaler Konfigurationsbefehl.",
},
{
type: "code",
text: "! Cisco IOS / IOS-XE — allow unsupported transceivers:\nSwitch(config)# service unsupported-transceiver\n\n! Verify transceiver DOM:\nSwitch# show interfaces GigabitEthernet1/0/1 transceiver\n\n! More detail on all transceivers in chassis:\nSwitch# show inventory\nSwitch# show interfaces transceiver",
},
{
type: "p",
text: "The 'service unsupported-transceiver' command is persistent across reboots. It generates a one-time syslog: '%PHY-4-SFP_NOT_SUPPORTED'. After applying the command, the port links normally and DOM is visible for modules with properly coded EEPROM.",
text_de:
"Der Befehl 'service unsupported-transceiver' ist rebootpersistent. Er erzeugt ein einmaliges Syslog: '%PHY-4-SFP_NOT_SUPPORTED'. Nach Anwenden des Befehls verbindet der Port normal und DOM ist fuer Module mit korrekt kodiertem EEPROM sichtbar.",
},
],
},
{
heading: "NX-OS: Data Center Switches",
heading_de: "NX-OS: Data-Center-Switches",
blocks: [
{
type: "code",
text: "! Cisco NX-OS (Nexus switches) — allow unsupported transceivers:\nnexus(config)# service unsupported-transceiver\n\n! Check transceiver info:\nnexus# show interface ethernet 1/1 transceiver\n\n! Check all transceivers:\nnexus# show transceiver\n\n! For QSFP modules on Nexus 9000:\nnexus# show interface ethernet 1/1 transceiver detail",
},
{
type: "callout",
variant: "warning",
text: "Cisco TAC (Technical Assistance Center) may ask you to swap a third-party module for an OEM one if you call with a problem. This is a SUPPORT POLICY, not a technical requirement. The module itself works fine — but TAC will decline to troubleshoot until you use an approved module.",
text_de:
"Cisco TAC (Technical Assistance Center) kann Sie bitten, ein Drittanbieter-Modul gegen ein OEM-Modul auszutauschen, wenn Sie mit einem Problem anrufen. Das ist eine SUPPORT-RICHTLINIE, keine technische Anforderung. Das Modul selbst funktioniert einwandfrei — aber TAC wird die Fehlersuche ablehnen, bis Sie ein genehmigtes Modul verwenden.",
},
],
},
{
heading: "DOM Commands on Cisco",
heading_de: "DOM-Befehle auf Cisco",
blocks: [
{
type: "code",
text: "! IOS-XE full DOM output example:\nSwitch# show interfaces GigabitEthernet1/0/1 transceiver detail\n\nTransceiver is present\nType : SFP-10G-LR\nName : FLEXOPTIX\nPN : SFP-10G-LR\n...\nOptical Tx Power : -2.8 dBm\nOptical Rx Power : -3.1 dBm\nTemperature : 33.5 C\nVoltage : 3.27 V\nCurrent : 42.7 mA\n\n! NX-OS example:\nnexus# show interface ethernet 1/1 transceiver\n...\nrx_pwr_dbm -3.5\ntx_pwr_dbm -2.8\ntemperature 34.0",
},
{
type: "p",
text: "DOM values are available when the module has a properly coded EEPROM page A2h (SFP) or the corresponding diagnostic page (QSFP/QSFP-DD). FLEXOPTIX-coded modules include full DOM support for Cisco platforms.",
text_de:
"DOM-Werte sind verfuegbar, wenn das Modul eine korrekt kodierte EEPROM-Seite A2h (SFP) oder die entsprechende Diagnoseseite (QSFP/QSFP-DD) hat. FLEXOPTIX-kodierte Module enthalten vollstaendige DOM-Unterstuetzung fuer Cisco-Plattformen.",
},
],
},
],
quiz: [
{
id: "sw-cisco-q1",
lesson: "sw-cisco",
q: "On Cisco IOS, which command enables third-party transceivers?",
q_de: "Welcher Befehl aktiviert auf Cisco IOS Drittanbieter-Transceiver?",
options: [
"transceiver override all",
"service unsupported-transceiver",
"no transceiver vendor-check",
"permit transceiver third-party",
],
options_de: [
"transceiver override all",
"service unsupported-transceiver",
"no transceiver vendor-check",
"permit transceiver third-party",
],
answer: 1,
explanation:
"'service unsupported-transceiver' (global config mode) enables third-party transceivers on Cisco IOS, IOS-XE, and NX-OS platforms.",
explanation_de:
"'service unsupported-transceiver' (globaler Konfigurationsmodus) aktiviert Drittanbieter-Transceiver auf Cisco IOS, IOS-XE und NX-OS-Plattformen.",
},
{
id: "sw-cisco-q2",
lesson: "sw-cisco",
q: "Which Cisco NOS typically has the strictest transceiver lock?",
q_de: "Welches Cisco-NOS hat typischerweise den strengsten Transceiver-Lock?",
options: ["IOS-XE on Catalyst 9000", "NX-OS on Nexus 9000", "IOS-XR on NCS-5500 and ASR 9000", "IOS on Catalyst 3750"],
options_de: [
"IOS-XE auf Catalyst 9000",
"NX-OS auf Nexus 9000",
"IOS-XR auf NCS-5500 und ASR 9000",
"IOS auf Catalyst 3750",
],
answer: 2,
explanation:
"IOS-XR on service provider platforms (NCS-5500, ASR 9000) often enforces hard locks on specific line cards (PICs) where only Cisco-coded modules are accepted, regardless of software configuration.",
explanation_de:
"IOS-XR auf Service-Provider-Plattformen (NCS-5500, ASR 9000) erzwingt oft harte Locks auf bestimmten Line Cards (PICs), wo nur Cisco-kodierte Module akzeptiert werden, unabhaengig von der Software-Konfiguration.",
},
{
id: "sw-cisco-q3",
lesson: "sw-cisco",
q: "If Cisco TAC asks you to swap a third-party module, this means:",
q_de: "Wenn Cisco TAC Sie bittet, ein Drittanbieter-Modul auszutauschen, bedeutet das:",
options: [
"The module is technically defective",
"This is a support policy — not an indication the module is faulty",
"The switch hardware is incompatible",
"You must buy a Cisco module or void your switch warranty",
],
options_de: [
"Das Modul ist technisch defekt",
"Das ist eine Support-Richtlinie — kein Hinweis darauf, dass das Modul fehlerhaft ist",
"Die Switch-Hardware ist inkompatibel",
"Sie muessen ein Cisco-Modul kaufen oder Ihre Switch-Garantie verlieren",
],
answer: 1,
explanation:
"Cisco TAC's policy is to only support Cisco-approved modules. If you call with a problem and have a third-party module, TAC will ask you to swap it. This is a support policy — not evidence the module is faulty.",
explanation_de:
"Die Cisco-TAC-Richtlinie ist es, nur Cisco-genehmigte Module zu unterstuetzen. Wenn Sie mit einem Problem anrufen und ein Drittanbieter-Modul haben, wird TAC Sie bitten, es auszutauschen. Das ist eine Support-Richtlinie — kein Beweis, dass das Modul fehlerhaft ist.",
},
{
id: "sw-cisco-q4",
lesson: "sw-cisco",
q: "What command shows DOM data on Cisco NX-OS?",
q_de: "Welcher Befehl zeigt DOM-Daten auf Cisco NX-OS?",
options: [
"show interface GigabitEthernet1/1 optics",
"show interface ethernet 1/1 transceiver",
"show sfp diagnostics",
"show transceiver dom",
],
options_de: [
"show interface GigabitEthernet1/1 optics",
"show interface ethernet 1/1 transceiver",
"show sfp diagnostics",
"show transceiver dom",
],
answer: 1,
explanation:
"On NX-OS: 'show interface ethernet 1/1 transceiver' displays DOM values including Tx power, Rx power, temperature, voltage, and bias current.",
explanation_de:
"Auf NX-OS: 'show interface ethernet 1/1 transceiver' zeigt DOM-Werte einschliesslich Tx-Leistung, Rx-Leistung, Temperatur, Spannung und Vorspannungsstrom.",
},
],
},
{
id: "sw-juniper-arista",
category: "switches",
title: "Juniper Junos and Arista EOS Compatibility",
title_de: "Juniper Junos und Arista EOS Kompatibilitaet",
level: "intermediate",
duration_min: 12,
summary:
"Juniper and Arista are the two most open major switch vendors for third-party transceivers. Learn their platform specifics, DOM commands, and any restrictions that do exist.",
summary_de:
"Juniper und Arista sind die zwei offensten grossen Switch-Hersteller fuer Drittanbieter-Transceiver. Lernen Sie deren Plattform-Details, DOM-Befehle und vorhandene Einschraenkungen.",
tags: ["juniper", "arista", "eos", "junos", "qfx", "ex", "7050"],
sections: [
{
heading: "Juniper Junos: Platform Overview",
heading_de: "Juniper Junos: Plattform-Ueberblick",
blocks: [
{
type: "table",
headers: ["Platform", "Use Case", "3rd-Party Friendly?", "Notes"],
headers_de: ["Plattform", "Anwendungsfall", "Drittanbieter-freundlich?", "Hinweise"],
rows: [
["EX2300 / EX3400", "SMB access switches", "Yes", "Very open, no restrictions"],
["EX4300 / EX4650", "Enterprise access/agg", "Yes", "Full DOM visible"],
["QFX5100 / QFX5120", "Data center leaf", "Yes", "Some 100G module restrictions"],
["QFX10002 / QFX10008", "Data center spine", "Mostly yes", "400G QSFP-DD some restrictions"],
["MX204 / MX480", "SP edge", "Yes (most MICs)", "Requires module type match"],
["MX10003 / MX10008", "SP core", "Varies by MIC/PIC", "Check MIC compatibility list"],
["PTX1000 / PTX10001", "SP core / peering", "Yes", "Generally open"],
],
},
{
type: "callout",
variant: "tip",
text: "Juniper generally does not enforce vendor lock. However, some platforms require a minimum JunOS release for specific module types. Always check the Juniper Hardware Guide for your specific switch + module combination.",
text_de:
"Juniper erzwingt generell keinen Vendor-Lock. Einige Plattformen erfordern jedoch eine Mindest-JunOS-Version fuer bestimmte Modultypen. Immer den Juniper Hardware-Leitfaden fuer Ihre spezifische Switch+Modul-Kombination pruefen.",
},
],
},
{
heading: "Juniper DOM Commands",
heading_de: "Juniper DOM-Befehle",
blocks: [
{
type: "code",
text: "# Juniper Junos — optical diagnostics:\nuser@switch> show interfaces xe-0/0/0 media detail\nuser@switch> show interfaces et-0/0/0 diagnostics optics\n\n# Example output:\nPhysical interface: xe-0/0/0\n Optical diagnostics:\n Laser output power : 0.398 mW / -4.00 dBm\n Laser rx power : 0.230 mW / -6.38 dBm\n Module temperature : 40 degrees C / 104 degrees F\n Module voltage : 3.25 V\n Laser bias current : 45.300 mA\n\n# Check module identity:\nuser@switch> show chassis hardware",
},
],
},
{
heading: "Arista EOS: The Open Platform",
heading_de: "Arista EOS: Die offene Plattform",
blocks: [
{
type: "p",
text: "Arista is well known in the industry for having essentially no transceiver vendor lock. Any MSA-compliant module with correct EEPROM will operate in an Arista switch without any configuration changes. Arista also shows full DOM for any module that supports it.",
text_de:
"Arista ist in der Branche bekannt fuer quasi keinen Transceiver-Vendor-Lock. Jedes MSA-konforme Modul mit korrektem EEPROM funktioniert in einem Arista-Switch ohne Konfigurationsaenderungen. Arista zeigt auch vollstaendiges DOM fuer jedes Modul, das es unterstuetzt.",
},
{
type: "table",
headers: ["Arista Platform", "Use Case", "Key Form Factors", "Notes"],
headers_de: ["Arista Plattform", "Anwendungsfall", "Wichtige Formfaktoren", "Hinweise"],
rows: [
["7020", "ToR, 1G/10G access", "SFP, SFP+", "Very open"],
["7050CX3 / 7060CX2", "100G leaf switch", "QSFP28, QSFP-DD (compat)", "QSFP-DD slot accepts QSFP28"],
["7170", "100G leaf + programmable", "QSFP28", "Tofino ASIC, very open"],
["7280 / 7300", "100G/400G spine", "QSFP28, QSFP-DD", "400G ports on 7300"],
["7500 / 7800", "400G spine chassis", "QSFP-DD, OSFP", "Line card dependent — verify per card"],
],
},
{
type: "code",
text: "# Arista EOS transceiver commands:\nswitch# show interfaces Ethernet1 transceiver\nswitch# show transceiver Ethernet1\n\n# Detailed output:\nswitch# show interfaces Ethernet1 transceiver detail\n\n# Example output:\nEthernet1: Not present\n Tx Power : -2.9 dBm\n Rx Power : -3.4 dBm\n Temp : 34.1 Celsius\n Voltage : 3.27 V\n Bias : 42.3 mA",
},
],
},
],
quiz: [
{
id: "sw-jnpr-q1",
lesson: "sw-juniper-arista",
q: "Which Juniper command shows optical diagnostics including Tx/Rx power?",
q_de: "Welcher Juniper-Befehl zeigt optische Diagnosen einschliesslich Tx/Rx-Leistung?",
options: [
"show transceiver detail",
"show interfaces et-0/0/0 diagnostics optics",
"show sfp optical et-0/0/0",
"show optics dom et-0/0/0",
],
options_de: [
"show transceiver detail",
"show interfaces et-0/0/0 diagnostics optics",
"show sfp optical et-0/0/0",
"show optics dom et-0/0/0",
],
answer: 1,
explanation:
"'show interfaces et-0/0/0 diagnostics optics' is the Junos command for optical diagnostic (DOM) data including laser power, temperature, voltage, and bias current.",
explanation_de:
"'show interfaces et-0/0/0 diagnostics optics' ist der Junos-Befehl fuer optische Diagnosedaten (DOM) einschliesslich Laserleistung, Temperatur, Spannung und Vorspannungsstrom.",
},
{
id: "sw-jnpr-q2",
lesson: "sw-juniper-arista",
q: "What is Arista EOS's policy on third-party transceivers?",
q_de: "Was ist die Richtlinie von Arista EOS zu Drittanbieter-Transceivern?",
options: [
"Hard lock — requires Arista-coded modules",
"Soft lock — requires 'service unsupported-transceiver' command",
"No lock — any MSA-compliant module works without configuration",
"Soft lock — requires license key per port",
],
options_de: [
"Harter Lock — erfordert Arista-kodierte Module",
"Weicher Lock — erfordert Befehl 'service unsupported-transceiver'",
"Kein Lock — jedes MSA-konforme Modul funktioniert ohne Konfiguration",
"Weicher Lock — erfordert Lizenzschluessel pro Port",
],
answer: 2,
explanation:
"Arista EOS has no vendor lock. Any MSA-compliant transceiver with correct EEPROM works without any configuration changes. Full DOM support is also provided for third-party modules.",
explanation_de:
"Arista EOS hat keinen Vendor-Lock. Jeder MSA-konforme Transceiver mit korrektem EEPROM funktioniert ohne Konfigurationsaenderungen. Vollstaendige DOM-Unterstuetzung ist auch fuer Drittanbieter-Module vorhanden.",
},
{
id: "sw-jnpr-q3",
lesson: "sw-juniper-arista",
q: "On Arista, which command shows DOM data for Ethernet1?",
q_de: "Welcher Befehl zeigt auf Arista DOM-Daten fuer Ethernet1?",
options: [
"show interface Ethernet1 transceiver",
"show sfp Ethernet1 diagnostics",
"show optics Ethernet1",
"show dom Ethernet1",
],
options_de: [
"show interface Ethernet1 transceiver",
"show sfp Ethernet1 diagnostics",
"show optics Ethernet1",
"show dom Ethernet1",
],
answer: 0,
explanation:
"'show interfaces Ethernet1 transceiver' or 'show transceiver Ethernet1' both work on Arista EOS to display DOM data.",
explanation_de:
"'show interfaces Ethernet1 transceiver' oder 'show transceiver Ethernet1' funktionieren beide auf Arista EOS zur Anzeige von DOM-Daten.",
},
],
},
{
id: "sw-fec-config",
category: "switches",
title: "FEC Configuration for Optical Interfaces",
title_de: "FEC-Konfiguration fuer optische Schnittstellen",
level: "advanced",
duration_min: 12,
summary:
"Forward Error Correction (FEC) is mandatory at 25G and above. Mismatched FEC is the #1 cause of unexplained link-down events. Learn which FEC is required at each speed and how to configure it.",
summary_de:
"Forward Error Correction (FEC) ist ab 25G Pflicht. Fehlangepasstes FEC ist die haeufigste Ursache unerklaerter Link-Down-Ereignisse. Lernen Sie, welches FEC bei welcher Geschwindigkeit erforderlich ist und wie es konfiguriert wird.",
tags: ["fec", "rs-fec", "kp4", "25g", "100g", "400g", "troubleshooting"],
sections: [
{
heading: "What is FEC and Why Does It Matter?",
heading_de: "Was ist FEC und warum ist es wichtig?",
blocks: [
{
type: "p",
text: "FEC (Forward Error Correction) is a mathematical technique that adds redundant bits to transmitted data, allowing the receiver to detect and correct bit errors without retransmission. As data rates increased to 25G and beyond, signal-to-noise ratios decreased and raw bit error rates increased. FEC compensates for this, enabling reliable links over real-world fiber and copper.",
text_de:
"FEC (Forward Error Correction) ist eine mathematische Technik, die uebertragenen Daten redundante Bits hinzufuegt und es dem Empfaenger ermooglicht, Bit-Fehler ohne Wiederuebertragung zu erkennen und zu korrigieren. Mit steigenden Datenraten auf 25G und hoeher sanken Signal-Rausch-Abstaende und stieg die rohe Bitfehlerrate. FEC kompensiert dies fuer zuverlaessige Verbindungen.",
},
{
type: "table",
headers: ["FEC Type", "IEEE Clause", "Used At", "Overhead", "Error Correction Capability"],
headers_de: ["FEC-Typ", "IEEE-Klausel", "Verwendet bei", "Overhead", "Fehlerkorrekturkapazitaet"],
rows: [
["No FEC", "—", "1G, 10G (usually)", "0%", "None — bit errors cause frame loss"],
["FC-FEC (CL74)", "Clause 74", "25G, some 40G", "~2.4%", "Corrects burst errors up to 11 bits"],
["RS-FEC (CL91)", "Clause 91", "100G, 25G SR/LR", "~2.4%", "Corrects up to 15 symbol errors per codeword"],
["KP4 RS-FEC", "Clause 134", "400G, 800G PAM4", "~2.4%", "Higher correction for PAM4 noise environment"],
],
},
{
type: "callout",
variant: "key",
text: "FEC must match on BOTH ends of the link. If one side uses RS-FEC and the other uses FC-FEC, the link will come up but with massive bit errors, or it won't come up at all. Always set FEC explicitly — don't rely on auto-negotiation at 25G and above.",
text_de:
"FEC muss auf BEIDEN Seiten der Verbindung uebereinstimmen. Wenn eine Seite RS-FEC und die andere FC-FEC verwendet, kommt die Verbindung moeglicherweise mit massiven Bitfehlern hoch oder gar nicht. FEC immer explizit setzen — nicht auf Auto-Negotiation bei 25G und hoeher vertrauen.",
},
],
},
{
heading: "FEC Requirements by Speed and Standard",
heading_de: "FEC-Anforderungen nach Geschwindigkeit und Standard",
blocks: [
{
type: "table",
headers: ["Speed / Standard", "FEC Required", "FEC Type"],
headers_de: ["Geschwindigkeit / Standard", "FEC erforderlich", "FEC-Typ"],
rows: [
["10GBASE-SR/LR", "No", "None"],
["25GBASE-SR", "Yes (recommended)", "RS-FEC or FC-FEC"],
["25GBASE-LR", "Yes", "RS-FEC (CL91)"],
["25GBASE-ER", "Yes", "RS-FEC (CL91)"],
["40GBASE-SR4 / LR4", "Optional", "FC-FEC sometimes needed"],
["100GBASE-SR4", "Yes", "RS-FEC (CL91)"],
["100GBASE-LR4", "Yes", "RS-FEC (CL91)"],
["100GBASE-DR", "Yes", "RS-FEC (CL91)"],
["400G all PAM4", "Mandatory", "KP4 RS-FEC (CL134)"],
["800G all PAM4", "Mandatory", "KP4 RS-FEC"],
],
},
],
},
{
heading: "Configuring FEC: Cisco, Juniper, Arista",
heading_de: "FEC konfigurieren: Cisco, Juniper, Arista",
blocks: [
{
type: "code",
text: "# CISCO NX-OS (Nexus 9000):\ninterface ethernet 1/1\n speed 25000\n fec rs-fec ! RS-FEC for 25G\n no shutdown\n\n# For 100G:\ninterface ethernet 1/2\n speed 100000\n fec rs-fec\n\n# ARISTA EOS:\ninterface Ethernet1\n speed forced 25gfull\n fec rs\n\n# JUNIPER Junos (QFX/EX):\nset interfaces et-0/0/0 ether-options fec fec91\n# or for auto:\nset interfaces et-0/0/0 ether-options auto-negotiation\n\n# JUNIPER — check FEC stats:\nshow interfaces et-0/0/0 statistics detail",
},
{
type: "callout",
variant: "warning",
text: "If a 25G link goes down immediately after cabling, or shows very high input errors, FEC mismatch is the most likely cause. Check both ends — the server NIC and the switch port — and ensure they are configured for the same FEC mode.",
text_de:
"Wenn eine 25G-Verbindung sofort nach dem Verkabeln untergeht oder sehr hohe Eingabefehler zeigt, ist FEC-Fehlanpassung die wahrscheinlichste Ursache. Beide Enden pruefen — die Server-NIC und den Switch-Port — und sicherstellen, dass beide denselben FEC-Modus konfiguriert haben.",
},
],
},
],
quiz: [
{
id: "sw-fec-q1",
lesson: "sw-fec-config",
q: "What FEC is mandatory for all 400G PAM4 interfaces?",
q_de: "Welches FEC ist fuer alle 400G PAM4-Schnittstellen obligatorisch?",
options: ["FC-FEC (Clause 74)", "RS-FEC Clause 91", "KP4 RS-FEC (Clause 134)", "No FEC needed at 400G"],
options_de: [
"FC-FEC (Klausel 74)",
"RS-FEC Klausel 91",
"KP4 RS-FEC (Klausel 134)",
"Kein FEC bei 400G erforderlich",
],
answer: 2,
explanation:
"KP4 RS-FEC (Clause 134) is mandatory for all 400G PAM4 interfaces. PAM4's smaller signal margins require stronger FEC than NRZ links.",
explanation_de:
"KP4 RS-FEC (Klausel 134) ist fuer alle 400G PAM4-Schnittstellen obligatorisch. PAM4s kleinere Signalabstaende erfordern starkeres FEC als NRZ-Verbindungen.",
},
{
id: "sw-fec-q2",
lesson: "sw-fec-config",
q: "A 25G link comes up but shows very high input errors. What is the most likely cause?",
q_de: "Eine 25G-Verbindung kommt hoch, zeigt aber sehr hohe Eingabefehler. Was ist die wahrscheinlichste Ursache?",
options: [
"The cable is too long",
"FEC mismatch — one end uses RS-FEC, the other uses FC-FEC or none",
"The module is third-party and incompatible",
"The switch ASIC is overloaded",
],
options_de: [
"Das Kabel ist zu lang",
"FEC-Fehlanpassung — eine Seite verwendet RS-FEC, die andere FC-FEC oder keins",
"Das Modul ist ein Drittanbietermodul und inkompatibel",
"Der Switch-ASIC ist ueberlastet",
],
answer: 1,
explanation:
"High input errors at 25G with the link physically up is the classic symptom of FEC mismatch. Check FEC configuration at both ends — switch port and server NIC.",
explanation_de:
"Hohe Eingabefehler bei 25G mit physisch verbundener Verbindung ist das klassische Symptom einer FEC-Fehlanpassung. FEC-Konfiguration an beiden Enden pruefen — Switch-Port und Server-NIC.",
},
{
id: "sw-fec-q3",
lesson: "sw-fec-config",
q: "On Arista EOS, how do you set RS-FEC on a 25G interface?",
q_de: "Wie setzt man auf Arista EOS RS-FEC an einer 25G-Schnittstelle?",
options: [
"interface Ethernet1 → fec rs",
"interface Ethernet1 → fec forward-error-correction rs",
"set fec Ethernet1 rs-fec",
"fec enable Ethernet1 rs",
],
options_de: [
"interface Ethernet1 → fec rs",
"interface Ethernet1 → fec forward-error-correction rs",
"set fec Ethernet1 rs-fec",
"fec enable Ethernet1 rs",
],
answer: 0,
explanation:
"On Arista EOS: under 'interface Ethernet1', the command 'fec rs' enables RS-FEC (Reed-Solomon).",
explanation_de:
"Auf Arista EOS: unter 'interface Ethernet1' aktiviert der Befehl 'fec rs' RS-FEC (Reed-Solomon).",
},
],
},
];

View File

@ -1,713 +0,0 @@
import type { TrainingLesson } from "./types";
export const testingBuyingLessons: TrainingLesson[] = [
{
id: "test-equipment",
category: "testing-buying",
title: "Fiber Optic Test Equipment",
title_de: "Glasfaser-Testgeraete",
level: "beginner",
duration_min: 12,
summary:
"Every fiber installation requires testing. Learn the essential test tools — OTDR, optical power meter, VFL, and inspection microscope — what they measure and when to use each.",
summary_de:
"Jede Glasfaserinstallation erfordert Tests. Lernen Sie die wesentlichen Testgeraete — OTDR, optisches Leistungsmessgeraet, VFL und Inspektionsmikroskop — was sie messen und wann jedes eingesetzt wird.",
tags: ["otdr", "power-meter", "vfl", "testing", "inspection", "tools"],
sections: [
{
heading: "Essential Test Equipment Overview",
heading_de: "Ueberblick ueber wesentliche Testgeraete",
blocks: [
{
type: "table",
headers: ["Tool", "What It Measures", "When to Use", "Approx. Price"],
headers_de: ["Geraet", "Was es misst", "Wann einsetzen", "Ungefaehre Kosten"],
rows: [
["Optical Power Meter (OPM)", "Received optical power in dBm", "Verify received power, acceptance testing", "$150500"],
["Light Source", "Stable reference power output", "Paired with OPM for IL testing", "$100400"],
["OPM + Source Kit", "Insertion Loss (IL)", "Full link acceptance testing", "$300800"],
["OTDR", "Distance, loss per event, fiber trace", "Long fiber runs, splice/connector maps, breaks", "$2,00025,000"],
["VFL (Visual Fault Locator)", "Visible red light in fiber", "Find bends, breaks, continuity check, ID fibers", "$50200"],
["Fiber Inspection Microscope / Video Probe", "Connector endface contamination", "Before mating any connector", "$2001,500"],
["Optical Spectrum Analyzer (OSA)", "All DWDM channel powers and wavelengths", "WDM system commissioning and troubleshooting", "$15,000100,000"],
["BERT (Bit Error Rate Tester)", "End-to-end bit error rate", "Final acceptance, troubleshooting transient errors", "$5,00050,000"],
],
},
{
type: "callout",
variant: "key",
text: "For most enterprise and data center work, you need only three tools: (1) Fiber inspection video probe, (2) OPM + light source kit, and (3) VFL. These three tools solve 80%+ of all fiber problems and together cost under $1,500.",
text_de:
"Fuer die meisten Enterprise- und Rechenzentrumsarbeiten benoetigen Sie nur drei Geraete: (1) Glasfaser-Inspektions-Videosonde, (2) OPM + Lichtquellen-Kit und (3) VFL. Diese drei Geraete losen 80%+ aller Glasfaserprobleme und kosten zusammen unter 1.500 EUR.",
},
],
},
{
heading: "The OTDR (Optical Time-Domain Reflectometer)",
heading_de: "Das OTDR (Optisches Zeitbereichs-Reflektometer)",
blocks: [
{
type: "p",
text: "An OTDR sends short pulses of light into the fiber and measures the returning backscattered light over time. Since the speed of light in fiber is known (~5 ns/m), the OTDR can calculate the exact distance to any reflection or scattering event — splices, connectors, bends, and breaks — and measure the loss at each point. The result is an OTDR trace: a graph of distance vs. backscatter level.",
text_de:
"Ein OTDR sendet kurze Lichtimpulse in die Faser und misst das zeitabhaengige zurueckkehrende Streulicht. Da die Lichtgeschwindigkeit in der Faser bekannt ist (~5 ns/m), kann das OTDR den genauen Abstand zu jedem Reflexions- oder Streuereignis — Spleisse, Stecker, Biegungen und Brueche — berechnen und den Verlust an jedem Punkt messen.",
},
{
type: "ul",
items: [
"Reflective events (spikes up then down): connectors, air gaps, mechanical splices — appear as peaks",
"Non-reflective events (step down): fusion splices, bends, macrobending — appear as steps",
"End of fiber: large reflective peak or flat line into noise floor",
"OTDR dead zone: first 550m near the OTDR is unmeasurable — use launch cable (2550m) to push the dead zone past the first connector",
],
items_de: [
"Reflektive Ereignisse (Spitze nach oben dann unten): Stecker, Luftspuecken, Mechanische Spleisse — erscheinen als Peaks",
"Nicht-reflektive Ereignisse (Schritt nach unten): Fusionsspleisse, Biegungen, Makrobiegungen — erscheinen als Stufen",
"Faserende: grosse reflektive Spitze oder flache Linie zum Rauschboden",
"OTDR-Totzone: erste 550 m nahe dem OTDR sind nicht messbar — Startkabel (2550 m) verwenden, um die Totzone hinter den ersten Stecker zu verschieben",
],
},
],
},
{
heading: "Fiber Inspection — Why It Matters",
heading_de: "Faserninspektion — Warum sie wichtig ist",
blocks: [
{
type: "p",
text: "A single dust particle on a connector endface can cause 140 dB of additional loss — enough to fail a link budget or cause intermittent errors. Fiber inspection with a 200×400× video probe takes 30 seconds and catches this immediately. IEC 61300-3-35 defines the pass/fail criteria: Zone A (core) must be Grade A (no defects) for single-mode.",
text_de:
"Ein einziges Staubpartikel auf einem Steckerendgesicht kann 140 dB zusaetzlichen Verlust verursachen — genug, um ein Linkbudget scheitern zu lassen oder intermittierende Fehler zu verursachen. Die Faserinspektiion mit einer 200×400×-Videosonde dauert 30 Sekunden und erkennt das sofort. IEC 61300-3-35 definiert die Pass/Fail-Kriterien: Zone A (Kern) muss fuer Einzelmodefaser Grad A (keine Defekte) sein.",
},
{
type: "callout",
variant: "warning",
text: "NEVER look directly into a fiber connector with the naked eye or non-filtered scope — even 'dark' fibers may carry infrared laser light that can instantly and permanently damage your eyes. Always use a proper fiber inspection probe with IR filter.",
text_de:
"NIEMALS direkt in einen Faserstecker mit blossem Auge oder ungefilterten Messinstrumenten schauen — selbst 'dunkle' Fasern koennen Infrarot-Laserlicht fuehren, das Ihre Augen sofort und dauerhaft schaedigen kann. Immer eine ordentliche Faserinspetkionssonde mit IR-Filter verwenden.",
},
],
},
],
quiz: [
{
id: "test-eq-q1",
lesson: "test-equipment",
q: "What is the primary function of an OTDR?",
q_de: "Was ist die Hauptfunktion eines OTDR?",
options: [
"Measure the total bandwidth of a fiber link",
"Send pulses and measure backscattered light to locate splices, connectors, and breaks with distance information",
"Measure the optical power output of a transceiver module",
"Test the bit error rate of a data link",
],
options_de: [
"Gesamtbandbreite einer Faserverbindung messen",
"Impulse senden und Streulicht messen, um Spleisse, Stecker und Brueche mit Abstandsangabe zu lokalisieren",
"Optische Ausgangsleistung eines Transceiver-Moduls messen",
"Bitfehlerrate einer Datenverbindung testen",
],
answer: 1,
explanation:
"An OTDR locates and measures the loss of events (splices, connectors, bends, breaks) in a fiber by analyzing the time and amplitude of backscattered light from pulses it sends into the fiber.",
explanation_de:
"Ein OTDR lokalisiert und misst den Verlust von Ereignissen (Spleisse, Stecker, Biegungen, Brueche) in einer Faser durch Analyse von Zeit und Amplitude des Streulichts aus Impulsen, die es in die Faser sendet.",
},
{
id: "test-eq-q2",
lesson: "test-equipment",
q: "Why must you NEVER look directly into a fiber with your naked eye?",
q_de: "Warum darf man NIEMALS direkt mit blossem Auge in eine Faser schauen?",
options: [
"You will contaminate the fiber end with your eye",
"Invisible infrared laser light can instantly and permanently damage your eyes",
"The fiber glow will hurt your eyes temporarily",
"It is only a precaution for multimode fiber, not single-mode",
],
options_de: [
"Sie kontaminieren das Faserende mit Ihrem Auge",
"Unsichtbares Infrarot-Laserlicht kann Ihre Augen sofort und dauerhaft schaedigen",
"Das Faserleuchten verletzt voruebergehend Ihre Augen",
"Es ist nur eine Vorsichtsmassnahme fuer Mehrmodefaser, nicht Einzelmodefaser",
],
answer: 1,
explanation:
"Fiber optic systems use infrared lasers (850nm, 1310nm, 1550nm) invisible to the naked eye but capable of causing instant permanent eye damage. Always use a proper inspection scope with IR filter.",
explanation_de:
"Glasfasersysteme verwenden Infrarot-Laser (850 nm, 1310 nm, 1550 nm), die fuer das blosse Auge unsichtbar sind, aber sofortige dauerhafte Augenschaeden verursachen koennen. Immer eine ordentliche Inspektionssonde mit IR-Filter verwenden.",
},
{
id: "test-eq-q3",
lesson: "test-equipment",
q: "What is the purpose of a 'launch cable' when using an OTDR?",
q_de: "Was ist der Zweck eines 'Startkabels' bei der Verwendung eines OTDRs?",
options: [
"To launch the OTDR software on a laptop",
"To push the OTDR dead zone past the first connector so it can be measured",
"To amplify the OTDR signal for longer reach",
"To protect the OTDR port from laser damage",
],
options_de: [
"Um die OTDR-Software auf einem Laptop zu starten",
"Um die OTDR-Totzone hinter den ersten Stecker zu verschieben, sodass er gemessen werden kann",
"Um das OTDR-Signal fuer groessere Reichweite zu verstaerken",
"Um den OTDR-Port vor Laserschaeden zu schuetzen",
],
answer: 1,
explanation:
"The OTDR dead zone (first 550m) is unmeasurable because the receiver is still recovering from the large pulse. A 2550m launch cable 'pushes' the dead zone past the first connector of interest, allowing it to be measured.",
explanation_de:
"Die OTDR-Totzone (erste 550 m) ist unmessbar, weil der Empfaenger noch vom grossen Impuls erholt. Ein 2550-m-Startkabel 'verschiebt' die Totzone hinter den ersten interessierenden Stecker und ermoeglicht seine Messung.",
},
],
},
{
id: "buy-oem-vs-compatible",
category: "testing-buying",
title: "OEM vs. Compatible Transceivers: Facts vs. Myths",
title_de: "OEM vs. kompatible Transceiver: Fakten vs. Mythen",
level: "beginner",
duration_min: 15,
summary:
"Compatible transceivers are often misunderstood. This lesson separates myths from facts, explains the manufacturing reality, quality tiers, and how to evaluate a compatible module supplier.",
summary_de:
"Kompatible Transceiver werden oft falsch verstanden. Diese Lektion trennt Mythen von Fakten, erklaert die Fertigungsrealitaet, Qualitaetsstufen und wie ein kompatibler Modulanbieter bewertet wird.",
tags: ["oem", "compatible", "third-party", "quality", "myths", "tco"],
sections: [
{
heading: "Who Actually Manufactures OEM Modules?",
heading_de: "Wer stellt OEM-Module tatsaechlich her?",
blocks: [
{
type: "p",
text: "Cisco, Juniper, Arista, and other network vendors do NOT manufacture their own transceivers. They outsource production to a small number of optical module manufacturers (ODMs) — the same companies that also supply third-party compatible modules. The physical module is identical; only the EEPROM vendor programming and price differ.",
text_de:
"Cisco, Juniper, Arista und andere Netzwerkhersteller STELLEN ihre eigenen Transceiver NICHT her. Sie vergeben die Produktion an eine kleine Anzahl optischer Modulhersteller (ODMs) — dieselben Unternehmen, die auch kompatible Drittanbieter-Module liefern. Das physische Modul ist identisch; nur die EEPROM-Herstellerprogrammierung und der Preis unterscheiden sich.",
},
{
type: "table",
headers: ["Major ODM", "OEM customers (examples)", "Also sells compatible?"],
headers_de: ["Grosser ODM", "OEM-Kunden (Beispiele)", "Verkauft auch kompatibel?"],
rows: [
["II-VI (Coherent)", "Cisco, Juniper, HP", "Yes — via many rebranders"],
["Lumentum", "Cisco, Arista, Nokia", "Yes — via channel"],
["InnoLight", "Arista, hyperscalers", "Yes — major compatible supplier"],
["Hisense Broadband", "Hyperscalers, enterprise", "Yes — major compatible ODM"],
["Source Photonics", "Cisco, Calix, ADTRAN", "Yes — OEM and compatible"],
["JDSU / Viavi", "Cisco, Juniper", "Yes — mainly OEM now"],
],
},
{
type: "callout",
variant: "key",
text: "The module that says 'Cisco SFP-10G-LR' and a FLEXOPTIX-coded compatible SFP+ LR may come from the same production line at InnoLight or II-VI. The difference is the EEPROM programming and the price tag — not the optical or electrical components.",
text_de:
"Das Modul mit der Aufschrift 'Cisco SFP-10G-LR' und ein FLEXOPTIX-kodiertes kompatibles SFP+ LR koennen von derselben Produktionslinie bei InnoLight oder II-VI stammen. Der Unterschied liegt in der EEPROM-Programmierung und dem Preisschild — nicht in den optischen oder elektrischen Komponenten.",
},
],
},
{
heading: "Common Myths — Debunked",
heading_de: "Haeufige Mythen — Widerlegt",
blocks: [
{
type: "table",
headers: ["Myth", "Fact"],
headers_de: ["Mythos", "Fakt"],
rows: [
[
"Compatible modules damage switches",
"FALSE — MSA-compliant modules meet the same electrical interface specs. There is no electrical difference that could cause hardware damage.",
],
[
"Using a third-party module voids the switch warranty",
"FALSE (in most jurisdictions) — In the EU and US, using a third-party component does not void a product warranty unless the vendor can prove the component caused the damage. The Magnuson-Moss Warranty Act (US) and EU Directive 1999/44/EC protect consumers.",
],
[
"Compatible modules have lower quality / higher failure rates",
"FALSE for Tier 1 — Tier 1 compatible modules from major ODMs have the same MTBF (300,000+ hours) as OEM modules from the same factory. Tier 3 budget modules may have higher failure rates.",
],
[
"Only OEM modules show DOM diagnostics",
"FALSE — Properly coded compatible modules with full EEPROM programming show DOM on Cisco IOS, Arista EOS, Juniper Junos, and most other platforms.",
],
[
"You can't get support if you use compatible modules",
"PARTIALLY TRUE — Switch vendor TAC (e.g., Cisco) may decline to assist until you swap to an OEM module. But the module itself and your fiber supplier are responsible for module support.",
],
],
},
],
},
{
heading: "Quality Tiers",
heading_de: "Qualitaetsstufen",
blocks: [
{
type: "table",
headers: ["Tier", "Description", "MTBF", "DOM Support", "Where to Buy"],
headers_de: ["Stufe", "Beschreibung", "MTBF", "DOM-Unterstuetzung", "Wo kaufen"],
rows: [
["Tier 1 (OEM)", "OEM-branded, same ODM", "300,000+ h", "Full", "Cisco, Juniper, Arista shops"],
["Tier 1 (Compatible)", "Major ODM, full EEPROM, ISO 9001, full DOM, coded per platform", "300,000+ h", "Full", "Specialist: FLEXOPTIX, fs.com, Finisar-compatible"],
["Tier 2 (Compatible)", "Rebrand of Tier 1 or Tier 2 ODM, may have limited DOM, partial coding", "200,000300,000 h", "Partial", "Some online resellers"],
["Tier 3 (Budget)", "Unknown ODM, no testing data, may lack DOM, partial EEPROM", "<100,000 h", "Often missing", "Avoid for production networks"],
],
},
{
type: "callout",
variant: "tip",
text: "How to verify a Tier 1 compatible supplier: (1) Ask for full EEPROM dump for your target platform, (2) Request MTBF data from the ODM datasheet, (3) Confirm DOM parameter coverage, (4) Check warranty terms (minimum 3 years recommended), (5) Ask if they use ISO 9001 certified ODMs.",
text_de:
"Wie ein Tier-1-Kompatibel-Anbieter verifiziert wird: (1) Vollstaendigen EEPROM-Dump fuer Zielplattform anfragen, (2) MTBF-Daten aus dem ODM-Datenblatt anfordern, (3) DOM-Parameter-Abdeckung bestaetigen, (4) Garantiebedingungen pruefen (mindestens 3 Jahre empfohlen), (5) Pruefen ob ISO-9001-zertifizierte ODMs verwendet werden.",
},
],
},
],
quiz: [
{
id: "buy-oem-q1",
lesson: "buy-oem-vs-compatible",
q: "Who manufactures Cisco SFP+ modules?",
q_de: "Wer stellt Cisco SFP+-Module her?",
options: [
"Cisco manufactures all their own modules in-house",
"ODMs like II-VI, Lumentum, InnoLight — same companies that also make compatible modules",
"Only US-based manufacturers approved by Cisco",
"Joint venture between Cisco and Huawei",
],
options_de: [
"Cisco stellt alle eigenen Module intern her",
"ODMs wie II-VI, Lumentum, InnoLight — dieselben Unternehmen, die auch kompatible Module herstellen",
"Nur US-basierte von Cisco genehmigte Hersteller",
"Joint Venture zwischen Cisco und Huawei",
],
answer: 1,
explanation:
"Cisco outsources transceiver manufacturing to major ODMs (Original Design Manufacturers) like II-VI (Coherent), Lumentum, and InnoLight — the same companies that manufacture compatible modules for other brands.",
explanation_de:
"Cisco vergibt die Transceiver-Fertigung an grosse ODMs (Original Design Manufacturers) wie II-VI (Coherent), Lumentum und InnoLight — dieselben Unternehmen, die kompatible Module fuer andere Marken herstellen.",
},
{
id: "buy-oem-q2",
lesson: "buy-oem-vs-compatible",
q: "A Tier 1 compatible module from a major ODM has what MTBF compared to OEM?",
q_de: "Ein Tier-1-kompatibles Modul von einem grossen ODM hat welche MTBF im Vergleich zu OEM?",
options: [
"Much lower — typically 50,000 hours",
"Slightly lower — typically 200,000 hours vs 300,000 OEM",
"Equivalent — typically 300,000+ hours from the same factory",
"Higher — because compatible modules are newer",
],
options_de: [
"Viel niedriger — typisch 50.000 Stunden",
"Etwas niedriger — typisch 200.000 Stunden vs 300.000 OEM",
"Gleichwertig — typisch 300.000+ Stunden aus derselben Fabrik",
"Hoeher — weil kompatible Module neuer sind",
],
answer: 2,
explanation:
"Tier 1 compatible modules from major ODMs (the same factories making OEM modules) have equivalent MTBF — typically 300,000+ hours. The failure rate difference between OEM and Tier 1 compatible is negligible.",
explanation_de:
"Tier-1-kompatible Module von grossen ODMs (denselben Fabriken, die OEM-Module herstellen) haben gleichwertige MTBF — typisch 300.000+ Stunden. Der Ausfallratenunterschied zwischen OEM und Tier-1-kompatibel ist vernachlaessigbar.",
},
{
id: "buy-oem-q3",
lesson: "buy-oem-vs-compatible",
q: "Cisco TAC asks you to swap a third-party module. What should you do?",
q_de: "Cisco TAC bittet Sie, ein Drittanbieter-Modul auszutauschen. Was sollten Sie tun?",
options: [
"Always swap immediately — TAC knows best",
"Refuse — Cisco has no right to make this request",
"Understand this is a support policy: swap for diagnosis if needed, but the module may not be the cause",
"Escalate to Cisco management",
],
options_de: [
"Sofort tauschen — TAC weiss es am besten",
"Ablehnen — Cisco hat kein Recht, das zu verlangen",
"Verstehen, dass dies eine Support-Richtlinie ist: zum Diagnostizieren tauschen wenn noetig, aber das Modul ist moeglicherweise nicht die Ursache",
"An das Cisco-Management eskalieren",
],
answer: 2,
explanation:
"Cisco TAC's policy is to not support third-party modules. If TAC asks you to swap, understand this is a support policy — not evidence the module is faulty. Swap for diagnostic purposes if needed, but the compatible module itself is likely not the root cause.",
explanation_de:
"Die Cisco-TAC-Richtlinie ist es, keine Drittanbieter-Module zu unterstuetzen. Wenn TAC Sie bittet zu tauschen, verstehen Sie, dass dies eine Support-Richtlinie ist — kein Beweis, dass das Modul fehlerhaft ist. Zum Diagnostizieren tauschen wenn noetig, aber das kompatible Modul selbst ist wahrscheinlich nicht die Grundursache.",
},
{
id: "buy-oem-q4",
lesson: "buy-oem-vs-compatible",
q: "What is the most important indicator of a Tier 1 compatible module supplier?",
q_de: "Was ist der wichtigste Indikator fuer einen Tier-1-Kompatibel-Modulanbieter?",
options: [
"They have the lowest price",
"They can provide MTBF data, full EEPROM dump, ISO 9001 ODM certification, and DOM support",
"They are based in Europe",
"They have the most product listings",
],
options_de: [
"Sie haben den niedrigsten Preis",
"Sie koennen MTBF-Daten, vollstaendigen EEPROM-Dump, ISO-9001-ODM-Zertifizierung und DOM-Unterstuetzung bereitstellen",
"Sie sind in Europa ansaessig",
"Sie haben die meisten Produktlistungen",
],
answer: 1,
explanation:
"A Tier 1 compatible supplier can provide: MTBF data from ODM datasheets, full EEPROM programming samples, ISO 9001 ODM certification, full DOM support for your target platform, and a 3-year warranty.",
explanation_de:
"Ein Tier-1-Kompatibel-Anbieter kann bereitstellen: MTBF-Daten aus ODM-Datenblaettern, vollstaendige EEPROM-Programmerproben, ISO-9001-ODM-Zertifizierung, vollstaendige DOM-Unterstuetzung fuer die Zielplattform und 3 Jahre Garantie.",
},
],
},
{
id: "buy-datasheet",
category: "testing-buying",
title: "How to Read a Transceiver Datasheet",
title_de: "Wie man ein Transceiver-Datenblatt liest",
level: "beginner",
duration_min: 12,
summary:
"A transceiver datasheet contains everything you need to know about module performance. Learn which parameters are critical, what typical values look like, and which red flags indicate a low-quality module.",
summary_de:
"Ein Transceiver-Datenblatt enthaelt alles, was Sie ueber die Modulleistung wissen muessen. Lernen Sie, welche Parameter kritisch sind, wie typische Werte aussehen und welche roten Flaggen auf ein minderwertiges Modul hinweisen.",
tags: ["datasheet", "specifications", "tx-power", "rx-sensitivity", "buying"],
sections: [
{
heading: "Key Transmitter Parameters",
heading_de: "Wichtige Sender-Parameter",
blocks: [
{
type: "table",
headers: ["Parameter", "Typical Value (10G LR)", "What It Means"],
headers_de: ["Parameter", "Typischer Wert (10G LR)", "Bedeutung"],
rows: [
["Center wavelength", "1310 nm ± 50nm", "Optical wavelength — must match system filters if WDM"],
["TX output power (min)", "-8.2 dBm", "Worst-case minimum power out — use this for link budget"],
["TX output power (max)", "+0.5 dBm", "Maximum power — if short links, check for overpower"],
["Extinction ratio (min)", "8.2 dB", "Ratio of '1' to '0' power — lower = worse signal quality"],
["Side mode suppression ratio (SMSR)", ">30 dB", "Single-mode laser purity — low SMSR causes interference"],
],
},
{
type: "callout",
variant: "warning",
text: "Always use the MINIMUM TX power for link budget calculations — never the typical or maximum value. The minimum guaranteed value is what the module will deliver under worst-case temperature and aging conditions.",
text_de:
"Fuer Linkbudget-Berechnungen immer den MINDEST-TX-Leistungswert verwenden — niemals den typischen oder maximalen Wert. Der garantierte Mindestwert ist das, was das Modul unter Worst-Case-Temperatur und Alterungsbedingungen liefern wird.",
},
],
},
{
heading: "Key Receiver Parameters",
heading_de: "Wichtige Empfaenger-Parameter",
blocks: [
{
type: "table",
headers: ["Parameter", "Typical Value (10G LR)", "What It Means"],
headers_de: ["Parameter", "Typischer Wert (10G LR)", "Bedeutung"],
rows: [
["RX sensitivity (max)", "-14.4 dBm", "Minimum received power for BER 10⁻¹² — link budget input"],
["RX maximum input power", "+0.5 dBm", "Maximum power before saturation — check for overpower on short links"],
["Return loss (min)", "26 dB", "Reflected power — lower return loss causes laser instability"],
["BER at sensitivity", "10⁻¹²", "One bit error in 1 trillion bits transmitted — the standard target"],
],
},
],
},
{
heading: "Environmental and General Parameters",
heading_de: "Umgebungs- und allgemeine Parameter",
blocks: [
{
type: "table",
headers: ["Parameter", "Commercial", "Industrial", "Extended"],
headers_de: ["Parameter", "Kommerziell", "Industrie", "Erweitert"],
rows: [
["Operating temperature", "0 to +70°C", "-40 to +85°C", "-40 to +85°C or wider"],
["Storage temperature", "-40 to +85°C", "-40 to +85°C", "-40 to +85°C"],
["Humidity", "585% non-cond.", "595% non-cond.", "Full range"],
["Typical use case", "Indoor data center", "Outdoor / harsh env.", "Telco, military, aviation"],
],
},
{
type: "callout",
variant: "tip",
text: "For outdoor equipment enclosures or industrial environments (factories, warehouses), always specify Industrial temperature range (-40 to +85°C). Commercial modules may fail or degrade at temperatures above 70°C.",
text_de:
"Fuer Aussengehaeuseausruestung oder Industrieumgebungen (Fabriken, Lager) immer den Industrietemperaturbereich (-40 bis +85°C) angeben. Kommerzielle Module koennen bei Temperaturen ueber 70°C versagen oder sich verschlechtern.",
},
],
},
{
heading: "Red Flags in Datasheets",
heading_de: "Rote Flaggen in Datenblaettern",
blocks: [
{
type: "ul",
items: [
"No extinction ratio specification — means '0' level not controlled, higher BER likely",
"Missing or unrealistically good RX sensitivity — 'typical only' without minimum = no guarantee",
"No MTBF data or MTBF below 200,000 hours for standard modules",
"No certifications listed (CE, FCC, RoHS) — compliance unknown",
"No DOM register table — DOM may be missing or incorrect",
"Only 'typical' values listed, no min/max — can't do reliable link budget",
"No revision history or document date — likely not maintained / outdated spec",
],
items_de: [
"Kein Ausloeschungsverhaeltnis spezifiziert — bedeutet '0'-Pegel unkontrolliert, hohere BER wahrscheinlich",
"Fehlende oder unrealistisch gute RX-Empfindlichkeit — 'nur typisch' ohne Mindestwert = keine Garantie",
"Keine MTBF-Daten oder MTBF unter 200.000 Stunden fuer Standardmodule",
"Keine Zertifizierungen angegeben (CE, FCC, RoHS) — Konformitaet unbekannt",
"Keine DOM-Registertabelle — DOM koennte fehlen oder falsch sein",
"Nur 'typische' Werte angegeben, kein Min/Max — zuverlaessiges Linkbudget nicht moeglich",
"Keine Revisionshistorie oder Dokumentdatum — wahrscheinlich nicht gepflegt / veraltet",
],
},
],
},
],
quiz: [
{
id: "buy-ds-q1",
lesson: "buy-datasheet",
q: "Which TX power value should you use for link budget calculation?",
q_de: "Welchen TX-Leistungswert sollen Sie fuer die Linkbudget-Berechnung verwenden?",
options: [
"Maximum TX power — best case scenario",
"Typical TX power — most representative value",
"Minimum TX power — guaranteed worst case",
"Average of minimum and maximum",
],
options_de: [
"Maximale TX-Leistung — Best-Case-Szenario",
"Typische TX-Leistung — repraesentativster Wert",
"Minimale TX-Leistung — garantierter Worst-Case",
"Durchschnitt aus Minimum und Maximum",
],
answer: 2,
explanation:
"Always use the MINIMUM guaranteed TX power for link budget. This ensures the link works under worst-case conditions: end-of-life, maximum temperature, lowest manufacturing tolerance.",
explanation_de:
"Fuer das Linkbudget immer die MINDEST-garantierte TX-Leistung verwenden. Das stellt sicher, dass die Verbindung unter Worst-Case-Bedingungen funktioniert: Lebensende, maximale Temperatur, niedrigste Fertigungstoleranz.",
},
{
id: "buy-ds-q2",
lesson: "buy-datasheet",
q: "A datasheet shows RX sensitivity as '-14.4 dBm (typical)' with no minimum value. What is the concern?",
q_de: "Ein Datenblatt zeigt RX-Empfindlichkeit als '-14.4 dBm (typisch)' ohne Mindestwert. Was ist das Problem?",
options: [
"No concern — typical values are sufficient for planning",
"The sensitivity may be worse in some units — link budget is unreliable without a guaranteed minimum",
"The module is using PAM4 instead of NRZ",
"The module cannot be used in data centers",
],
options_de: [
"Kein Problem — typische Werte sind fuer die Planung ausreichend",
"Die Empfindlichkeit kann bei einigen Einheiten schlechter sein — Linkbudget ohne garantierten Mindestwert unzuverlaessig",
"Das Modul verwendet PAM4 statt NRZ",
"Das Modul kann nicht in Rechenzentren verwendet werden",
],
answer: 1,
explanation:
"A 'typical only' sensitivity value means there is no manufacturer guarantee. Some units may be significantly worse, making link budget calculations unreliable. Always require minimum (worst-case) values.",
explanation_de:
"Ein 'nur typischer' Empfindlichkeitswert bedeutet, dass es keine Herstellergarantie gibt. Einige Einheiten koennten deutlich schlechter sein, was Linkbudget-Berechnungen unzuverlaessig macht. Immer Mindestwerte (Worst-Case) fordern.",
},
{
id: "buy-ds-q3",
lesson: "buy-datasheet",
q: "What temperature range is required for modules installed in outdoor equipment enclosures?",
q_de: "Welcher Temperaturbereich ist fuer Module erforderlich, die in Aussengehaeuseausruestung installiert werden?",
options: [
"Commercial (0 to +70°C)",
"Industrial (-40 to +85°C)",
"Standard (10 to +60°C)",
"Extended (0 to +85°C)",
],
options_de: [
"Kommerziell (0 bis +70°C)",
"Industrie (-40 bis +85°C)",
"Standard (-10 bis +60°C)",
"Erweitert (0 bis +85°C)",
],
answer: 1,
explanation:
"Industrial temperature range (-40 to +85°C) is required for outdoor equipment or harsh environments. Commercial modules (0 to +70°C) may fail or degrade significantly in summer heat inside an outdoor enclosure.",
explanation_de:
"Der Industrietemperaturbereich (-40 bis +85°C) ist fuer Aussengeraete oder harte Umgebungen erforderlich. Kommerzielle Module (0 bis +70°C) koennen bei Sommerhitze in einem Aussengehaeuese versagen oder sich deutlich verschlechtern.",
},
],
},
{
id: "buy-tco",
category: "testing-buying",
title: "Total Cost of Ownership: OEM vs. Compatible",
title_de: "Gesamtbetriebskosten: OEM vs. kompatibel",
level: "intermediate",
duration_min: 12,
summary:
"The real cost of transceivers goes beyond the purchase price. Learn to calculate Total Cost of Ownership (TCO) including power, spares, and support — and why compatible modules often save 6080% over a 5-year horizon.",
summary_de:
"Die tatsaechlichen Kosten von Transceivern gehen ueber den Kaufpreis hinaus. Lernen Sie, die Gesamtbetriebskosten (TCO) einschliesslich Energie, Ersatzteile und Support zu berechnen — und warum kompatible Module ueber einen 5-Jahres-Horizont oft 6080 % einsparen.",
tags: ["tco", "cost", "oem", "compatible", "roi", "procurement"],
sections: [
{
heading: "TCO Components",
heading_de: "TCO-Komponenten",
blocks: [
{
type: "ul",
items: [
"Purchase price: the obvious cost — OEM is typically 310× more expensive than Tier 1 compatible",
"Power cost: P(W) × hours/year × $/kWh — usually small per port but significant at scale",
"Spares cost: maintain 25% spare pool; compatible spares cost less",
"Failure replacement labor: same regardless of module brand",
"Support cost: OEM modules may be required for vendor TAC — factor in if support contracts are critical",
"Lifecycle cost: transceivers don't need firmware updates — replace on failure only",
],
items_de: [
"Kaufpreis: die offensichtlichen Kosten — OEM ist typisch 310× teurer als Tier-1-kompatibel",
"Energiekosten: P(W) × Stunden/Jahr × EUR/kWh — pro Port meist gering, bei Skalierung erheblich",
"Ersatzteilkosten: 25% Ersatzteilpool pflegen; kompatible Ersatzteile kosten weniger",
"Arbeitskosten fuer Fehlerersatz: gleich unabhaengig von der Modulmarke",
"Support-Kosten: OEM-Module koennen fuer Hersteller-TAC erforderlich sein — beruecksichtigen wenn Support-Vertraege kritisch",
"Lifecycle-Kosten: Transceiver benoetigen keine Firmware-Updates — nur bei Ausfall ersetzen",
],
},
],
},
{
heading: "5-Year TCO Example: 1000-Port Data Center",
heading_de: "5-Jahres-TCO-Beispiel: 1000-Port-Rechenzentrum",
blocks: [
{
type: "table",
headers: ["Cost Item", "OEM (10G LR SFP+)", "Tier 1 Compatible (10G LR SFP+)", "Saving"],
headers_de: ["Kostenpunkt", "OEM (10G LR SFP+)", "Tier 1 kompatibel (10G LR SFP+)", "Ersparnis"],
rows: [
["Unit purchase price", "$800", "$80", "$720/port"],
["1,000 ports total (initial)", "$800,000", "$80,000", "$720,000"],
["5% spares (50 units)", "$40,000", "$4,000", "$36,000"],
["Power (1.5W × 1000 × 5yr)", "~$4,380", "~$4,380", "$0 (identical)"],
["Failure replacement (1% annual)", "$40,000", "$4,000", "$36,000"],
["Total 5-year TCO", "$884,380", "$92,380", "$792,000 (89% savings)"],
],
},
{
type: "callout",
variant: "key",
text: "At 1000 ports of 10G LR, the 5-year saving from Tier 1 compatible vs. OEM is approximately $792,000. Even accounting for validation lab costs ($1050K for thorough testing), the ROI is compelling.",
text_de:
"Bei 1.000 Ports mit 10G LR betraegt die 5-Jahres-Ersparnis durch Tier-1-kompatibel vs. OEM ungefaehr 792.000 USD. Selbst unter Beruecksichtigung der Validierungslaborkosten (10.00050.000 USD fuer gruendliche Tests) ist der ROI ueberzeugend.",
},
],
},
{
heading: "Power Cost at Scale",
heading_de: "Energiekosten in grossem Massstab",
blocks: [
{
type: "formula",
text: "Annual power cost = P_watts × 8760 h × ($/kWh) / 1000",
desc: "Example: 100 SFP28 25G SR ports at 1.5W each = 150W × 8760h × $0.10/kWh = $131/year. At 1000 ports = $1,310/year — small but non-zero.",
desc_de:
"Beispiel: 100 SFP28 25G SR Ports mit je 1,5W = 150W × 8760h × 0,10 EUR/kWh = 131 EUR/Jahr. Bei 1.000 Ports = 1.310 EUR/Jahr — gering aber nicht null.",
},
{
type: "p",
text: "For high-power modules like 400G-ZR coherent (~15W per port), power cost becomes significant: 64 ports × 15W = 960W × 8760h × $0.10/kWh = $841/year. OEM vs. compatible modules at the same spec consume identical power — power cost does not differ.",
text_de:
"Fuer leistungsstarke Module wie 400G-ZR Coherent (~15W pro Port) werden Energiekosten erheblich: 64 Ports × 15W = 960W × 8760h × 0,10 EUR/kWh = 841 EUR/Jahr. OEM vs. kompatible Module mit gleicher Spezifikation verbrauchen identische Leistung — Energiekosten unterscheiden sich nicht.",
},
],
},
{
heading: "Spares Strategy",
heading_de: "Ersatzteilstrategie",
blocks: [
{
type: "ul",
items: [
"Maintain a 25% spare pool per transceiver type in production",
"For critical links (data center spine, internet peering), keep ≥2 spares minimum",
"Store spares in ESD-safe bags at 1525°C, away from static and humidity",
"Transceivers have no firmware to update — replaced units are plug-and-play (after EEPROM coding matches)",
"Review spare pool annually against installed base changes",
],
items_de: [
"25% Ersatzteilpool pro Transceiver-Typ in der Produktion pflegen",
"Fuer kritische Verbindungen (Rechenzentrum-Spine, Internet-Peering) mindestens ≥2 Ersatzteile bereithalten",
"Ersatzteile in ESD-sicheren Beuteln bei 1525°C, entfernt von Statik und Feuchtigkeit lagern",
"Transceiver haben keine Firmware zum Aktualisieren — ersetzte Einheiten sind Plug-and-Play (nach korrekter EEPROM-Kodierung)",
"Ersatzteilpool jaehrlich gegen Aenderungen des Bestands ueberpruefen",
],
},
],
},
],
quiz: [
{
id: "buy-tco-q1",
lesson: "buy-tco",
q: "For 1000 ports of 10G LR, how much can a company save over 5 years by using Tier 1 compatible vs. OEM?",
q_de: "Bei 1.000 Ports mit 10G LR wie viel kann ein Unternehmen ueber 5 Jahre durch die Verwendung von Tier-1-kompatibel vs. OEM einsparen?",
options: [
"About $10,000 — not significant",
"About $100,000",
"About $700,000$800,000",
"Nothing — compatible modules have higher maintenance costs that offset purchase savings",
],
options_de: [
"Etwa 10.000 USD — nicht bedeutsam",
"Etwa 100.000 USD",
"Etwa 700.000800.000 USD",
"Nichts — kompatible Module haben hoehere Wartungskosten, die Kaufersparnisse aufwiegen",
],
answer: 2,
explanation:
"With OEM 10G LR at $800/port and compatible at $80/port, the initial saving alone is $720,000 at 1000 ports. Add spares and replacements over 5 years = ~$792,000 total saving.",
explanation_de:
"Mit OEM 10G LR zu 800 USD/Port und kompatibel zu 80 USD/Port betraegt die anfaengliche Ersparnis allein 720.000 USD bei 1.000 Ports. Dazu Ersatzteile und Austausch ueber 5 Jahre = ~792.000 USD Gesamtersparnis.",
},
{
id: "buy-tco-q2",
lesson: "buy-tco",
q: "What spare pool percentage is recommended for production transceivers?",
q_de: "Welcher Ersatzteilpool-Prozentsatz wird fuer Produktionstransceiver empfohlen?",
options: ["0% — replace on failure only", "25%", "1015%", "50% — one spare per port"],
options_de: ["0% — nur bei Ausfall ersetzen", "25%", "1015%", "50% — ein Ersatzgeraet pro Port"],
answer: 1,
explanation:
"A 25% spare pool covers typical annual failure rates (0.51%) while minimizing tied-up capital. For critical links, maintain at least 2 individual spares regardless of percentage.",
explanation_de:
"Ein 25%-Ersatzteilpool deckt typische jaehrliche Ausfallraten (0,51%) ab und minimiert gebundenes Kapital. Fuer kritische Verbindungen mindestens 2 individuelle Ersatzteile unabhaengig vom Prozentsatz bereithalten.",
},
{
id: "buy-tco-q3",
lesson: "buy-tco",
q: "Do OEM and Tier 1 compatible modules of the same spec consume different amounts of power?",
q_de: "Verbrauchen OEM und Tier-1-kompatible Module derselben Spezifikation unterschiedlich viel Strom?",
options: [
"Yes — OEM modules are more power-efficient",
"Yes — compatible modules consume more power",
"No — modules of the same spec from the same ODM consume identical power",
"It depends on the switch vendor",
],
options_de: [
"Ja — OEM-Module sind energieeffizienter",
"Ja — kompatible Module verbrauchen mehr Strom",
"Nein — Module gleicher Spezifikation vom gleichen ODM verbrauchen identisch viel Strom",
"Es haengt vom Switch-Hersteller ab",
],
answer: 2,
explanation:
"OEM and compatible modules of the same specification from the same ODM are electrically and optically identical — they consume the same power. Power cost does not differ between OEM and Tier 1 compatible.",
explanation_de:
"OEM und kompatible Module gleicher Spezifikation vom gleichen ODM sind elektrisch und optisch identisch — sie verbrauchen denselben Strom. Energiekosten unterscheiden sich nicht zwischen OEM und Tier-1-kompatibel.",
},
],
},
];

View File

@ -1,55 +0,0 @@
export interface TrainingLesson {
id: string;
category: string;
title: string;
title_de: string;
level: "beginner" | "intermediate" | "advanced";
duration_min: number;
summary: string;
summary_de: string;
sections: TrainingSection[];
quiz: QuizQuestion[];
tags: string[];
}
export interface TrainingSection {
heading: string;
heading_de?: string;
blocks: ContentBlock[];
}
export type ContentBlock =
| { type: "p"; text: string; text_de?: string }
| { type: "h3"; text: string; text_de?: string }
| { type: "table"; headers: string[]; headers_de?: string[]; rows: string[][] }
| { type: "ul"; items: string[]; items_de?: string[] }
| {
type: "callout";
variant: "info" | "warning" | "tip" | "key";
text: string;
text_de?: string;
}
| { type: "code"; text: string }
| { type: "formula"; text: string; desc: string; desc_de?: string };
export interface QuizQuestion {
id: string;
lesson: string;
q: string;
q_de?: string;
options: [string, string, string, string];
options_de?: [string, string, string, string];
answer: 0 | 1 | 2 | 3;
explanation: string;
explanation_de?: string;
}
export interface TrainingCategory {
id: string;
title: string;
title_de: string;
icon: string;
description: string;
description_de: string;
lessons: TrainingLesson[];
}

View File

@ -28,7 +28,7 @@ export async function searchTransceivers(params: SearchParams) {
let idx = 1; let idx = 1;
if (params.q) { if (params.q) {
conditions.push(`(search_vector @@ plainto_tsquery('english', $${idx}) OR t.part_number ILIKE '%' || $${idx} || '%' OR t.standard_name ILIKE '%' || $${idx} || '%')`); conditions.push(`search_vector @@ plainto_tsquery('english', $${idx})`);
values.push(params.q); values.push(params.q);
idx++; idx++;
} }
@ -98,8 +98,8 @@ export async function searchTransceivers(params: SearchParams) {
// Add relevance ranking when full-text search is used // Add relevance ranking when full-text search is used
const orderBy = params.q const orderBy = params.q
? `ORDER BY (t.part_number ILIKE $1) DESC, ts_rank(search_vector, plainto_tsquery('english', $1)) DESC, fully_verified DESC NULLS LAST, has_image DESC NULLS LAST` ? `ORDER BY ts_rank(search_vector, plainto_tsquery('english', $1)) DESC`
: `ORDER BY fully_verified DESC NULLS LAST, has_image DESC NULLS LAST, speed_gbps DESC NULLS LAST, reach_meters ASC NULLS LAST`; : `ORDER BY speed_gbps DESC, reach_meters ASC`;
const query = ` const query = `
SELECT t.*, v.name as vendor_name SELECT t.*, v.name as vendor_name

View File

@ -35,19 +35,6 @@ import { selflearningRouter } from "./routes/selflearning";
import { internalDemandRouter } from "./routes/internal-demand"; import { internalDemandRouter } from "./routes/internal-demand";
import { formFactorsRouter } from "./routes/form-factors"; import { formFactorsRouter } from "./routes/form-factors";
import { tipLlmRouter } from "./routes/tip-llm"; import { tipLlmRouter } from "./routes/tip-llm";
import { equivalencesRouter } from "./routes/equivalences";
import { priceHistoryRouter } from "./routes/price-history";
import { kbRouter } from "./routes/kb";
import { bulkPriceRouter } from "./routes/bulk-price";
import { vendorReliabilityRouter } from "./routes/vendor-reliability";
import { priceForecastRouter } from "./routes/price-forecast";
import { priceMatrixRouter } from "./routes/price-matrix";
import { trainingRouter } from "./routes/training";
import { rfqRouter } from "./routes/rfq";
import { priceAlertsRouter } from "./routes/price-alerts";
import { winLossRouter } from "./routes/win-loss";
import { apiKeysRouter } from "./routes/api-keys";
import { roiRouter } from "./routes/roi";
const app = express(); const app = express();
@ -57,7 +44,7 @@ app.set("trust proxy", 1);
// Middleware // Middleware
app.use(helmet({ contentSecurityPolicy: false })); app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "30mb" })); // 30MB to support base64-encoded PDF uploads app.use(express.json());
app.use( app.use(
rateLimit({ rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
@ -80,7 +67,6 @@ app.use("/api", (req, res, next) => {
if (req.path.startsWith("/proxy")) return next(); if (req.path.startsWith("/proxy")) return next();
if (req.path.startsWith("/hot-topics")) return next(); if (req.path.startsWith("/hot-topics")) return next();
if (req.path.startsWith("/price-comparison")) return next(); if (req.path.startsWith("/price-comparison")) return next();
if (req.path.startsWith("/training")) return next();
requireAuth(req, res, next); requireAuth(req, res, next);
}); });
@ -116,31 +102,6 @@ app.use("/api/form-factors", formFactorsRouter);
app.use("/api/internal/demand", internalDemandRouter); app.use("/api/internal/demand", internalDemandRouter);
// tip-llm-v1 guided inference // tip-llm-v1 guided inference
app.use("/api/tip-llm", tipLlmRouter); app.use("/api/tip-llm", tipLlmRouter);
// Equivalences (cross-brand alternatives)
app.use("/api/equivalences", equivalencesRouter);
// Price history charts
app.use("/api/price-history", priceHistoryRouter);
app.use("/api/kb", kbRouter);
// Bulk price lookup (G)
app.use("/api/bulk-price", bulkPriceRouter);
// Vendor reliability scores (I)
app.use("/api/vendors/reliability", vendorReliabilityRouter);
// Price forecast (O)
app.use("/api/price-forecast", priceForecastRouter);
// Price matrix / heat map (J)
app.use("/api/price-matrix", priceMatrixRouter);
// Transceiver Academy — public training content (no auth required)
app.use("/api/training", trainingRouter);
// RFQ Analyzer — quote vs market comparison
app.use("/api/rfq", rfqRouter);
// Price Alert Subscriptions
app.use("/api/price-alerts", priceAlertsRouter);
// Win/Loss Intelligence
app.use("/api/win-loss", winLossRouter);
// Customer API Key Management
app.use("/api/api-keys", apiKeysRouter);
// ROI Calculator
app.use("/api/roi", roiRouter);
// Dashboard (static HTML) // Dashboard (static HTML)
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard"))); app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));

View File

@ -22,191 +22,36 @@ const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:325
// ── Runtime-switchable provider state ────────────────────────────────────── // ── Runtime-switchable provider state ──────────────────────────────────────
// Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch). // Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch).
// Falls back to process.env, then to defaults. No restart required for switches. // Falls back to process.env, then to defaults. No restart required for switches.
//
// AUTO-DISCOVERY: At startup and on a periodic refresh, the active fo-blog-v* model
// is validated against Ollama's actual model list. If the configured model no longer
// exists (e.g. Magatama trained a new version and Ollama removed older tags), the
// highest available fo-blog-v* version is picked automatically — no manual env or
// settings-file update needed after each training cycle.
const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json"); const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json");
const STATIC_FALLBACK_MODEL = "fo-blog-v10";
const DISCOVERY_REFRESH_MS = Number.parseInt(process.env.BLOG_LLM_DISCOVERY_REFRESH_MS || "", 10) || 10 * 60_000;
interface LlmSettings { interface LlmSettings { provider: string; ollamaModel: string }
provider: string;
ollamaModel: string;
/** When set, auto-upgrade is disabled and this exact version is used. */
pinnedVersion?: string;
}
function loadSettingsRaw(): LlmSettings { function loadSettings(): LlmSettings {
try { try {
if (existsSync(SETTINGS_FILE)) { if (existsSync(SETTINGS_FILE)) {
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<LlmSettings>; const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings;
return { return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" };
provider: raw.provider || process.env.BLOG_LLM_PROVIDER || "ollama",
ollamaModel: raw.ollamaModel || process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
pinnedVersion: raw.pinnedVersion || undefined,
};
} }
} catch { /* ignore corrupt file */ } } catch { /* ignore corrupt file */ }
return { return {
provider: process.env.BLOG_LLM_PROVIDER || "ollama", provider: process.env.BLOG_LLM_PROVIDER || "ollama",
ollamaModel: process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL, ollamaModel: process.env.OLLAMA_LLM_MODEL || "fo-blog-v7",
}; };
} }
/** let _settings = loadSettings();
* Sort fo-blog-v{N}[-r{M}] tags newest-first.
*
* Magatama convention (confirmed by /api/llm/status?lane=fo_blogllm):
* - `fo-blog-vN` base tag, the active production model after adoption
* - `fo-blog-vN-rM` revision metadata, intermediate adapter save
*
* So within the same major N, the BASE tag wins over -rM revisions.
*
* Order: higher N > lower N; within same N, base ("no -r") > any -rM revision.
*/
function compareFoBlogVersionsDesc(a: string, b: string): number {
const re = /^fo-blog-v(\d+)(?:-r(\d+))?$/;
const ma = re.exec(a);
const mb = re.exec(b);
if (!ma || !mb) return a.localeCompare(b);
const va = Number.parseInt(ma[1], 10);
const vb = Number.parseInt(mb[1], 10);
if (va !== vb) return vb - va;
// Same major version: base tag (no -r suffix) wins over any -rM revision
const aIsBase = ma[2] === undefined;
const bIsBase = mb[2] === undefined;
if (aIsBase !== bIsBase) return aIsBase ? -1 : 1;
// Both have -rM: higher M is newer
const ra = ma[2] ? Number.parseInt(ma[2], 10) : 0;
const rb = mb[2] ? Number.parseInt(mb[2], 10) : 0;
return rb - ra;
}
interface OllamaTag { name: string } /** Switch the active LLM provider at runtime. Persists to settings file. */
interface OllamaTagsResponse { models: OllamaTag[] } export function setLlmProvider(provider: string, ollamaModel?: string): void {
_settings = { provider, ollamaModel: ollamaModel || _settings.ollamaModel };
/** Probe Ollama for available fo-blog-v* models. Returns [] on any error (non-fatal). */
async function fetchOllamaFoBlogTags(): Promise<string[]> {
try {
const resp = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) return [];
const data = await resp.json() as OllamaTagsResponse;
return (data.models || [])
.map(m => m.name.replace(/:latest$/, ""))
.filter(n => /^fo-blog-v\d+(?:-r\d+)?$/.test(n));
} catch {
return [];
}
}
/**
* Reconcile configured model against Ollama reality.
*
* Always upgrades to the highest available fo-blog-vN BASE tag (no -r suffix).
* This ensures newly-trained versions are picked up automatically within 10 min,
* without needing to delete old Ollama tags or restart the API.
*
* Priority:
* 1. Highest fo-blog-vN base tag Ollama actually serves (auto-upgrade)
* 2. Configured model if no upgrade candidate found
* 3. Static fallback STATIC_FALLBACK_MODEL last resort
*
* Non-blocking: any Ollama failure leaves _settings untouched.
*/
async function reconcileWithOllama(): Promise<void> {
// Skip auto-upgrade when a version is explicitly pinned
if (_settings.pinnedVersion) return;
const configured = _settings.ollamaModel;
if (!configured.startsWith("fo-blog-v")) return; // only manage fo-blog-* lane
const available = await fetchOllamaFoBlogTags();
if (available.length === 0) return;
// Pick the highest base version (no -r suffix) available in Ollama
const sorted = [...available].sort(compareFoBlogVersionsDesc);
const winner = sorted[0];
if (!winner || winner === configured) return; // already on best, or nothing to do
// Only upgrade (never downgrade): winner must have a higher major version
const re = /^fo-blog-v(\d+)(?:-r(\d+))?$/;
const mc = re.exec(configured);
const mw = re.exec(winner);
if (mc && mw) {
const vc = Number.parseInt(mc[1], 10);
const vw = Number.parseInt(mw[1], 10);
if (vw <= vc) return; // winner is not newer — no-op
}
const reason = available.includes(configured)
? `newer version available`
: `"${configured}" no longer in Ollama`;
console.log(`[LLM] auto-upgrade: "${configured}" → "${winner}" (${reason}; candidates: ${sorted.join(", ")})`);
_settings = { ..._settings, ollamaModel: winner };
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ } try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
} console.log(`[LLM] Provider switched → ${provider}${ollamaModel ? ` (${ollamaModel})` : ""}`);
let _settings = loadSettingsRaw();
// Fire-and-forget initial reconciliation. Subsequent refresh runs every DISCOVERY_REFRESH_MS.
void reconcileWithOllama();
setInterval(() => { void reconcileWithOllama(); }, DISCOVERY_REFRESH_MS).unref();
/**
* Switch the active LLM provider at runtime. Persists to settings file.
* Switching provider/model clears any existing pin so auto-upgrade can resume
* on the new provider unless the caller explicitly passes a pinnedVersion.
*/
export function setLlmProvider(provider: string, ollamaModel?: string, pinnedVersion?: string): void {
_settings = {
..._settings, // preserve any fields not explicitly overridden
provider,
ollamaModel: ollamaModel || _settings.ollamaModel,
pinnedVersion: pinnedVersion ?? undefined, // explicit undefined clears pin on provider switch
};
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
console.log(`[LLM] Provider switched → ${provider}${ollamaModel ? ` (${ollamaModel})` : ""}${pinnedVersion ? ` [pinned]` : ""}`);
}
/**
* Pin the active fo-blog version, disabling auto-upgrade.
* The model stays at `version` until explicitly unpinned.
*/
export function pinLlmVersion(version: string): void {
_settings = { ..._settings, ollamaModel: version, pinnedVersion: version };
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
console.log(`[LLM] Version pinned → ${version} (auto-upgrade disabled)`);
}
/**
* Remove the version pin auto-upgrade resumes on next reconcile interval.
* Triggers an immediate reconcile so the highest available version is adopted
* without waiting up to DISCOVERY_REFRESH_MS.
*/
export async function unpinLlmVersion(): Promise<LlmSettings> {
_settings = { ..._settings, pinnedVersion: undefined };
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
console.log("[LLM] Version unpinned — auto-upgrade re-enabled");
await reconcileWithOllama();
return { ..._settings };
} }
/** Returns the currently active provider config. */ /** Returns the currently active provider config. */
export function getLlmProvider(): LlmSettings { return { ..._settings }; } export function getLlmProvider(): LlmSettings { return { ..._settings }; }
/**
* Force an immediate auto-discovery reconciliation against Ollama.
* Returns the active settings after reconcile.
*/
export async function refreshLlmAutoDiscovery(): Promise<LlmSettings> {
await reconcileWithOllama();
return { ..._settings };
}
// Convenience getters used below (re-read on every call for zero-latency switch) // Convenience getters used below (re-read on every call for zero-latency switch)
function provider(): string { return _settings.provider; } function provider(): string { return _settings.provider; }
function llmModel(): string { return _settings.ollamaModel; } function llmModel(): string { return _settings.ollamaModel; }

View File

@ -1,144 +0,0 @@
/**
* Customer API Key Management /api/api-keys
*
* Manages externally-issued API keys for customer access to the TIP public API.
* Keys are stored as SHA-256 hashes. The actual key is shown ONCE at creation.
*
* Routes:
* POST /api/api-keys Issue a new key (admin-only in prod)
* GET /api/api-keys List keys (filter by email)
* DELETE /api/api-keys/:id Revoke a key
* GET /api/api-keys/stats Usage stats per key
* POST /api/api-keys/validate Validate a key (internal use by middleware)
*/
import { Router, Request, Response } from "express";
import { createHash, randomBytes } from "crypto";
import { pool } from "../db/client";
export const apiKeysRouter = Router();
function hashKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
function generateKey(): { key: string; prefix: string; hash: string } {
const raw = randomBytes(24).toString("base64url");
const key = `tip_${raw}`;
const prefix = key.slice(0, 12);
const hash = hashKey(key);
return { key, prefix, hash };
}
// ── POST /api/api-keys — Issue new key ──────────────────────────────────────
apiKeysRouter.post("/", async (req: Request, res: Response) => {
const { email, label, tier = "free", rate_limit, expires_in_days } = req.body as Record<string, any>;
if (!email || !email.includes("@")) {
return res.status(400).json({ success: false, error: "Valid email required" });
}
if (!label || typeof label !== "string") {
return res.status(400).json({ success: false, error: "label required" });
}
const RATE_LIMITS: Record<string, number> = { free: 100, pro: 1000, enterprise: 10000 };
const resolvedRateLimit = rate_limit ? parseInt(rate_limit) : RATE_LIMITS[tier] || 100;
const expiresAt = expires_in_days
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
try {
const { key, prefix, hash } = generateKey();
const result = await pool.query(
`INSERT INTO api_keys (key_hash, key_prefix, label, email, tier, rate_limit, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id, key_prefix, label, email, tier, rate_limit, active, created_at, expires_at`,
[hash, prefix, label, email.toLowerCase().trim(), tier, resolvedRateLimit, expiresAt]
);
return res.status(201).json({
success: true,
api_key: key, // shown ONCE — client must store it
warning: "Store this key now — it will not be shown again.",
meta: result.rows[0],
});
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/api-keys?email= — List keys ────────────────────────────────────
apiKeysRouter.get("/", async (req: Request, res: Response) => {
const email = String(Array.isArray(req.query.email) ? req.query.email[0] ?? "" : req.query.email ?? "").trim().toLowerCase();
try {
const result = await pool.query(
`SELECT id, key_prefix, label, email, tier, rate_limit, active,
last_used_at, usage_count, created_at, expires_at
FROM api_keys
WHERE ($1 = '' OR email = $1)
ORDER BY created_at DESC
LIMIT 100`,
[email]
);
return res.json({ success: true, keys: result.rows });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── DELETE /api/api-keys/:id — Revoke key ───────────────────────────────────
apiKeysRouter.delete("/:id", async (req: Request, res: Response) => {
try {
const result = await pool.query(
`UPDATE api_keys SET active = false WHERE id = $1 RETURNING id, key_prefix`,
[parseInt(String(req.params.id))]
);
if (result.rowCount === 0) return res.status(404).json({ success: false, error: "Key not found" });
return res.json({ success: true, revoked: result.rows[0] });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/api-keys/stats — Usage dashboard ───────────────────────────────
apiKeysRouter.get("/stats", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
tier,
COUNT(*) AS total_keys,
COUNT(*) FILTER (WHERE active) AS active_keys,
SUM(usage_count) AS total_requests,
MAX(last_used_at) AS last_activity
FROM api_keys
GROUP BY tier
ORDER BY total_requests DESC
`);
return res.json({ success: true, stats: result.rows });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── POST /api/api-keys/validate — Validate key (used by auth middleware) ────
apiKeysRouter.post("/validate", async (req: Request, res: Response) => {
const { key } = req.body as { key?: string };
if (!key) return res.status(400).json({ valid: false });
try {
const hash = hashKey(key);
const result = await pool.query(
`UPDATE api_keys
SET last_used_at = NOW(), usage_count = usage_count + 1
WHERE key_hash = $1
AND active = true
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING id, key_prefix, email, tier, rate_limit`,
[hash]
);
if (result.rowCount === 0) return res.json({ valid: false });
return res.json({ valid: true, ...result.rows[0] });
} catch (err) {
return res.status(500).json({ valid: false, error: String(err) });
}
});

View File

@ -10,11 +10,8 @@
* Voice: Senior optical network engineer, not marketing. * Voice: Senior optical network engineer, not marketing.
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import * as pdfParseModule from "pdf-parse";
const pdfParse: (buffer: Buffer) => Promise<{ text: string; numpages: number; info: Record<string, unknown> }> =
(pdfParseModule as any).default ?? (pdfParseModule as any);
import { pool } from "../db/client"; import { pool } from "../db/client";
import { setLlmProvider, getLlmProvider, refreshLlmAutoDiscovery, pinLlmVersion, unpinLlmVersion } from "../llm/client"; import { setLlmProvider, getLlmProvider } from "../llm/client";
/** In-memory pipeline progress tracker — step updates pushed here, polled via GET /api/blog/:id/progress */ /** In-memory pipeline progress tracker — step updates pushed here, polled via GET /api/blog/:id/progress */
const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>(); const pipelineProgress = new Map<string, { step: number; total: number; label: string; pct: number }>();
@ -987,177 +984,6 @@ async function processLlmQueue(): Promise<void> {
if (llmQueue.length > 0) setTimeout(() => processLlmQueue(), 3000); if (llmQueue.length > 0) setTimeout(() => processLlmQueue(), 3000);
} }
/**
* 3-pass pipeline for external content (from-url / from-pdf).
* Grounds entirely in source text never expands from parametric knowledge,
* which prevents the "senior network engineer" persona from drifting to optics.
*/
async function runExternalContentPipeline(
draftId: string,
title: string,
selectedTopic: string,
targetAudience: string,
additionalContext: string,
): Promise<void> {
const {
FO_BLOG_SYSTEM_PROMPT,
STEP8_KILL_AI_TONE,
STEP8c_STYLE_LOCK,
STEP_HEADLINE_GENERATION,
withCalibration,
buildFeedbackContext,
} = await import("../llm/fo-blog-pipeline");
const LLM_WRITE = { temperature: 0.7, maxTokens: 2000, timeoutMs: 480000 };
const LLM_REFINE = { temperature: 0.35, maxTokens: 2000, timeoutMs: 480000 };
// Extract persona-neutral section of FO system prompt (keep writing style, drop optics persona)
const mindsetMarker = "YOUR MINDSET:";
const mindsetStart = FO_BLOG_SYSTEM_PROMPT.indexOf(mindsetMarker);
const writingStyleRules = mindsetStart > -1
? FO_BLOG_SYSTEM_PROMPT.slice(mindsetStart)
: FO_BLOG_SYSTEM_PROMPT;
// Load feedback
let feedbackContext = "";
try {
const fbResult = await pool.query(
`SELECT score_overall, feedback_text, blog_type FROM blog_feedback
WHERE feedback_text IS NOT NULL AND feedback_text != ''
ORDER BY score_overall ASC LIMIT 20`
);
feedbackContext = buildFeedbackContext(fbResult.rows.map(r => ({
score: r.score_overall, feedback_text: r.feedback_text, blog_type: r.blog_type || ""
})));
} catch { /* no feedback yet */ }
// Extract just the PDF/URL text from additionalContext
const pdfStart = additionalContext.indexOf("--- EXTRACTED PDF CONTENT ---");
const urlStart = additionalContext.indexOf("--- EXTRACTED PAGE CONTENT ---");
const contentStart = pdfStart > -1 ? pdfStart : urlStart > -1 ? urlStart : -1;
const sourceText = contentStart > -1
? additionalContext.slice(contentStart).slice(0, 5000)
: additionalContext.slice(0, 5000);
const externalSysPrompt = withCalibration(
`You are a senior IT infrastructure engineer and technical writer with 20+ years of experience.\n` +
`You write practical articles for IT architects, infrastructure managers, and decision-makers.\n\n` +
`ABSOLUTE RULE: You write ONLY about what the source document says. ` +
`Do NOT add optical transceivers, fiber optics, 400G, or compatible optics content ` +
`unless the source document explicitly covers it. ` +
`Your only source of facts is the document provided.\n\n` +
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
`WRITING STYLE (apply to everything you write):\n` +
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
writingStyleRules + feedbackContext
);
console.log(`Blog External Pipeline: Starting for ${draftId} — "${title}"`);
setProgress(draftId, 1, "Step 1/5: Extract source facts");
// ─── Pass 1: Extract key facts from source document ───────────────────────
const extractPrompt =
`Extract the key factual content from the source document below.\n\n` +
`Return:\n` +
`- The core problem or challenge the document describes (2-3 sentences)\n` +
`- 5-7 specific facts, findings, or insights stated in the document\n` +
`- The main conclusion or practical recommendation\n` +
`- Who this affects and why it matters to them\n\n` +
`Do NOT add information not present in the document. Do NOT interpret or expand.\n` +
`Return only what the document actually says.\n\n` +
sourceText;
await generate("You are a precise document analyst.", extractPrompt, { temperature: 0.2, maxTokens: 800, timeoutMs: 120000 }).catch(() => {});
const extractResult = await generate("You are a precise document analyst.", extractPrompt, { temperature: 0.2, maxTokens: 800, timeoutMs: 120000 });
setProgress(draftId, 2, "Step 2/5: Write article draft");
// ─── Pass 2: Write the full article from extracted facts ──────────────────
const draftPrompt =
`Write a blog article titled "${title}".\n\n` +
`Use ONLY the facts below as your source material. Do not add any facts not listed here.\n\n` +
`SOURCE FACTS:\n${extractResult.text}\n\n` +
`ARTICLE REQUIREMENTS:\n` +
`- 600-900 words\n` +
`- Continuous narrative prose — no section headers, no bullet lists in the body\n` +
`- First-person engineering voice ("I've seen this happen...", "The problem is...")\n` +
`- Start with a specific operational scenario, not a general statement\n` +
`- Explain consequences for real infrastructure decisions\n` +
`- End with a concrete implication or call to action, not a summary\n` +
`- Apply all writing style rules from your system prompt\n\n` +
`Topic: "${title}"\n` +
`Audience: ${targetAudience} engineers and infrastructure decision-makers`;
const draftResult = await generate(externalSysPrompt, draftPrompt, LLM_WRITE);
console.log(` Draft: ${draftResult.text.split(/\s+/).length} words`);
setProgress(draftId, 3, "Step 3/5: Kill AI tone");
// ─── Pass 3: Kill AI tone ─────────────────────────────────────────────────
const step8 = await generate(externalSysPrompt,
STEP8_KILL_AI_TONE.replace("{{ARTICLE}}", draftResult.text),
LLM_REFINE
);
setProgress(draftId, 4, "Step 4/5: Style lock");
// Skip STEP8b_REDUCTION for external content — it targets 1,200-2,000 words
// with "DO NOT go below 1,000 words" which conflicts with thin source material.
// Just apply style lock for readability polish.
const step8c = await generate(externalSysPrompt,
STEP8c_STYLE_LOCK.replace("{{ARTICLE}}", step8.text),
LLM_REFINE
);
setProgress(draftId, 5, "Step 5/5: LinkedIn + headline");
// ─── LinkedIn post — topic-neutral version (no optics example) ────────────
const externalLinkedInPrompt =
`Write a LinkedIn post for this article.\n\n` +
`FORMAT:\n` +
`Line 1-2: HOOK — a reframe or uncomfortable truth from the article. NOT an announcement.\n\n` +
`3-5 SHORT BEATS — each beat is 1-3 lines. One insight per beat. No bullet markers.\n\n` +
`Last line before hashtags: "Full breakdown in the blog — link in first comment."\n\n` +
`HASHTAGS (last line): 3-4 relevant hashtags based on the article topic. Include #Flexoptix.\n` +
` Pick hashtags that match what the article is actually about — NOT #OpticalNetworking unless the article covers optics.\n\n` +
`RULES:\n` +
`- No emojis\n` +
`- No "I'm thrilled to share" or "Excited to announce"\n` +
`- Engineer voice — specific, blunt, useful\n` +
`- Maximum 2,800 characters\n` +
`- Return ONLY the post text. No commentary.\n\n` +
`Article:\n${step8c.text}`;
const linkedInResult = await generate(externalSysPrompt,
externalLinkedInPrompt,
{ ...LLM_REFINE, maxTokens: 600 }
).catch(() => ({ text: "" }));
// ─── Headline ──────────────────────────────────────────────────────────────
const headlineResult = await generate(externalSysPrompt,
STEP_HEADLINE_GENERATION.replace("{{ARTICLE}}", step8c.text),
{ temperature: 0.5, maxTokens: 80, timeoutMs: 60000 }
).catch(() => ({ text: title }));
const finalTitle = headlineResult.text.trim().replace(/^["']|["']$/g, "").replace(/\n.*$/s, "").trim() || title;
const wordCount = step8c.text.split(/\s+/).length;
await pool.query(
`UPDATE blog_drafts SET
title = $1,
draft_content = $2,
linkedin_post = $3,
word_count = $4,
status = 'draft',
updated_at = NOW()
WHERE id = $5`,
[finalTitle, step8c.text, linkedInResult.text || null, wordCount, draftId]
);
clearProgress(draftId);
console.log(`Blog External Pipeline: ${draftId} complete — ${wordCount} words, title: "${finalTitle}"`);
}
/** Run 10-Step Flexoptix Style LLM Pipeline and update draft in-place */ /** Run 10-Step Flexoptix Style LLM Pipeline and update draft in-place */
async function runLlmPipeline( async function runLlmPipeline(
draftId: string, draftId: string,
@ -1167,12 +993,6 @@ async function runLlmPipeline(
data: Awaited<ReturnType<typeof gatherBlogData>>, data: Awaited<ReturnType<typeof gatherBlogData>>,
additionalContext?: string, additionalContext?: string,
): Promise<void> { ): Promise<void> {
// External content (from-url / from-pdf) uses a grounded 5-pass pipeline
// that never expands from parametric knowledge — prevents optics topic drift
if (additionalContext?.startsWith("⚠️ TOPIC LOCK") && additionalContext.length > 200) {
return runExternalContentPipeline(draftId, title, selectedTopic, targetAudience, additionalContext);
}
// Lazy-load the new FO pipeline // Lazy-load the new FO pipeline
const { const {
FO_BLOG_SYSTEM_PROMPT, FO_BLOG_SYSTEM_PROMPT,
@ -1220,48 +1040,7 @@ async function runLlmPipeline(
}))); })));
} catch { /* no feedback yet, that's fine */ } } catch { /* no feedback yet, that's fine */ }
// For external content (from-url / from-pdf), the Flexoptix optical-networking persona const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext);
// must be replaced — otherwise every article drifts back to 400G transceivers regardless
// of what the TOPIC LOCK says. Strip the Flexoptix mandate and inject a generic persona.
const isExternalContent = additionalContext?.startsWith("⚠️ TOPIC LOCK");
const extractedTopicName = isExternalContent
? (additionalContext?.match(/TOPIC LOCK[^"]*"([^"]+)"/) || [])[1] || title
: title;
// Build the section of FO_BLOG_SYSTEM_PROMPT that's topic-neutral (writing style rules only).
// The Flexoptix persona block ends at the first ════ separator after line 65 ("YOUR MINDSET").
const mindsetMarker = "YOUR MINDSET:";
const mindsetStart = FO_BLOG_SYSTEM_PROMPT.indexOf(mindsetMarker);
const writingStyleRules = mindsetStart > -1
? FO_BLOG_SYSTEM_PROMPT.slice(mindsetStart)
: FO_BLOG_SYSTEM_PROMPT;
const externalSystemPrompt = `\
EXTERNAL CONTENT MODE these rules override ALL defaults below
THIS ARTICLE IS ABOUT: "${extractedTopicName}"
You are a senior IT infrastructure engineer and technical writer.
Your readers are IT architects, infrastructure managers, and decision-makers.
ABSOLUTE RULE: Write ONLY about "${extractedTopicName}".
Do NOT write about optical transceivers, fiber optics, 400G, DR4, compatible optics,
Flexoptix products, or any networking hardware UNLESS the source document below
explicitly covers that topic. The source document is your sole editorial mandate.
The Flexoptix brand rules and compatible-optics framing in this prompt DO NOT APPLY
to this article. This is a general IT/infrastructure piece, not an optics blog post.
WRITING STYLE (applies to all articles keep these):
${writingStyleRules}`;
const systemPrompt = isExternalContent
? withCalibration(externalSystemPrompt + feedbackContext)
: withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext);
// Warmup // Warmup
await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {}); await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
@ -1321,23 +1100,11 @@ ${writingStyleRules}`;
// ═══ STEP 1: Topic Expansion ═══ // ═══ STEP 1: Topic Expansion ═══
console.log(" Step 1/10: Topic Expansion..."); console.log(" Step 1/10: Topic Expansion...");
setProgress(draftId, 1, "Step 1/10: Topic Expansion"); setProgress(draftId, 1, "Step 1/10: Topic Expansion");
// For external content: inject a hard topic anchor before the standard prompt so the
// LLM cannot drift back to optical networking when expanding the topic in Step 1.
const step1TopicPrefix = isExternalContent
? `HARD TOPIC LOCK: This article is about "${extractedTopicName}". ` +
`It is NOT about optical transceivers, fiber optics, 400G migrations, or compatible optics. ` +
`Your expansion below must stay strictly within the topic stated above.\n\n`
: "";
const step1 = await generate(systemPrompt, const step1 = await generate(systemPrompt,
step1TopicPrefix + STEP1_TOPIC_EXPANSION STEP1_TOPIC_EXPANSION
.replace("{{TOPIC}}", isExternalContent ? extractedTopicName : title) .replace("{{TOPIC}}", title)
.replace("{{ADDITIONAL_CONTEXT}}", additionalContext .replace("{{ADDITIONAL_CONTEXT}}", additionalContext
? `\n\n---\nBACKGROUND REFERENCE (use as factual direction ONLY — do not copy verbatim):\n${additionalContext.slice(0, 4000)}\n\n` + ? `\n\n---\nBACKGROUND REFERENCE (editorial context — use as factual direction ONLY):\n${additionalContext}\n\nCRITICAL: Do NOT copy any phrase, sentence, or wording from the above into the article or any step output. It is context for your understanding, not source material.`
(isExternalContent
? `REMINDER: Write about "${extractedTopicName}" — NOT optical networking. The background above is your source material.`
: `CRITICAL: Do NOT copy any phrase, sentence, or wording from the above into the article or any step output.`)
: ""), : ""),
LLM_OPTS LLM_OPTS
); );
@ -1580,12 +1347,10 @@ ${writingStyleRules}`;
const finalIssues = validateArticle(draftContent); const finalIssues = validateArticle(draftContent);
// Update the draft in DB (title updated to generated headline if available) // Update the draft in DB (title updated to generated headline if available)
const pipelineModel = getLlmProvider();
const pipelineGeneratedBy = `fo-blog-engine-${pipelineModel.ollamaModel || pipelineModel.provider || "llm"}`;
await pool.query( await pool.query(
`UPDATE blog_drafts `UPDATE blog_drafts
SET title = $9, draft_content = $1, word_count = $2, SET title = $9, draft_content = $1, word_count = $2,
generated_by = $10, generated_by = 'fo-blog-engine-v7',
pipeline_version = 'v7', pipeline_version = 'v7',
pipeline_steps_completed = $3, pipeline_steps_completed = $3,
auto_qa_score = $4, auto_qa_score = $4,
@ -1612,7 +1377,6 @@ ${writingStyleRules}`;
linkedinCharCount, linkedinCharCount,
draftId, draftId,
finalTitle, finalTitle,
pipelineGeneratedBy,
], ],
); );
@ -1764,425 +1528,6 @@ blogRouter.post("/generate", async (req: Request, res: Response) => {
} }
}); });
/** Paywall signal patterns in page HTML */
const PAYWALL_PATTERNS = [
/class=["'][^"']*paywall[^"']*["']/i,
/id=["'][^"']*paywall[^"']*["']/i,
/"paywall"\s*:/i,
/data-paywall/i,
/subscribe (to|now) (read|access|continue)/i,
/sign[- ]?in to (read|continue|access)/i,
/log[- ]?in to (read|continue|access)/i,
/create (a free )?account to (read|continue|access)/i,
/register (now )?to (read|continue|access)/i,
/this (article|content|paper) is (for|available to) (subscribers?|members?)/i,
/premium (content|article|paper)/i,
/access (this|the) (full )?(article|content|paper)/i,
/metered[- ]?content/i,
/subscriber[- ]?only/i,
/intel(ligence)?\.theregister\.com\/paper/i,
];
/** Fetch a URL and extract readable text content for use as LLM context.
* Returns spaDetected=true when extracted body text is thin (< 300 chars).
* Returns paywallDetected=true when login/subscription wall signals are found.
*/
async function fetchUrlContent(rawUrl: string): Promise<{
pageTitle: string;
text: string;
spaDetected: boolean;
paywallDetected: boolean;
metaDesc: string;
}> {
const response = await fetch(rawUrl, {
headers: { "User-Agent": "TIPBot/1.0 blog-from-url (+https://tip.flexoptix.net)" },
signal: AbortSignal.timeout(20000),
redirect: "follow",
});
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("text/html") && !contentType.includes("text/plain") && !contentType.includes("application/xhtml")) {
throw new Error(`Unsupported content type: ${contentType.split(";")[0]}`);
}
const html = await response.text();
// --- Extract OG / meta tags for SPA fallback ---
const decodeEntities = (s: string) =>
s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&#\d+;/g, "").replace(/&[a-z]+;/gi, " ").trim();
const ogTitle =
html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']{1,200})["']/i)?.[1] ||
html.match(/<meta[^>]+content=["']([^"']{1,200})["'][^>]+property=["']og:title["']/i)?.[1] || "";
const ogDesc =
html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{1,500})["']/i)?.[1] ||
html.match(/<meta[^>]+content=["']([^"']{1,500})["'][^>]+property=["']og:description["']/i)?.[1] ||
html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']{1,500})["']/i)?.[1] ||
html.match(/<meta[^>]+content=["']([^"']{1,500})["'][^>]+name=["']description["']/i)?.[1] || "";
const ogSiteName =
html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']{1,100})["']/i)?.[1] ||
html.match(/<meta[^>]+content=["']([^"']{1,100})["'][^>]+property=["']og:site_name["']/i)?.[1] || "";
// Extract page title: prefer OG title, then <title>, then <h1>
const titleMatch = html.match(/<title[^>]*>([^<]{1,200})<\/title>/i);
const h1Match = html.match(/<h1[^>]*>([^<]{1,150})<\/h1>/i);
const pageTitle = decodeEntities(
ogTitle || titleMatch?.[1] || h1Match?.[1] || ""
);
const metaDesc = decodeEntities(ogDesc);
// Strip scripts, styles, SVG, navigation boilerplate
let text = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<svg[\s\S]*?<\/svg>/gi, " ")
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
.replace(/<header[\s\S]*?<\/header>/gi, " ")
.replace(/<aside[\s\S]*?<\/aside>/gi, " ")
.replace(/<form[\s\S]*?<\/form>/gi, " ")
// Block elements → newlines
.replace(/<\/?(p|div|section|article|h[1-6]|li|br|hr|tr|td|th|blockquote|pre)[^>]*>/gi, "\n")
// Strip all remaining tags
.replace(/<[^>]{0,500}>/g, " ")
// Decode common entities
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ")
.replace(/&[a-z]+;/gi, " ")
// Collapse whitespace
.split("\n").map(l => l.trim()).filter(l => l.length > 30).join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
// Limit to ~5000 chars — enough for LLM context, not so much it blows the prompt
if (text.length > 5000) {
text = text.slice(0, 5000) + "\n[… content truncated for LLM context …]";
}
// Detect SPA: very little body text means JS renders the real content
const spaDetected = text.length < 300;
// Detect paywall: check raw HTML for subscription/login wall signals
const paywallDetected = PAYWALL_PATTERNS.some(p => p.test(html.slice(0, 20000)));
// When SPA/paywall detected, enrich text with what we could extract from meta tags
if ((spaDetected || paywallDetected) && (metaDesc || ogSiteName)) {
const parts: string[] = [];
if (ogSiteName) parts.push(`Site: ${ogSiteName}`);
if (pageTitle) parts.push(`Title: ${pageTitle}`);
if (metaDesc) parts.push(`Description: ${metaDesc}`);
text = parts.join("\n");
}
return { pageTitle, text, spaDetected, paywallDetected, metaDesc };
}
// POST /api/blog/from-url — Fetch URL, extract content, generate a blog from it
blogRouter.post("/from-url", async (req: Request, res: Response) => {
const { url, topic } = req.body as { url?: string; topic?: string };
if (!url) {
res.status(400).json({ success: false, error: "url ist erforderlich" });
return;
}
// Validate URL — must be http/https
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
if (!["http:", "https:"].includes(parsedUrl.protocol)) throw new Error("bad protocol");
} catch {
res.status(400).json({ success: false, error: "Ungültige URL — muss http:// oder https:// beginnen" });
return;
}
const selectedTopic = topic || "technology_deep_dive";
const templates = BLOG_TEMPLATES[selectedTopic];
if (!templates) {
res.status(400).json({ success: false, error: `Ungültiger Blog-Typ. Gültig: ${Object.keys(BLOG_TEMPLATES).join(", ")}` });
return;
}
try {
// Fetch page content server-side (no CORS issues)
const { pageTitle, text: extractedText, spaDetected, paywallDetected, metaDesc } = await fetchUrlContent(url);
console.log(
`Blog from-url: fetched "${pageTitle}" from ${parsedUrl.hostname} ` +
`(${extractedText.length} chars${spaDetected ? ", SPA" : ""}${paywallDetected ? ", PAYWALL" : ""})`
);
// Paywall or inaccessible content — return signal so client can prompt for PDF upload.
// Also catches meta-refresh redirects and other "content gatekeeping" patterns where
// the fetched HTML is tiny and yields no usable text or metadata.
const contentBlocked =
paywallDetected ||
(spaDetected && extractedText.length < 50 && !metaDesc && !pageTitle);
if (contentBlocked) {
res.json({
success: false,
paywall_detected: true,
page_title: pageTitle,
meta_desc: metaDesc,
source_url: url,
topic: selectedTopic,
error: paywallDetected
? "Paywall erkannt — bitte PDF hochladen"
: "Seite nicht zugänglich — bitte PDF hochladen",
});
return;
}
// Build a rich additional_context from the URL content.
// When a SPA is detected (JS-rendered), body text is a shell — we rely on meta tags instead.
const spaWarning = spaDetected
? `\nNOTE: This URL is a JavaScript Single Page Application. Only meta/OG data was available ` +
`server-side — the LLM should infer topic from the site name, title, and description above. ` +
`Do NOT default to optical networking topics unless the page is actually about that.`
: "";
const additionalContext =
`⚠️ TOPIC LOCK — THIS BLOG IS ABOUT: "${pageTitle || parsedUrl.hostname}"\n` +
`The article MUST cover this topic. Do NOT write about optical transceivers, 400G, fiber optics, or DOM readings unless the source article explicitly covers them.\n\n` +
`SOURCE URL: ${url}\n` +
`PAGE TITLE: ${pageTitle}\n` +
`HOSTNAME: ${parsedUrl.hostname}\n` +
(metaDesc ? `META DESCRIPTION: ${metaDesc}\n` : "") +
`\n--- EXTRACTED PAGE CONTENT ---\n` +
`${extractedText || "(No body text extractable — JavaScript-rendered SPA)"}\n` +
`--- END PAGE CONTENT ---\n` +
spaWarning +
`\n\nIMPORTANT: Use this content as factual background and editorial direction. ` +
`The blog MUST be about the topic described above, NOT about optical transceivers or fiber unless explicitly relevant. ` +
`Do NOT copy sentences verbatim. Write a Flexoptix-voice blog article using these facts and insights.`;
const title = pageTitle || parsedUrl.hostname;
const template = templates[Math.floor(Math.random() * templates.length)];
// For from-url flow: ALWAYS use empty data — no transceiver product injection.
// The URL content IS the data. Injecting transceiver products would cause the
// fine-tuned model to ignore the source article and write a generic 400G post.
const data = { products: [] as any[], news: [] as any[], faq: [] as any[], troubleshooting: [] as any[] };
// Use a minimal placeholder draft — generateTemplateDraft produces transceiver-specific
// skeleton content (NOC scenarios, DOM readings) that pollutes the LLM context.
const date = new Date().toISOString().split("T")[0];
const draftContent =
`# ${title}\n\n` +
`*Generated from URL: ${url} on ${date}*\n\n` +
`> **Status**: Pending LLM enhancement — source article loaded.\n\n` +
`**Source**: ${url}\n` +
(metaDesc ? `**Summary**: ${metaDesc}\n` : "");
const wordCount = draftContent.split(/\s+/).length;
const initialIssues: string[] = [];
const activeModel = getLlmProvider();
const generatedBy = `tip-blog-from-url-${activeModel.ollamaModel || activeModel.provider || "llm"}`;
const result = await pool.query(
`INSERT INTO blog_drafts (title, topic, target_audience, outline, draft_content, data_sources, status, generated_by, word_count, seo_keywords)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7, $8, $9)
RETURNING id, created_at`,
[
title,
selectedTopic,
template.target_audience,
JSON.stringify({ generation_method: "from-url", source_url: url, spa_detected: spaDetected, quality_issues: initialIssues }),
draftContent,
JSON.stringify({ source_url: url, extracted_chars: extractedText.length, spa_detected: spaDetected, products: data.products.length, news: data.news.length }),
generatedBy,
wordCount,
template.seo_keywords,
],
);
const draftId = result.rows[0].id;
// Launch LLM pipeline with URL content as context
const health = await checkHealth().catch(() => ({ ok: false, model: "", error: "unreachable" }));
let llmStarted = false;
if (health.ok) {
llmStarted = true;
enqueueLlmPipeline(draftId, title, selectedTopic, template.target_audience, data, additionalContext).catch((err) => {
console.error(`Blog from-url LLM pipeline error: ${(err as Error).message}`);
});
}
res.json({
success: true,
source_url: url,
page_title: pageTitle,
extracted_chars: extractedText.length,
spa_detected: spaDetected,
draft: {
id: draftId,
title,
topic: selectedTopic,
target_audience: template.target_audience,
word_count: wordCount,
generation_method: "from-url",
llm_enhancing: llmStarted,
created_at: result.rows[0].created_at,
},
});
} catch (err) {
const msg = (err as Error).message;
console.error(`Blog from-url error for ${url}: ${msg}`);
res.status(500).json({
success: false,
error: `URL konnte nicht verarbeitet werden: ${msg}`,
});
}
});
// POST /api/blog/from-pdf — Upload a PDF, extract text, generate blog from it
// Accepts JSON body: { pdf_base64: string, filename: string, url?: string, topic?: string, page_title?: string }
// Using base64 JSON instead of multipart to avoid Cloudflare WAF blocking binary uploads.
blogRouter.post("/from-pdf", async (req: Request, res: Response) => {
const { pdf_base64, filename, url, topic, page_title } = req.body as {
pdf_base64?: string;
filename?: string;
url?: string;
topic?: string;
page_title?: string;
};
if (!pdf_base64) {
res.status(400).json({ success: false, error: "Keine PDF-Daten empfangen (pdf_base64 fehlt)" });
return;
}
// Validate base64 + decode
let fileBuffer: Buffer;
let fileSize: number;
try {
fileBuffer = Buffer.from(pdf_base64, "base64");
fileSize = fileBuffer.length;
if (fileSize < 100) throw new Error("Datei zu klein");
if (fileSize > 20 * 1024 * 1024) throw new Error("Datei zu groß (max 20 MB)");
} catch (err) {
res.status(400).json({ success: false, error: `Ungültige PDF-Daten: ${(err as Error).message}` });
return;
}
const originalName = filename || "upload.pdf";
const selectedTopic = topic || "technology_deep_dive";
const templates = BLOG_TEMPLATES[selectedTopic];
if (!templates) {
res.status(400).json({ success: false, error: `Ungültiger Blog-Typ. Gültig: ${Object.keys(BLOG_TEMPLATES).join(", ")}` });
return;
}
try {
// Extract text from PDF
const pdfData = await pdfParse(fileBuffer);
let extractedText = pdfData.text
.split("\n").map((l: string) => l.trim()).filter((l: string) => l.length > 20).join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (extractedText.length < 100) {
res.status(422).json({ success: false, error: "PDF enthält zu wenig lesbaren Text (ggf. gescannt/bildbasiert)" });
return;
}
// Limit to ~6000 chars for LLM context
if (extractedText.length > 6000) {
extractedText = extractedText.slice(0, 6000) + "\n[… PDF content truncated for LLM context …]";
}
const title: string = page_title || (typeof pdfData.info?.Title === "string" ? pdfData.info.Title : null) || originalName.replace(/\.pdf$/i, "") || "Artikel aus PDF";
console.log(`Blog from-pdf: "${title}" — ${extractedText.length} chars from ${originalName} (${(fileSize / 1024).toFixed(0)} KB)`);
const additionalContext =
`⚠️ TOPIC LOCK — THIS BLOG IS ABOUT: "${title}"\n` +
`The article MUST cover this topic. Do NOT write about optical transceivers, 400G, fiber optics, or DOM readings unless the source document explicitly covers them.\n\n` +
(url ? `SOURCE URL: ${url}\n` : "") +
`SOURCE FILE: ${originalName}\n` +
`PAGE TITLE: ${title}\n` +
`\n--- EXTRACTED PDF CONTENT ---\n` +
`${extractedText}\n` +
`--- END PDF CONTENT ---\n\n` +
`IMPORTANT: Use this content as factual background and editorial direction. ` +
`The blog MUST be about the topic described above. ` +
`Do NOT copy sentences verbatim. Write a Flexoptix-voice blog article using these facts and insights.`;
const template = templates[Math.floor(Math.random() * templates.length)];
const data = { products: [] as any[], news: [] as any[], faq: [] as any[], troubleshooting: [] as any[] };
const date = new Date().toISOString().split("T")[0];
const draftContent =
`# ${title}\n\n` +
`*Generated from PDF: ${originalName} on ${date}*\n\n` +
`> **Status**: Pending LLM enhancement — PDF content loaded.\n\n` +
(url ? `**Source URL**: ${url}\n` : "") +
`**Source file**: ${originalName} (${(fileSize / 1024).toFixed(0)} KB, ${pdfData.numpages} pages)\n`;
const wordCount = draftContent.split(/\s+/).length;
const activeModel = getLlmProvider();
const generatedBy = `tip-blog-from-pdf-${activeModel.ollamaModel || activeModel.provider || "llm"}`;
const result = await pool.query(
`INSERT INTO blog_drafts (title, topic, target_audience, outline, draft_content, data_sources, status, generated_by, word_count, seo_keywords)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7, $8, $9)
RETURNING id, created_at`,
[
title,
selectedTopic,
template.target_audience,
JSON.stringify({ generation_method: "from-pdf", source_url: url || null, source_file: originalName, pdf_pages: pdfData.numpages }),
draftContent,
JSON.stringify({ source_url: url || null, source_file: originalName, extracted_chars: extractedText.length, pdf_pages: pdfData.numpages }),
generatedBy,
wordCount,
template.seo_keywords,
],
);
const draftId = result.rows[0].id;
const health = await checkHealth().catch(() => ({ ok: false, model: "", error: "unreachable" }));
let llmStarted = false;
if (health.ok) {
llmStarted = true;
enqueueLlmPipeline(draftId, title, selectedTopic, template.target_audience, data, additionalContext).catch((err) => {
console.error(`Blog from-pdf LLM pipeline error: ${(err as Error).message}`);
});
}
res.json({
success: true,
source_file: originalName,
source_url: url || null,
page_title: title,
extracted_chars: extractedText.length,
pdf_pages: pdfData.numpages,
draft: {
id: draftId,
title,
topic: selectedTopic,
target_audience: template.target_audience,
word_count: wordCount,
generation_method: "from-pdf",
llm_enhancing: llmStarted,
created_at: result.rows[0].created_at,
},
});
} catch (err) {
const msg = (err as Error).message;
console.error(`Blog from-pdf error: ${msg}`);
res.status(500).json({ success: false, error: `PDF konnte nicht verarbeitet werden: ${msg}` });
}
});
// GET /api/blog — List all drafts // GET /api/blog — List all drafts
blogRouter.get("/", async (_req: Request, res: Response) => { blogRouter.get("/", async (_req: Request, res: Response) => {
try { try {
@ -2212,113 +1557,6 @@ blogRouter.post("/llm/reset-queue", (_req: Request, res: Response) => {
res.json({ success: true, message: "Ollama queue reset — stuck requests cleared" }); res.json({ success: true, message: "Ollama queue reset — stuck requests cleared" });
}); });
// GET /api/blog/llm/model-info — Training metadata for the active fo-blog model
// Returns Ollama model details + training manifest stats (pairs, eval, base model, revision, etc.)
blogRouter.get("/llm/model-info", async (_req: Request, res: Response) => {
try {
const settings = getLlmProvider();
const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
// Only meaningful for fo-blog Ollama models
const modelName = settings.ollamaModel || "";
const isFoBlog = /^fo-blog-v\d+/.test(modelName);
// 1. Ollama /api/show — model metadata
let ollamaInfo: Record<string, unknown> | null = null;
if (settings.provider === "ollama" && modelName) {
try {
const r = await fetch(`${ollamaUrl}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(6000),
});
if (r.ok) ollamaInfo = (await r.json()) as Record<string, unknown>;
} catch { /* non-fatal */ }
}
// 2. Ollama /api/tags — count available fo-blog-vX revisions
let availableVersions: string[] = [];
try {
const r = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
if (r.ok) {
const data = (await r.json()) as { models?: { name: string; modified_at?: string }[] };
availableVersions = (data.models || [])
.map((m) => m.name)
.filter((n) => /^fo-blog-v\d+(?:-r\d+)?:/.test(n));
}
} catch { /* non-fatal */ }
// 3. Training manifest on disk (training-data/runpod/blog_llm/manifest.json)
let manifest: Record<string, unknown> | null = null;
try {
const { readFileSync } = await import("fs");
const { resolve } = await import("path");
const manifestPath = resolve(process.cwd(), "training-data/runpod/blog_llm/manifest.json");
manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
} catch { /* manifest may not exist on this deployment */ }
// Extract structured fields from Ollama response
const details = (ollamaInfo?.details ?? {}) as Record<string, unknown>;
const modelInfo = (ollamaInfo?.model_info ?? {}) as Record<string, unknown>;
const params = (ollamaInfo?.parameters ?? "") as string;
// Parse revision from parent_model e.g. "fo-blog-v13-r16:latest" → r16
const parentModel = (details?.parent_model ?? "") as string;
const revMatch = parentModel.match(/-r(\d+)/);
const revision = revMatch ? `r${revMatch[1]}` : null;
// Parse context length from parameters string
const ctxMatch = params.match(/num_ctx\s+(\d+)/);
const contextLength = ctxMatch ? Number.parseInt(ctxMatch[1], 10) : null;
// Count major versions (fo-blog-vX:latest, not revisions like fo-blog-vX-rY:latest)
const majorVersions = availableVersions.filter((n) => !/v\d+-r\d+:/.test(n));
const allRevisions = availableVersions.filter((n) => /v\d+-r\d+:/.test(n));
res.json({
success: true,
provider: settings.provider,
model: modelName,
is_fo_blog: isFoBlog,
revision,
parent_model: parentModel || null,
trained_at: (ollamaInfo?.modified_at ?? null) as string | null,
quantization: (details?.quantization_level ?? null) as string | null,
parameter_size: (details?.parameter_size ?? null) as string | null,
base_model: (modelInfo?.["general.basename"] ?? null) as string | null,
finetune_id: (modelInfo?.["general.finetune"] ?? null) as string | null,
parameter_count: (modelInfo?.["general.parameter_count"] ?? null) as number | null,
context_length: contextLength ?? ((modelInfo?.["qwen2.context_length"] ?? null) as number | null),
temperature: Number.parseFloat(params.match(/temperature\s+([\d.]+)/)?.[1] ?? "NaN") || null,
// Training data stats from manifest
training_pairs: (manifest?.training_pairs ?? null) as number | null,
train_pairs: (manifest?.train_pairs ?? null) as number | null,
eval_pairs: (manifest?.eval_pairs ?? null) as number | null,
raw_pairs: (manifest?.raw_pairs ?? null) as number | null,
// Version availability
major_versions_available: majorVersions.length,
total_revisions_available: allRevisions.length,
available_versions: majorVersions,
// Pin status
pinned_version: settings.pinnedVersion ?? null,
});
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// POST /api/blog/llm/refresh-discovery — Force auto-discovery to pick up newly-trained fo-blog-v* versions
// Useful right after Magatama adopts a new fo-blog-vN model. Otherwise runs every 10 min by itself.
blogRouter.post("/llm/refresh-discovery", async (_req: Request, res: Response) => {
try {
const active = await refreshLlmAutoDiscovery();
res.json({ success: true, active, message: `Auto-discovery refreshed. Active: ${active.provider}${active.ollamaModel ? ` (${active.ollamaModel})` : ""}` });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// POST /api/blog/llm/switch — Switch active LLM provider at runtime (no restart needed) // POST /api/blog/llm/switch — Switch active LLM provider at runtime (no restart needed)
// Body: { provider: "claude-code" | "anthropic" | "ollama", model?: "fo-blog-v10" | "qwen2.5:14b" | ... } // Body: { provider: "claude-code" | "anthropic" | "ollama", model?: "fo-blog-v10" | "qwen2.5:14b" | ... }
blogRouter.post("/llm/switch", (req: Request, res: Response) => { blogRouter.post("/llm/switch", (req: Request, res: Response) => {
@ -2346,45 +1584,6 @@ blogRouter.post("/llm/switch", (req: Request, res: Response) => {
}); });
}); });
// POST /api/blog/llm/pin — Pin a specific fo-blog-vN model, disabling auto-upgrade
// Body: { version: "fo-blog-v13" } — omit version to pin the current model
blogRouter.post("/llm/pin", (req: Request, res: Response) => {
const { version } = req.body as { version?: string };
const current = getLlmProvider();
const target = version || current.ollamaModel;
if (!target.startsWith("fo-blog-v")) {
res.status(400).json({ success: false, error: "Only fo-blog-v* models can be pinned" });
return;
}
pinLlmVersion(target);
const next = getLlmProvider();
console.log(`[blog/llm/pin] pinned to ${target}`);
res.json({
success: true,
pinned: target,
active: { provider: next.provider, model: next.ollamaModel, pinnedVersion: next.pinnedVersion },
message: `Pinned to ${target} — auto-upgrade disabled`,
});
});
// POST /api/blog/llm/unpin — Remove version pin, re-enable auto-upgrade
// Immediately reconciles against Ollama so the highest available version is adopted.
blogRouter.post("/llm/unpin", async (_req: Request, res: Response) => {
try {
const active = await unpinLlmVersion();
console.log(`[blog/llm/unpin] unpinned, active → ${active.ollamaModel}`);
res.json({
success: true,
active: { provider: active.provider, model: active.ollamaModel },
message: `Unpinned — auto-upgrade re-enabled. Active: ${active.ollamaModel}`,
});
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// GET /api/blog/:id — Get a specific draft with full content // GET /api/blog/:id — Get a specific draft with full content
// GET /api/blog/:id/progress — Real-time pipeline step progress (in-memory) // GET /api/blog/:id/progress — Real-time pipeline step progress (in-memory)
blogRouter.get("/:id/progress", (req: Request, res: Response) => { blogRouter.get("/:id/progress", (req: Request, res: Response) => {
@ -2596,41 +1795,3 @@ blogRouter.delete("/:id", async (req: Request, res: Response) => {
res.status(500).json({ success: false, error: (err as Error).message }); res.status(500).json({ success: false, error: (err as Error).message });
} }
}); });
// GET /api/blog/linkedin/history — LinkedIn distribution log
blogRouter.get("/linkedin/history", async (_req: Request, res: Response) => {
try {
const result = await pool.query(
`SELECT id, ghost_post_id, ghost_slug, ghost_url, title, state, teaser,
linkedin_urn, error_message, attempt_count, created_at, posted_at
FROM blog_linkedin_distribution
ORDER BY created_at DESC
LIMIT 50`
);
// Also get current DRY_RUN status from env (read from distributor ecosystem config)
let dryRun = true;
try {
const { execSync } = await import("child_process");
const out = execSync(
"cat /opt/linkedin-distributor/ecosystem.config.cjs 2>/dev/null | grep DRY_RUN | head -1",
{ timeout: 3000 }
).toString();
dryRun = !out.includes("'false'") && !out.includes('"false"');
} catch { /* keep default true */ }
res.json({
success: true,
dry_run: dryRun,
history: result.rows,
total: result.rows.length,
stats: {
posted: result.rows.filter(r => r.state === "posted").length,
dry_run: result.rows.filter(r => r.state === "dry_run").length,
skipped: result.rows.filter(r => r.state === "skipped").length,
failed: result.rows.filter(r => r.state === "failed").length,
},
});
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});

View File

@ -1,156 +0,0 @@
/**
* Bulk Price Lookup
*
* Routes:
* POST /api/bulk-price Get current prices for multiple part numbers at once
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const bulkPriceRouter = Router();
const MAX_PART_NUMBERS = 100;
// ─── POST /api/bulk-price ─────────────────────────────────────────────────────
bulkPriceRouter.post("/", async (req: Request, res: Response) => {
try {
const { part_numbers, limit } = req.body as {
part_numbers?: unknown;
limit?: unknown;
};
if (!Array.isArray(part_numbers) || part_numbers.length === 0) {
res.status(400).json({ success: false, error: "part_numbers must be a non-empty array" });
return;
}
const safe = part_numbers
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
.slice(0, MAX_PART_NUMBERS)
.map((p) => p.trim());
if (safe.length === 0) {
res.status(400).json({ success: false, error: "No valid part numbers provided" });
return;
}
const perVendorLimit = typeof limit === "number" && limit > 0 ? Math.min(limit, 50) : 10;
// Build $1,$2,... placeholders for the IN clause
const placeholders = safe.map((_, i) => `$${i + 1}`).join(", ");
const result = await pool.query<{
part_number: string;
transceiver_id: number;
model_name: string;
form_factor: string;
speed_gbps: number;
vendor_id: number;
vendor_name: string;
price: string;
currency: string;
observed_at: Date;
}>(
`WITH matched AS (
SELECT id, part_number, COALESCE(standard_name, part_number, '') AS model_name, form_factor, speed_gbps
FROM transceivers
WHERE part_number ILIKE ANY (ARRAY[${placeholders}])
),
recent_prices AS (
SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id)
po.transceiver_id,
po.source_vendor_id,
po.price,
po.currency,
po.time AS observed_at
FROM price_observations po
JOIN matched m ON m.id = po.transceiver_id
WHERE po.time > NOW() - INTERVAL '30 days'
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
)
SELECT
m.part_number,
m.id AS transceiver_id,
m.model_name,
m.form_factor,
m.speed_gbps,
v.id AS vendor_id,
v.name AS vendor_name,
rp.price,
rp.currency,
rp.observed_at
FROM matched m
LEFT JOIN recent_prices rp ON rp.transceiver_id = m.id
LEFT JOIN vendors v ON v.id = rp.source_vendor_id
ORDER BY m.part_number, rp.price ASC NULLS LAST
LIMIT $${safe.length + 1}`,
[...safe, safe.length * perVendorLimit]
);
// Group rows by part_number
type PriceEntry = {
vendor_id: number;
vendor_name: string;
price_usd: number; // normalised name for API output
currency: string;
observed_at: string;
};
type ResultEntry = {
part_number: string;
transceiver_id: number;
model_name: string;
form_factor: string;
speed_gbps: number;
prices: PriceEntry[];
best_price_usd: number | null;
price_count: number;
};
const map = new Map<string, ResultEntry>();
for (const row of result.rows) {
if (!map.has(row.part_number)) {
map.set(row.part_number, {
part_number: row.part_number,
transceiver_id: row.transceiver_id,
model_name: row.model_name,
form_factor: row.form_factor,
speed_gbps: row.speed_gbps,
prices: [],
best_price_usd: null,
price_count: 0,
});
}
const entry = map.get(row.part_number)!;
if (row.vendor_id !== null && row.price !== null) {
const priceNum = parseFloat(row.price);
entry.prices.push({
vendor_id: row.vendor_id,
vendor_name: row.vendor_name,
price_usd: priceNum,
currency: row.currency,
observed_at: row.observed_at.toISOString(),
});
if (entry.best_price_usd === null || priceNum < entry.best_price_usd) {
entry.best_price_usd = priceNum;
}
entry.price_count += 1;
}
}
const foundKeys = new Set(map.keys());
const notFound = safe.filter(
(pn) => !Array.from(foundKeys).some((k) => k.toLowerCase() === pn.toLowerCase())
);
res.json({
success: true,
results: Array.from(map.values()),
total_found: map.size,
not_found: notFound,
});
} catch (err) {
console.error("POST /api/bulk-price error:", err);
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -1,186 +0,0 @@
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const equivalencesRouter = Router();
// GET /api/equivalences?q=<part_number>&vendor=<vendor>&limit=50&offset=0
// Search equivalences by competitor or Flexoptix part number
equivalencesRouter.get("/", async (req: Request, res: Response) => {
const { q, vendor, limit: lim, offset: off } = req.query as Record<string, string>;
const limit = Math.min(parseInt(lim || "50"), 200);
const offset = parseInt(off || "0");
const conditions: string[] = ["e.status IN ('approved', 'auto_approved')"];
const values: unknown[] = [];
let idx = 1;
if (q) {
conditions.push(
`(fx.part_number ILIKE $${idx} OR fx.standard_name ILIKE $${idx} OR cx.part_number ILIKE $${idx} OR cx.standard_name ILIKE $${idx})`
);
values.push(`%${q}%`);
idx++;
}
if (vendor) {
conditions.push(`(cv.name ILIKE $${idx} OR fv.name ILIKE $${idx})`);
values.push(`%${vendor}%`);
idx++;
}
const where = `WHERE ${conditions.join(" AND ")}`;
const query = `
SELECT
e.id,
e.confidence,
e.match_basis,
e.status,
e.created_at,
-- Flexoptix side
fx.id AS flexoptix_id,
fx.part_number AS flexoptix_pn,
fx.standard_name AS flexoptix_std,
fx.form_factor AS flexoptix_form_factor,
fx.speed AS flexoptix_speed,
fx.speed_gbps AS flexoptix_speed_gbps,
fx.reach_label AS flexoptix_reach,
fx.product_page_url AS flexoptix_url,
fx.price_verified_eur AS flexoptix_price_eur,
fx.market_status AS flexoptix_market_status,
-- Competitor side
cx.id AS competitor_id,
cx.part_number AS competitor_pn,
cx.standard_name AS competitor_std,
cx.form_factor AS competitor_form_factor,
cx.speed AS competitor_speed,
cx.reach_label AS competitor_reach,
cx.product_page_url AS competitor_url,
cx.price_verified_eur AS competitor_price_eur,
cx.market_status AS competitor_market_status,
cv.name AS competitor_vendor,
cv.website AS competitor_vendor_website
FROM transceiver_equivalences e
JOIN transceivers fx ON fx.id = e.flexoptix_id
JOIN vendors fv ON fv.id = fx.vendor_id
JOIN transceivers cx ON cx.id = e.competitor_id
JOIN vendors cv ON cv.id = cx.vendor_id
${where}
ORDER BY e.confidence DESC, e.status DESC
LIMIT ${limit} OFFSET ${offset}
`;
const countQuery = `
SELECT COUNT(*) FROM transceiver_equivalences e
JOIN transceivers fx ON fx.id = e.flexoptix_id
JOIN vendors fv ON fv.id = fx.vendor_id
JOIN transceivers cx ON cx.id = e.competitor_id
JOIN vendors cv ON cv.id = cx.vendor_id
${where}
`;
try {
const [data, count] = await Promise.all([
pool.query(query, values),
pool.query(countQuery, values),
]);
res.json({
success: true,
data: data.rows,
total: parseInt(count.rows[0].count),
limit,
offset,
});
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// GET /api/equivalences/transceiver/:id — all equivalences for a specific transceiver (both sides)
equivalencesRouter.get("/transceiver/:id", async (req: Request, res: Response) => {
const { id } = req.params;
try {
const result = await pool.query(
`SELECT
e.id,
e.confidence,
e.match_basis,
e.status,
-- Flexoptix side
fx.id AS flexoptix_id,
fx.part_number AS flexoptix_pn,
fx.standard_name AS flexoptix_std,
fx.form_factor AS flexoptix_form_factor,
fx.speed AS flexoptix_speed,
fx.reach_label AS flexoptix_reach,
fx.product_page_url AS flexoptix_url,
fx.price_verified_eur AS flexoptix_price_eur,
-- Competitor side
cx.id AS competitor_id,
cx.part_number AS competitor_pn,
cx.standard_name AS competitor_std,
cx.form_factor AS competitor_form_factor,
cx.speed AS competitor_speed,
cx.reach_label AS competitor_reach,
cx.product_page_url AS competitor_url,
cx.price_verified_eur AS competitor_price_eur,
cv.name AS competitor_vendor,
cv.website AS competitor_vendor_website
FROM transceiver_equivalences e
JOIN transceivers fx ON fx.id = e.flexoptix_id
JOIN transceivers cx ON cx.id = e.competitor_id
JOIN vendors cv ON cv.id = cx.vendor_id
WHERE (e.flexoptix_id = $1::uuid OR e.competitor_id = $1::uuid)
AND e.status IN ('approved', 'auto_approved')
ORDER BY e.confidence DESC`,
[id]
);
res.json({ success: true, data: result.rows, total: result.rows.length });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// GET /api/equivalences/stats — overview numbers
equivalencesRouter.get("/stats", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status IN ('approved','auto_approved')) AS active,
COUNT(DISTINCT competitor_id) FILTER (WHERE status IN ('approved','auto_approved')) AS unique_competitor_products,
COUNT(DISTINCT flexoptix_id) FILTER (WHERE status IN ('approved','auto_approved')) AS unique_flexoptix_products,
COUNT(DISTINCT cv.name) AS unique_competitor_vendors,
AVG(confidence) FILTER (WHERE status IN ('approved','auto_approved'))::numeric(4,3) AS avg_confidence
FROM transceiver_equivalences e
JOIN transceivers cx ON cx.id = e.competitor_id
JOIN vendors cv ON cv.id = cx.vendor_id
`);
res.json({ success: true, stats: result.rows[0] });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
// GET /api/equivalences/top-vendors — which competitor vendors have most equivalences
equivalencesRouter.get("/top-vendors", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
cv.name AS vendor,
cv.website,
COUNT(*) AS equiv_count,
COUNT(DISTINCT e.competitor_id) AS products_covered,
AVG(e.confidence)::numeric(4,3) AS avg_confidence
FROM transceiver_equivalences e
JOIN transceivers cx ON cx.id = e.competitor_id
JOIN vendors cv ON cv.id = cx.vendor_id
WHERE e.status IN ('approved','auto_approved')
GROUP BY cv.name, cv.website
ORDER BY equiv_count DESC
LIMIT 20
`);
res.json({ success: true, data: result.rows });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});

View File

@ -1,118 +0,0 @@
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
import { semanticSearch } from "../embeddings/client";
export const kbRouter = Router();
// GET /api/kb — Knowledge base browser: FAQ + troubleshooting entries
// ?q=search&category=faq|troubleshooting|known_issue&limit=50&semantic=1
// Falls back to Qdrant semantic search when ILIKE returns 0 results
kbRouter.get("/", async (req: Request, res: Response) => {
const q = ((req.query.q as string) || "").trim();
const category = (req.query.category as string) || "";
const limit = Math.min(parseInt((req.query.limit as string) || "60"), 200);
const forceSemantic = req.query.semantic === "1";
try {
const [textEntries, cats] = await Promise.all([
pool.query(
`SELECT id, category, subcategory, question, answer,
applies_to_form_factors, applies_to_speeds, severity, tags
FROM knowledge_base
WHERE ($1 = '' OR category = $1)
AND ($2 = '' OR question ILIKE '%' || $2 || '%'
OR answer ILIKE '%' || $2 || '%'
OR subcategory ILIKE '%' || $2 || '%')
ORDER BY
CASE WHEN $2 != '' AND question ILIKE '%' || $2 || '%' THEN 0 ELSE 1 END,
category, subcategory, id
LIMIT $3`,
[category, q, limit]
),
pool.query(
`SELECT category, COUNT(*)::int AS count
FROM knowledge_base
GROUP BY category
ORDER BY count DESC`
),
]);
// If text search found results and semantic not forced, return them
if (textEntries.rows.length > 0 && !forceSemantic) {
return res.json({
success: true,
entries: textEntries.rows,
categories: cats.rows,
total: textEntries.rows.length,
query: q,
search_mode: "text",
});
}
// Semantic fallback — only when query is provided and text search returned nothing
if (q.length > 2) {
try {
const collections: Array<"faq_embeddings" | "troubleshooting_embeddings"> =
category === "faq" ? ["faq_embeddings"] :
category === "troubleshooting" ? ["troubleshooting_embeddings"] :
["faq_embeddings", "troubleshooting_embeddings"];
const semanticHits = (
await Promise.all(
collections.map(col =>
semanticSearch(col, q, Math.ceil(limit / collections.length))
.catch(() => [] as Array<{ id: string; score: number; payload: Record<string, unknown> }>)
)
)
).flat().sort((a, b) => b.score - a.score);
// Deduplicate by kb id from payload, then fetch full rows from DB
const kbIds = [...new Set(
semanticHits
.filter(h => h.score >= 0.5 && h.payload.kb_id)
.slice(0, limit)
.map(h => h.payload.kb_id as string)
)];
if (kbIds.length > 0) {
const semanticRows = await pool.query(
`SELECT id, category, subcategory, question, answer,
applies_to_form_factors, applies_to_speeds, severity, tags
FROM knowledge_base
WHERE id = ANY($1::int[])`,
[kbIds.map(Number).filter(n => !isNaN(n))]
);
// Sort results by semantic score order
const scoreMap = new Map(semanticHits.map(h => [String(h.payload.kb_id), h.score]));
const sorted = semanticRows.rows.sort(
(a, b) => (scoreMap.get(String(b.id)) || 0) - (scoreMap.get(String(a.id)) || 0)
);
return res.json({
success: true,
entries: sorted,
categories: cats.rows,
total: sorted.length,
query: q,
search_mode: "semantic",
});
}
} catch (_semErr) {
// Semantic search unavailable — fall through to text results
}
}
// Final fallback: return text results (even if empty)
return res.json({
success: true,
entries: textEntries.rows,
categories: cats.rows,
total: textEntries.rows.length,
query: q,
search_mode: "text",
});
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -1,188 +0,0 @@
/**
* Price Alert Subscriptions /api/price-alerts
*
* Users subscribe to price thresholds for specific SKUs or form factor/speed combos.
* A background checker (called by the scraper scheduler) evaluates active subscriptions
* against the latest price_observations and queues email delivery.
*
* Routes:
* POST /api/price-alerts Create subscription
* GET /api/price-alerts?email= List subscriptions for an email
* DELETE /api/price-alerts/:id Cancel subscription
* POST /api/price-alerts/check Internal: evaluate + queue alerts (scheduler)
* GET /api/price-alerts/triggered Recent triggered alerts
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const priceAlertsRouter = Router();
// ── POST /api/price-alerts — Create a price alert subscription ───────────────
priceAlertsRouter.post("/", async (req: Request, res: Response) => {
const {
email, transceiver_id, form_factor, speed_gbps,
threshold_price, currency = "USD", direction = "below", vendor_id,
} = req.body as Record<string, any>;
if (!email || typeof email !== "string" || !email.includes("@")) {
return res.status(400).json({ success: false, error: "Valid email required" });
}
if (!threshold_price || isNaN(parseFloat(threshold_price))) {
return res.status(400).json({ success: false, error: "threshold_price required" });
}
if (!transceiver_id && !form_factor && !speed_gbps) {
return res.status(400).json({ success: false, error: "At least one of: transceiver_id, form_factor, speed_gbps" });
}
try {
const result = await pool.query(
`INSERT INTO price_alert_subscriptions
(email, transceiver_id, form_factor, speed_gbps, threshold_price, currency, direction, vendor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, email, threshold_price, currency, direction, created_at`,
[
email.toLowerCase().trim(),
transceiver_id || null,
form_factor || null,
speed_gbps ? parseFloat(speed_gbps) : null,
parseFloat(threshold_price),
currency.toUpperCase(),
direction,
vendor_id || null,
]
);
return res.status(201).json({ success: true, subscription: result.rows[0] });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/price-alerts?email= — List subscriptions ────────────────────────
priceAlertsRouter.get("/", async (req: Request, res: Response) => {
const email = String(Array.isArray(req.query.email) ? req.query.email[0] ?? "" : req.query.email ?? "").trim().toLowerCase();
if (!email) return res.status(400).json({ success: false, error: "email required" });
try {
const result = await pool.query(
`SELECT pas.*,
t.standard_name, t.form_factor AS tx_form_factor, t.speed_gbps AS tx_speed,
v.name AS vendor_name
FROM price_alert_subscriptions pas
LEFT JOIN transceivers t ON t.id = pas.transceiver_id
LEFT JOIN vendors v ON v.id = pas.vendor_id
WHERE pas.email = $1
ORDER BY pas.created_at DESC`,
[email]
);
return res.json({ success: true, subscriptions: result.rows });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── DELETE /api/price-alerts/:id — Cancel subscription ───────────────────────
priceAlertsRouter.delete("/:id", async (req: Request, res: Response) => {
const id = String(req.params.id);
const email = String(Array.isArray(req.query.email) ? req.query.email[0] ?? "" : req.query.email ?? "").trim().toLowerCase();
try {
const result = await pool.query(
`UPDATE price_alert_subscriptions SET active = false
WHERE id = $1 AND ($2 = '' OR email = $2)
RETURNING id`,
[parseInt(id), email]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, error: "Subscription not found" });
}
return res.json({ success: true, cancelled: parseInt(id) });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/price-alerts/triggered — Recent triggered alerts ─────────────────
priceAlertsRouter.get("/triggered", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT pal.*,
t.standard_name, t.form_factor,
v.name AS vendor_name
FROM price_alert_log pal
LEFT JOIN transceivers t ON t.id = pal.transceiver_id
LEFT JOIN vendors v ON v.id = pal.vendor_id
ORDER BY pal.created_at DESC
LIMIT 100
`);
return res.json({ success: true, alerts: result.rows });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── POST /api/price-alerts/check — Evaluate all active subscriptions ──────────
// Called by the scraper scheduler periodically. Finds triggered conditions,
// inserts into price_alert_log, and marks last_triggered on subscription.
priceAlertsRouter.post("/check", async (_req: Request, res: Response) => {
try {
// Find subscriptions where latest price crosses the threshold
const triggered = await pool.query(`
WITH latest_prices AS (
SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id)
po.transceiver_id, po.source_vendor_id AS vendor_id,
po.price, po.currency, po.time
FROM price_observations po
WHERE po.price > 0 AND COALESCE(po.is_anomalous, false) = false
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
),
matched AS (
SELECT
pas.id AS subscription_id,
pas.email, pas.threshold_price, pas.currency, pas.direction,
lp.transceiver_id, lp.vendor_id, lp.price AS triggered_price
FROM price_alert_subscriptions pas
JOIN latest_prices lp ON (
(pas.transceiver_id IS NULL OR lp.transceiver_id = pas.transceiver_id)
AND lp.currency = pas.currency
AND (pas.vendor_id IS NULL OR lp.vendor_id = pas.vendor_id)
)
JOIN transceivers t ON t.id = lp.transceiver_id
WHERE pas.active = true
AND (pas.form_factor IS NULL OR t.form_factor = pas.form_factor)
AND (pas.speed_gbps IS NULL OR t.speed_gbps = pas.speed_gbps)
AND (
(pas.direction = 'below' AND lp.price < pas.threshold_price)
OR
(pas.direction = 'above' AND lp.price > pas.threshold_price)
)
-- Don't re-trigger more than once per 24h per subscription
AND (pas.last_triggered IS NULL OR pas.last_triggered < NOW() - INTERVAL '24 hours')
)
SELECT * FROM matched
LIMIT 200
`);
let queued = 0;
for (const row of triggered.rows) {
await pool.query(
`INSERT INTO price_alert_log
(subscription_id, transceiver_id, vendor_id, triggered_price, threshold_price, currency, email, delivery_status)
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')`,
[row.subscription_id, row.transceiver_id, row.vendor_id,
row.triggered_price, row.threshold_price, row.currency, row.email]
);
await pool.query(
`UPDATE price_alert_subscriptions
SET last_triggered = NOW(), trigger_count = trigger_count + 1
WHERE id = $1`,
[row.subscription_id]
);
queued++;
}
return res.json({ success: true, checked: triggered.rowCount, queued });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -11,7 +11,6 @@
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { pool } from "../db/client"; import { pool } from "../db/client";
import { sendCSV } from "../utils/csv";
export const priceComparisonRouter = Router(); export const priceComparisonRouter = Router();
@ -97,10 +96,9 @@ priceComparisonRouter.get("/summary", async (_req: Request, res: Response) => {
// ─── GET /api/price-comparison ─────────────────────────────────────────────── // ─── GET /api/price-comparison ───────────────────────────────────────────────
/** /**
* Top 50 transceivers ranked by number of vendors tracking them. * Top 50 transceivers ranked by number of vendors tracking them.
* Add ?format=csv to download as CSV. * Shows price spread across vendors the more vendors, the better the comparison.
*/ */
priceComparisonRouter.get("/", async (req: Request, res: Response) => { priceComparisonRouter.get("/", async (_req: Request, res: Response) => {
const fmt = req.query.format as string | undefined;
try { try {
const result = await pool.query(` const result = await pool.query(`
WITH latest AS ( WITH latest AS (
@ -140,9 +138,6 @@ priceComparisonRouter.get("/", async (req: Request, res: Response) => {
LIMIT 50 LIMIT 50
`); `);
if (fmt === "csv") {
return sendCSV(res, result.rows, `tip-price-comparison-${new Date().toISOString().slice(0,10)}.csv`);
}
res.json({ res.json({
success: true, success: true,
data: result.rows, data: result.rows,

View File

@ -1,111 +0,0 @@
/**
* Price Forecast Linear Regression
*
* Routes:
* GET /api/price-forecast/:id 30-day forecast for a transceiver
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const priceForecastRouter = Router();
const MIN_PRICE = 0.01;
function linearRegression(xs: number[], ys: number[]): { slope: number; intercept: number; rSquared: number } {
const n = xs.length;
if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, rSquared: 0 };
const sumX = xs.reduce((a, b) => a + b, 0);
const sumY = ys.reduce((a, b) => a + b, 0);
const sumXY = xs.reduce((acc, x, i) => acc + x * ys[i], 0);
const sumX2 = xs.reduce((acc, x) => acc + x * x, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
const yMean = sumY / n;
const ssTot = ys.reduce((acc, y) => acc + (y - yMean) ** 2, 0);
const ssRes = xs.reduce((acc, x, i) => acc + (ys[i] - (slope * x + intercept)) ** 2, 0);
const rSquared = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
return { slope, intercept, rSquared };
}
function addDays(base: Date, n: number): string {
const d = new Date(base);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
// ─── GET /api/price-forecast/:id ─────────────────────────────────────────────
priceForecastRouter.get("/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(String(req.params.id), 10);
if (!Number.isFinite(id) || id <= 0) {
res.status(400).json({ success: false, error: "Invalid transceiver id" });
return;
}
const histResult = await pool.query<{ day: Date; avg_price: string }>(
`SELECT
DATE(time) AS day,
AVG(price) AS avg_price
FROM price_observations
WHERE transceiver_id = $1
AND time > NOW() - INTERVAL '90 days'
GROUP BY DATE(time)
ORDER BY day`,
[id]
);
if (histResult.rows.length === 0) {
res.status(404).json({ success: false, error: "No price history found for this transceiver" });
return;
}
const history = histResult.rows.map((r) => ({
date: r.day.toISOString().slice(0, 10),
avg_price: parseFloat(r.avg_price),
}));
// Use day-0 offset as x axis so numbers stay small
const epoch0 = new Date(history[0].date + "T00:00:00Z").getTime();
const xs = history.map((h) => (new Date(h.date + "T00:00:00Z").getTime() - epoch0) / 86_400_000);
const ys = history.map((h) => h.avg_price);
const { slope, intercept, rSquared } = linearRegression(xs, ys);
const lastDate = new Date(history[history.length - 1].date + "T00:00:00Z");
const lastX = xs[xs.length - 1];
const forecast = Array.from({ length: 30 }, (_, i) => {
const dayOffset = lastX + i + 1;
const rawPrice = slope * dayOffset + intercept;
const predictedPrice = Math.max(MIN_PRICE, Math.round(rawPrice * 100) / 100);
return {
date: addDays(lastDate, i + 1),
predicted_price: predictedPrice,
is_forecast: true,
};
});
const trend =
slope > 0.05 ? "rising" :
slope < -0.05 ? "declining" : "stable";
const forecast30dPrice = forecast[29].predicted_price;
res.json({
success: true,
transceiver_id: id,
history,
forecast,
trend,
slope_per_day: Math.round(slope * 10_000) / 10_000,
r_squared: Math.round(rSquared * 100) / 100,
forecast_30d_price: forecast30dPrice,
});
} catch (err) {
console.error("GET /api/price-forecast/:id error:", err);
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -1,90 +0,0 @@
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const priceHistoryRouter = Router();
// GET /api/price-history/:transceiverIdOrSlug?days=90&vendor=all
// Returns time-bucketed price history for a transceiver (for charts)
priceHistoryRouter.get("/:id", async (req: Request, res: Response) => {
const { id } = req.params;
const days = Math.min(parseInt((req.query.days as string) || "90"), 365);
const vendorFilter = req.query.vendor as string | undefined;
try {
// Resolve slug or UUID to transceiver
const tx = await pool.query(
`SELECT t.id, t.part_number, t.standard_name, v.name as vendor_name
FROM transceivers t LEFT JOIN vendors v ON v.id = t.vendor_id
WHERE t.id::text = $1 OR t.slug = $1 LIMIT 1`,
[id]
);
if (!tx.rows[0]) {
res.status(404).json({ success: false, error: "Transceiver not found" });
return;
}
const txId = tx.rows[0].id;
// Daily min/max/avg price per source vendor — bucket by day
const conditions = [`po.transceiver_id = $1`, `po.time >= NOW() - INTERVAL '${days} days'`, `po.price > 0`, `po.is_anomalous IS NOT TRUE`];
const values: unknown[] = [txId];
let idx = 2;
if (vendorFilter && vendorFilter !== "all") {
conditions.push(`sv.name ILIKE $${idx}`);
values.push(`%${vendorFilter}%`);
idx++;
}
const where = `WHERE ${conditions.join(" AND ")}`;
const seriesQuery = `
SELECT
DATE_TRUNC('day', po.time) AS day,
sv.name AS source_vendor,
sv.id AS source_vendor_id,
MIN(po.price) AS price_min,
MAX(po.price) AS price_max,
AVG(po.price)::numeric(12,2) AS price_avg,
po.currency,
COUNT(*) AS observations
FROM price_observations po
LEFT JOIN vendors sv ON sv.id = po.source_vendor_id
${where}
GROUP BY DATE_TRUNC('day', po.time), sv.name, sv.id, po.currency
ORDER BY day ASC, source_vendor
`;
// Current best price (lowest verified non-anomalous)
const currentQuery = `
SELECT
sv.name AS source_vendor,
MIN(po.price) AS best_price,
po.currency,
MAX(po.time) AS last_seen
FROM price_observations po
LEFT JOIN vendors sv ON sv.id = po.source_vendor_id
WHERE po.transceiver_id = $1
AND po.time >= NOW() - INTERVAL '7 days'
AND po.price > 0
AND po.is_anomalous IS NOT TRUE
GROUP BY sv.name, po.currency
ORDER BY best_price ASC
LIMIT 10
`;
const [series, current] = await Promise.all([
pool.query(seriesQuery, values),
pool.query(currentQuery, [txId]),
]);
res.json({
success: true,
transceiver: tx.rows[0],
days,
series: series.rows,
current_prices: current.rows,
});
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});

View File

@ -1,136 +0,0 @@
/**
* Price Matrix Transceiver × Vendor Grid
*
* Routes:
* GET /api/price-matrix Price matrix for selected (or top) transceivers
* Query params:
* ids comma-separated transceiver IDs (max 20); omit for auto top-10
* limit how many top transceivers to return when ids is omitted (default 10)
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const priceMatrixRouter = Router();
const MAX_IDS = 20;
const MAX_LIMIT = 50;
// ─── GET /api/price-matrix ────────────────────────────────────────────────────
priceMatrixRouter.get("/", async (req: Request, res: Response) => {
try {
let transceiverIds: number[];
if (typeof req.query.ids === "string" && req.query.ids.trim().length > 0) {
const parsed = req.query.ids
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0)
.slice(0, MAX_IDS);
if (parsed.length === 0) {
res.status(400).json({ success: false, error: "No valid transceiver IDs provided" });
return;
}
transceiverIds = parsed;
} else {
// Auto-select top N by observation count in last 30 days
const rawLimit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 10;
const topLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, MAX_LIMIT) : 10;
const topResult = await pool.query<{ transceiver_id: number }>(
`SELECT transceiver_id
FROM price_observations
WHERE time > NOW() - INTERVAL '30 days'
GROUP BY transceiver_id
ORDER BY COUNT(*) DESC
LIMIT $1`,
[topLimit]
);
transceiverIds = topResult.rows.map((r) => r.transceiver_id);
if (transceiverIds.length === 0) {
res.json({ success: true, transceivers: [], vendors: [], matrix: {}, best_prices: {} });
return;
}
}
const placeholders = transceiverIds.map((_, i) => `$${i + 1}`).join(", ");
const [txResult, priceResult] = await Promise.all([
pool.query<{
id: number;
model_name: string;
part_number: string;
form_factor: string;
speed_gbps: number;
}>(
`SELECT id, COALESCE(standard_name, part_number, '') AS model_name, part_number, form_factor, speed_gbps
FROM transceivers
WHERE id IN (${placeholders})
ORDER BY id`,
transceiverIds
),
pool.query<{
transceiver_id: number;
vendor_id: number;
vendor_name: string;
price: string;
}>(
`SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id)
po.transceiver_id,
po.source_vendor_id AS vendor_id,
v.name AS vendor_name,
po.price
FROM price_observations po
JOIN vendors v ON v.id = po.source_vendor_id
WHERE po.transceiver_id IN (${placeholders})
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC`,
transceiverIds
),
]);
// Build vendor list (deduplicated, stable order)
const vendorMap = new Map<number, string>();
for (const row of priceResult.rows) {
if (!vendorMap.has(row.vendor_id)) {
vendorMap.set(row.vendor_id, row.vendor_name);
}
}
const vendors = Array.from(vendorMap.entries()).map(([vendor_id, vendor_name]) => ({
vendor_id,
vendor_name,
}));
// Build matrix and best_prices
const matrix: Record<string, Record<string, number>> = {};
const bestPrices: Record<string, number> = {};
for (const row of priceResult.rows) {
const txKey = String(row.transceiver_id);
const vKey = String(row.vendor_id);
const price = parseFloat(row.price);
if (!Number.isFinite(price)) continue;
if (!matrix[txKey]) matrix[txKey] = {};
matrix[txKey][vKey] = price;
if (bestPrices[txKey] === undefined || price < bestPrices[txKey]) {
bestPrices[txKey] = price;
}
}
res.json({
success: true,
transceivers: txResult.rows,
vendors,
matrix,
best_prices: bestPrices,
});
} catch (err) {
console.error("GET /api/price-matrix error:", err);
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -9,12 +9,9 @@
* GET /api/procurement/market-intel Market intelligence events * GET /api/procurement/market-intel Market intelligence events
* GET /api/procurement/stock-trends/:id Stock history for a transceiver * GET /api/procurement/stock-trends/:id Stock history for a transceiver
* GET /api/procurement/lifecycle Lifecycle events (EOL, standards) * GET /api/procurement/lifecycle Lifecycle events (EOL, standards)
* GET /api/procurement/ai-clusters AI datacenter announcements with transceiver demand
* GET /api/procurement/internal-demand Flexoptix internal demand velocity (fast/slow/dead)
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { pool } from "../db/client"; import { pool } from "../db/client";
import { sendCSV } from "../utils/csv";
export const procurementRouter = Router(); export const procurementRouter = Router();
@ -25,13 +22,11 @@ procurementRouter.get("/overview", async (_req: Request, res: Response) => {
try { try {
const [signals, abc, intel, lifecycle] = await Promise.all([ const [signals, abc, intel, lifecycle] = await Promise.all([
pool.query(` pool.query(`
WITH latest AS ( SELECT signal, COUNT(*) AS count
SELECT DISTINCT ON (transceiver_id) signal FROM reorder_signals
FROM reorder_signals WHERE expires_at > NOW()
WHERE expires_at > NOW() AND computed_at = (SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = reorder_signals.transceiver_id)
ORDER BY transceiver_id, computed_at DESC GROUP BY signal
)
SELECT signal, COUNT(*) AS count FROM latest GROUP BY signal
`), `),
pool.query(` pool.query(`
SELECT abc_class, COUNT(*) AS count FROM abc_classification GROUP BY abc_class ORDER BY abc_class SELECT abc_class, COUNT(*) AS count FROM abc_classification GROUP BY abc_class ORDER BY abc_class
@ -75,47 +70,31 @@ procurementRouter.get("/signals", async (req: Request, res: Response) => {
limit = "50", offset = "0" limit = "50", offset = "0"
} = req.query; } = req.query;
// Use DISTINCT ON with the existing idx_reorder_transceiver index instead of let sql = `
// a correlated subquery that would run once per active row (108k+ scans).
const params: any[] = [];
let idx = 1;
const signalFilter = signal ? ` AND rs.signal = $${idx++}` : "";
if (signal) params.push(signal);
const abcFilter = abc_class ? ` AND ac.abc_class = $${idx++}` : "";
if (abc_class) params.push(abc_class);
const ffFilter = form_factor ? ` AND t.form_factor = $${idx++}` : "";
if (form_factor) params.push(form_factor);
const speedFilter = speed_gbps ? ` AND t.speed_gbps = $${idx++}` : "";
if (speed_gbps) params.push(parseFloat(speed_gbps as string));
params.push(parseInt(limit as string), parseInt(offset as string));
const limitIdx = idx; idx++;
const offsetIdx = idx;
const sql = `
WITH latest AS (
SELECT DISTINCT ON (transceiver_id)
id, transceiver_id, signal, signal_strength, reasons,
stock_trend, price_trend, lead_time_weeks, hype_phase,
computed_at, expires_at, is_demo_data
FROM reorder_signals
WHERE expires_at > NOW()
ORDER BY transceiver_id, computed_at DESC
)
SELECT rs.*, SELECT rs.*,
t.part_number, t.standard_name, t.form_factor, t.speed_gbps, t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
t.reach_label, t.image_url, t.image_r2_key, t.reach_label, t.image_url, t.image_r2_key,
ac.abc_class, ac.demand_score, ac.supply_risk, ac.abc_class, ac.demand_score, ac.supply_risk,
v.name AS vendor_name v.name AS vendor_name
FROM latest rs FROM reorder_signals rs
JOIN transceivers t ON rs.transceiver_id = t.id JOIN transceivers t ON rs.transceiver_id = t.id
LEFT JOIN abc_classification ac ON ac.transceiver_id = t.id LEFT JOIN abc_classification ac ON ac.transceiver_id = t.id
LEFT JOIN vendors v ON t.vendor_id = v.id LEFT JOIN vendors v ON t.vendor_id = v.id
WHERE 1=1${signalFilter}${abcFilter}${ffFilter}${speedFilter} WHERE rs.expires_at > NOW()
ORDER BY rs.signal_strength DESC AND rs.computed_at = (
LIMIT $${limitIdx} OFFSET $${offsetIdx} SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = rs.transceiver_id
)
`; `;
const params: any[] = [];
let idx = 1;
if (signal) { sql += ` AND rs.signal = $${idx}`; params.push(signal); idx++; }
if (abc_class) { sql += ` AND ac.abc_class = $${idx}`; params.push(abc_class); idx++; }
if (form_factor) { sql += ` AND t.form_factor = $${idx}`; params.push(form_factor); idx++; }
if (speed_gbps) { sql += ` AND t.speed_gbps = $${idx}`; params.push(parseFloat(speed_gbps as string)); idx++; }
sql += ` ORDER BY rs.signal_strength DESC LIMIT $${idx} OFFSET $${idx + 1}`;
params.push(parseInt(limit as string), parseInt(offset as string));
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
res.json({ data: result.rows, total: result.rowCount }); res.json({ data: result.rows, total: result.rowCount });
@ -214,9 +193,6 @@ procurementRouter.get("/abc", async (req: Request, res: Response) => {
params.push(parseInt(limit as string), parseInt(offset as string)); params.push(parseInt(limit as string), parseInt(offset as string));
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
if ((req.query.format as string) === "csv") {
return sendCSV(res, result.rows, `tip-abc-classification-${new Date().toISOString().slice(0,10)}.csv`);
}
res.json({ data: result.rows, total: result.rowCount }); res.json({ data: result.rows, total: result.rowCount });
} catch (err) { } catch (err) {
console.error("ABC error:", err); console.error("ABC error:", err);
@ -315,833 +291,3 @@ procurementRouter.get("/lifecycle", async (req: Request, res: Response) => {
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
}); });
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/ai-clusters?days=90&limit=50&min_transceivers=0
// Returns AI datacenter announcements with transceiver demand estimates
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/ai-clusters", async (req: Request, res: Response) => {
try {
const {
days = "90",
limit = "50",
min_transceivers = "0",
} = req.query;
const daysN = Math.min(Math.max(parseInt(days as string) || 90, 1), 730);
const limitN = Math.min(parseInt(limit as string) || 50, 200);
const minTx = parseInt(min_transceivers as string) || 0;
const result = await pool.query(
`SELECT
id, company, title, summary,
announced_date, scale_mw, scale_servers,
network_speed, estimated_transceivers,
deployment_date, location, source_url, source_name,
created_at
FROM ai_cluster_announcements
WHERE
(announced_date IS NULL OR announced_date >= NOW() - INTERVAL '1 day' * $1)
AND ($2 = 0 OR estimated_transceivers >= $2)
ORDER BY announced_date DESC NULLS LAST, created_at DESC
LIMIT $3`,
[daysN, minTx, limitN]
);
// Aggregate stats
const statsResult = await pool.query(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates,
SUM(estimated_transceivers) FILTER (WHERE estimated_transceivers > 0) AS total_estimated_transceivers,
SUM(scale_mw) FILTER (WHERE scale_mw IS NOT NULL) AS total_mw,
COUNT(DISTINCT company) FILTER (WHERE company != 'Unknown') AS distinct_companies
FROM ai_cluster_announcements
WHERE announced_date >= NOW() - INTERVAL '1 day' * $1`,
[daysN]
);
res.json({
data: result.rows,
stats: statsResult.rows[0],
period_days: daysN,
});
} catch (err) {
console.error("AI clusters error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/internal-demand?velocity_class=fast_mover&limit=100
// Returns Flexoptix internal demand data — real SKU velocity from internal data
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/internal-demand", async (req: Request, res: Response) => {
try {
const {
velocity_class,
limit = "100",
offset = "0",
sort = "demand_12m",
} = req.query;
const allowedSorts: Record<string, string> = {
demand_12m: "fid.demand_12m DESC",
demand_3m: "fid.demand_3m DESC",
trend: "fid.demand_trend_pct DESC NULLS LAST",
sku: "fid.sku ASC",
};
const orderBy = allowedSorts[sort as string] ?? allowedSorts["demand_12m"];
const params: unknown[] = [];
const conditions: string[] = ["fid.is_internal = true"];
let idx = 1;
if (velocity_class) {
conditions.push(`fid.velocity_class = $${idx}`);
params.push(velocity_class);
idx++;
}
params.push(Math.min(parseInt(limit as string) || 100, 500));
params.push(Math.max(parseInt(offset as string) || 0, 0));
const result = await pool.query(
`SELECT
fid.id, fid.sku, fid.description,
fid.demand_12m, fid.demand_3m, fid.demand_trend_pct,
fid.velocity_class, fid.imported_at,
t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
t.reach_label, t.image_url,
v.name AS vendor_name
FROM flexoptix_internal_demand fid
LEFT JOIN transceivers t ON t.id = fid.transceiver_id
LEFT JOIN vendors v ON v.id = t.vendor_id
WHERE ${conditions.join(" AND ")}
ORDER BY ${orderBy}
LIMIT $${idx} OFFSET $${idx + 1}`,
params
);
// Velocity summary
const summaryResult = await pool.query(
`SELECT
velocity_class,
COUNT(*) AS cnt,
SUM(demand_12m)::numeric(12,0) AS total_demand_12m,
AVG(demand_trend_pct)::numeric(8,1) AS avg_trend_pct
FROM flexoptix_internal_demand
WHERE is_internal = true
GROUP BY velocity_class
ORDER BY total_demand_12m DESC NULLS LAST`
);
res.json({
data: result.rows,
summary: summaryResult.rows,
total: result.rowCount,
});
} catch (err) {
console.error("Internal demand error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/hyperscaler-capex
// Hyperscaler quarterly CapEx from SEC filings — demand context for transceivers
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/hyperscaler-capex", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
company, period_label, period_end,
capex_usd_millions, dc_capex_est_millions,
yoy_growth_pct, filing_type, source_url
FROM hyperscaler_capex
ORDER BY period_end DESC, capex_usd_millions DESC
`);
const summaryResult = await pool.query(`
SELECT
company,
MAX(period_end) AS latest_period_end,
MAX(period_label) AS latest_period,
(ARRAY_AGG(capex_usd_millions ORDER BY period_end DESC))[1] AS latest_capex,
(ARRAY_AGG(dc_capex_est_millions ORDER BY period_end DESC))[1] AS latest_dc_capex,
(ARRAY_AGG(yoy_growth_pct ORDER BY period_end DESC))[1] AS latest_yoy_growth,
AVG(yoy_growth_pct) FILTER (WHERE period_end >= NOW() - INTERVAL '365 days') AS avg_yoy_12m
FROM hyperscaler_capex
GROUP BY company
ORDER BY latest_capex DESC NULLS LAST
`);
res.json({
data: result.rows,
summary: summaryResult.rows,
});
} catch (err) {
console.error("Hyperscaler capex error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/marketplace-velocity
// Secondary market (eBay) sell-through as demand signal
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/marketplace-velocity", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT DISTINCT ON (form_factor, speed_label)
marketplace, keyword, form_factor, speed_label,
sold_count_30d, active_listings, avg_sold_price,
min_price, max_price, currency, scraped_at
FROM marketplace_velocity
ORDER BY form_factor, speed_label, scraped_at DESC
`);
const hotResult = await pool.query(`
SELECT DISTINCT ON (form_factor, speed_label)
marketplace, form_factor, speed_label,
sold_count_30d, active_listings, avg_sold_price
FROM marketplace_velocity
WHERE sold_count_30d > 0
ORDER BY form_factor, speed_label, scraped_at DESC
`);
res.json({
data: result.rows,
hot: hotResult.rows.sort(
(a: { sold_count_30d: string }, b: { sold_count_30d: string }) =>
parseInt(b.sold_count_30d) - parseInt(a.sold_count_30d)
),
});
} catch (err) {
console.error("Marketplace velocity error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─── E: GET /api/procurement/reorder-top ─────────────────────────────────────
// Top buy_now reorder signals with full reasons — 211k precomputed signals
procurementRouter.get("/reorder-top", async (req: Request, res: Response) => {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const formFactor = (req.query.form_factor as string) || "";
const minStrength = parseFloat(req.query.min_strength as string) || 0;
try {
const result = await pool.query(`
SELECT DISTINCT ON (t.id)
t.id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label,
v.name AS vendor_name,
rs.signal, rs.signal_strength,
rs.price_trend, rs.stock_trend, rs.hype_phase,
rs.reasons,
rs.computed_at
FROM reorder_signals rs
JOIN transceivers t ON t.id = rs.transceiver_id
JOIN vendors v ON v.id = t.vendor_id
WHERE rs.signal = 'buy_now'
AND rs.is_demo_data = false
AND rs.signal_strength >= $1
AND ($2 = '' OR t.form_factor ILIKE $2)
ORDER BY t.id, rs.signal_strength DESC, rs.computed_at DESC
`, [minStrength, formFactor]);
// After DISTINCT ON, re-sort by signal_strength
const rows = result.rows.sort(
(a: { signal_strength: string }, b: { signal_strength: string }) =>
parseFloat(b.signal_strength) - parseFloat(a.signal_strength)
);
const summary = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::int AS buy_now,
COUNT(*) FILTER (WHERE signal = 'wait' AND is_demo_data = false)::int AS wait,
COUNT(*) FILTER (WHERE signal = 'hold' AND is_demo_data = false)::int AS hold,
COUNT(*) FILTER (WHERE signal = 'monitor' AND is_demo_data = false)::int AS monitor,
ROUND(AVG(signal_strength) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::numeric,3) AS avg_buy_strength
FROM reorder_signals
`);
res.json({ success: true, data: rows.slice(0, limit), summary: summary.rows[0] });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── B: GET /api/procurement/switch-compat ───────────────────────────────────
// Switch ↔ transceiver compatibility matrix
procurementRouter.get("/switch-compat", async (req: Request, res: Response) => {
const search = (req.query.search as string) || "";
const limitNum = Math.min(parseInt(req.query.limit as string) || 30, 100);
try {
if (search.length >= 2) {
// Search for switches matching query, return their compatible transceivers
const switches = await pool.query(`
SELECT DISTINCT ON (sw.id)
sw.id, v.name AS sw_vendor, sw.model AS sw_model, sw.series AS sw_series,
COUNT(c.transceiver_id) OVER (PARTITION BY sw.id)::int AS compat_count
FROM switches sw
JOIN compatibility c ON c.switch_id = sw.id
LEFT JOIN vendors v ON v.id = sw.vendor_id
WHERE sw.model ILIKE $1 OR COALESCE(v.name,'') ILIKE $1 OR sw.series ILIKE $1
ORDER BY sw.id, compat_count DESC
LIMIT $2
`, [`%${search}%`, limitNum]);
// For each matched switch, get top compatible transceivers with prices
const switchIds = switches.rows.map((s: { id: string }) => s.id);
if (switchIds.length === 0) {
return res.json({ success: true, switches: [], transceivers: [] });
}
const transceivers = await pool.query(`
SELECT
c.switch_id,
t.id AS tx_id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label,
v.name AS vendor_name,
c.verification_method, c.status,
(SELECT ROUND(MIN(po.price)::numeric,2) FROM price_observations po
WHERE po.transceiver_id = t.id AND po.price > 0) AS min_price,
(SELECT po.currency FROM price_observations po
WHERE po.transceiver_id = t.id AND po.price > 0
ORDER BY po.time DESC LIMIT 1) AS currency
FROM compatibility c
JOIN transceivers t ON t.id = c.transceiver_id
JOIN vendors v ON v.id = t.vendor_id
WHERE c.switch_id = ANY($1)
AND c.status = 'compatible'
ORDER BY t.speed_gbps DESC, t.form_factor
LIMIT 200
`, [switchIds]);
return res.json({
success: true,
switches: switches.rows,
transceivers: transceivers.rows,
});
}
// No search — return top switches by compat count
const top = await pool.query(`
SELECT v.name AS vendor, sw.model, sw.series,
COUNT(c.transceiver_id)::int AS compat_count
FROM switches sw
JOIN compatibility c ON c.switch_id = sw.id
LEFT JOIN vendors v ON v.id = sw.vendor_id
WHERE c.status = 'compatible'
GROUP BY sw.id, v.name, sw.model, sw.series
ORDER BY compat_count DESC
LIMIT $1
`, [limitNum]);
const stats = await pool.query(`
SELECT
COUNT(DISTINCT sw.id)::int AS total_switches,
COUNT(DISTINCT c.transceiver_id)::int AS total_transceivers,
COUNT(*)::int AS total_compat_rows
FROM switches sw JOIN compatibility c ON c.switch_id = sw.id
WHERE c.status = 'compatible'
`);
return res.json({ success: true, topSwitches: top.rows, stats: stats.rows[0] });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── A: GET /api/procurement/arbitrage ───────────────────────────────────────
// OEM vs Flexoptix price gaps via transceiver_equivalences
procurementRouter.get("/arbitrage", async (_req: Request, res: Response) => {
// FX rates for normalization — approximate
const FX: Record<string, number> = { USD: 1.0, EUR: 1.08, GBP: 1.27 };
try {
const result = await pool.query(`
SELECT
te.confidence,
fx.part_number AS fx_part,
vfx.name AS fx_vendor,
fx.speed_gbps, fx.form_factor, fx.reach_label,
comp.part_number AS comp_part,
vcomp.name AS comp_vendor,
(SELECT price FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_price,
(SELECT currency FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_curr,
(SELECT price FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_price,
(SELECT currency FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_curr
FROM transceiver_equivalences te
JOIN transceivers fx ON fx.id = te.flexoptix_id
JOIN transceivers comp ON comp.id = te.competitor_id
JOIN vendors vfx ON vfx.id = fx.vendor_id
JOIN vendors vcomp ON vcomp.id = comp.vendor_id
WHERE te.status IN ('approved','auto_approved')
AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2)
AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2)
ORDER BY te.confidence DESC
LIMIT 2000
`);
const pairs = result.rows
.map((r: {
fx_price: string; fx_curr: string;
comp_price: string; comp_curr: string;
confidence: string;
fx_part: string; fx_vendor: string;
comp_part: string; comp_vendor: string;
speed_gbps: string; form_factor: string; reach_label: string;
}) => {
const fxUSD = parseFloat(r.fx_price) * (FX[r.fx_curr] || 1.0);
const compUSD = parseFloat(r.comp_price) * (FX[r.comp_curr] || 1.0);
if (!fxUSD || !compUSD) return null;
const savings = compUSD - fxUSD;
const savingsPct = Math.round((savings / compUSD) * 100);
return { ...r, fxUSD: Math.round(fxUSD), compUSD: Math.round(compUSD), savings: Math.round(savings), savingsPct };
})
.filter((r): r is NonNullable<typeof r> => r !== null && r.savings > 0)
.sort((a, b) => b.savingsPct - a.savingsPct)
.slice(0, 100);
// Stats
const totalPairs = result.rows.length;
const fxCheaper = pairs.length;
const avgSavings = pairs.length ? Math.round(pairs.reduce((s, r) => s + r.savingsPct, 0) / pairs.length) : 0;
res.json({ success: true, pairs, stats: { totalPairs, fxCheaper, avgSavingsPct: avgSavings } });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── D: GET /api/procurement/dead-stock-revival ──────────────────────────────
// Dead-stock SKUs whose equivalents are in rising hype phases
procurementRouter.get("/dead-stock-revival", async (_req: Request, res: Response) => {
try {
const [deadStock, hypeMap] = await Promise.all([
pool.query(`
SELECT
fid.transceiver_id,
fid.sku AS part_number,
fid.velocity_class,
fid.demand_12m,
fid.demand_trend_pct,
t.speed_gbps, t.form_factor, t.reach_label,
v.name AS vendor_name
FROM flexoptix_internal_demand fid
JOIN transceivers t ON t.id = fid.transceiver_id
JOIN vendors v ON v.id = t.vendor_id
WHERE fid.velocity_class = 'dead_stock'
AND fid.is_internal = true
LIMIT 7500
`),
pool.query(`
SELECT DISTINCT ON (technology)
technology, hype_phase, hype_score, computed_at
FROM hype_cycle_analysis
ORDER BY technology, computed_at DESC
`),
]);
// Build speed → hype phase map
type HypeRow = { technology: string; hype_phase: string; hype_score: string };
const ASCENDING = new Set(["innovation_trigger","peak_inflated_expectations","slope_enlightenment","plateau_productivity"]);
const speedToHype = new Map<number, HypeRow>();
for (const h of hypeMap.rows as HypeRow[]) {
const speedMatch = h.technology.match(/^(\d+(?:\.\d+)?)G/);
if (speedMatch) speedToHype.set(parseFloat(speedMatch[1]), h);
}
type DeadRow = {
transceiver_id: string; part_number: string;
speed_gbps: string; form_factor: string; reach_label: string;
vendor_name: string; demand_12m: string; demand_trend_pct: string;
velocity_class: string;
};
const revivals = (deadStock.rows as DeadRow[])
.map((r) => {
const speed = parseFloat(r.speed_gbps);
const hype = speedToHype.get(speed);
if (!hype) return null;
const ascending = ASCENDING.has(hype.hype_phase);
const score = parseFloat(hype.hype_score);
return { ...r, hype_phase: hype.hype_phase, hype_score: score, ascending };
})
.filter((r): r is NonNullable<typeof r> => r !== null && r.ascending && r.hype_score > 30)
.sort((a, b) => b.hype_score - a.hype_score)
.slice(0, 100);
const totalDead = deadStock.rows.length;
res.json({ success: true, revivals, totalDeadStock: totalDead, revivalCount: revivals.length });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── C: GET /api/procurement/supply-squeeze ──────────────────────────────────
// Multi-signal supply constraint detector
procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) => {
try {
const [priceSignals, aiDemand, hypeData, stockData] = await Promise.all([
// Price momentum: 30d vs 60d avg by speed/form_factor
pool.query(`
SELECT
t.speed_gbps, t.form_factor,
ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days')::numeric,2) AS avg_30d,
ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days')::numeric,2) AS avg_prior_30d,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d
FROM price_observations po
JOIN transceivers t ON t.id = po.transceiver_id
WHERE po.price > 5 AND po.currency = 'USD'
GROUP BY t.speed_gbps, t.form_factor
HAVING COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') >= 3
`),
// AI cluster demand by speed tier
pool.query(`
SELECT
CASE
WHEN COALESCE(network_speed, title, summary, '') ILIKE '%800G%' THEN 800
WHEN COALESCE(network_speed, title, summary, '') ILIKE '%400G%' THEN 400
WHEN COALESCE(network_speed, title, summary, '') ILIKE '%100G%' THEN 100
ELSE 0
END AS speed_tier,
COALESCE(SUM(estimated_transceivers),0)::int AS total_tx,
COUNT(*)::int AS cluster_count
FROM ai_cluster_announcements
WHERE announced_date >= NOW() - INTERVAL '90 days'
GROUP BY 1
HAVING COALESCE(SUM(estimated_transceivers),0) > 0
`),
// Hype phase per technology
pool.query(`
SELECT DISTINCT ON (technology)
technology, hype_phase, hype_score
FROM hype_cycle_analysis ORDER BY technology, computed_at DESC
`),
// Stock level distribution (in_stock vs out_of_stock)
pool.query(`
SELECT
t.speed_gbps, t.form_factor,
COUNT(*) FILTER (WHERE so.stock_level = 'out_of_stock')::int AS out_of_stock,
COUNT(*) FILTER (WHERE so.stock_level = 'in_stock')::int AS in_stock,
COUNT(*)::int AS total_obs
FROM stock_observations so
JOIN transceivers t ON t.id = so.transceiver_id
WHERE so.observed_at >= NOW() - INTERVAL '14 days'
GROUP BY t.speed_gbps, t.form_factor
HAVING COUNT(*) >= 3
`).catch(() => ({ rows: [] })),
]);
type PriceRow = { speed_gbps: string; form_factor: string; avg_30d: string; avg_prior_30d: string; obs_30d: string };
type HypeRow = { technology: string; hype_phase: string; hype_score: string };
type AiRow = { speed_tier: string; total_tx: string; cluster_count: string };
type StockRow = { speed_gbps: string; form_factor: string; out_of_stock: string; in_stock: string; total_obs: string };
const speedToHype = new Map<number, HypeRow>();
for (const h of hypeData.rows as HypeRow[]) {
const m = h.technology.match(/^(\d+(?:\.\d+)?)G/);
if (m) speedToHype.set(parseFloat(m[1]), h);
}
const aiBySpeed = new Map<number, AiRow>();
for (const a of aiDemand.rows as AiRow[]) {
aiBySpeed.set(parseFloat(a.speed_tier), a);
}
const stockByKey = new Map<string, StockRow>();
for (const s of stockData.rows as StockRow[]) {
stockByKey.set(`${s.speed_gbps}:${s.form_factor}`, s);
}
const RISKY_PHASES = new Set(["peak_inflated_expectations","slope_enlightenment","plateau_productivity"]);
const signals = (priceSignals.rows as PriceRow[])
.map((r) => {
const speed = parseFloat(r.speed_gbps);
const priceUp = r.avg_30d && r.avg_prior_30d
? ((parseFloat(r.avg_30d) - parseFloat(r.avg_prior_30d)) / parseFloat(r.avg_prior_30d)) * 100
: 0;
const hype = speedToHype.get(speed);
const ai = aiBySpeed.get(speed);
const stock = stockByKey.get(`${r.speed_gbps}:${r.form_factor}`);
let activeSignals = 0;
const reasons: string[] = [];
if (priceUp > 5) { activeSignals++; reasons.push(`Price +${Math.round(priceUp)}% (30d)`); }
if (hype && RISKY_PHASES.has(hype.hype_phase)) { activeSignals++; reasons.push(`Hype: ${hype.hype_phase.replace(/_/g,' ')}`); }
if (ai && parseInt(ai.total_tx) > 50000) { activeSignals++; reasons.push(`AI demand: ${parseInt(ai.total_tx).toLocaleString()} tx in 90d`); }
if (stock && parseInt(stock.out_of_stock) > parseInt(stock.in_stock)) { activeSignals++; reasons.push(`Stock pressure: ${stock.out_of_stock}/${stock.total_obs} vendors OOS`); }
const severity = activeSignals >= 3 ? "critical" : activeSignals === 2 ? "warning" : activeSignals === 1 ? "watch" : "ok";
return {
speed_gbps: r.speed_gbps, form_factor: r.form_factor,
avg_30d: r.avg_30d, avg_prior_30d: r.avg_prior_30d,
price_momentum_pct: Math.round(priceUp),
hype_phase: hype?.hype_phase || null,
hype_score: hype ? parseFloat(hype.hype_score) : null,
ai_demand_tx: ai ? parseInt(ai.total_tx) : 0,
activeSignals, severity, reasons,
};
})
.filter((r) => r.activeSignals >= 1)
.sort((a, b) => b.activeSignals - a.activeSignals || b.price_momentum_pct - a.price_momentum_pct);
res.json({ success: true, signals, criticalCount: signals.filter(s => s.severity === "critical").length });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/lead-times — Rolling lead-time trends per vendor/speed
// Query params: form_factor, speed_gbps, days (default 90), limit (default 20)
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/lead-times", async (req: Request, res: Response) => {
const days = Math.min(parseInt(req.query.days as string) || 90, 365);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
const ff = req.query.form_factor as string | undefined;
const spd = req.query.speed_gbps as string | undefined;
try {
const [weekly, summary, concentration] = await Promise.all([
// Weekly avg lead time per vendor × speed tier
pool.query(`
SELECT
v.name AS vendor_name,
v.id::text AS vendor_id,
t.speed_gbps::text,
t.form_factor,
DATE_TRUNC('week', ss.scraped_at)::date AS week,
ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_lead_days,
ROUND(MIN(ss.lead_time_days)::numeric, 1) AS min_lead_days,
ROUND(MAX(ss.lead_time_days)::numeric, 1) AS max_lead_days,
COUNT(*)::int AS observations
FROM stock_snapshots ss
JOIN transceivers t ON t.id = ss.transceiver_id
JOIN vendors v ON v.id = ss.source_vendor_id
WHERE ss.scraped_at >= NOW() - INTERVAL '${days} days'
AND ss.lead_time_days IS NOT NULL
AND ss.lead_time_days > 0
${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""}
GROUP BY v.name, v.id, t.speed_gbps, t.form_factor, DATE_TRUNC('week', ss.scraped_at)
ORDER BY week DESC, avg_lead_days DESC
LIMIT 500
`),
// Overall summary: current vs prior-period avg per vendor
pool.query(`
WITH cur AS (
SELECT
v.name AS vendor_name, v.id::text AS vendor_id,
ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_days,
COUNT(*)::int AS obs
FROM stock_snapshots ss
JOIN vendors v ON v.id = ss.source_vendor_id
JOIN transceivers t ON t.id = ss.transceiver_id
WHERE ss.scraped_at >= NOW() - INTERVAL '30 days'
AND ss.lead_time_days > 0
${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""}
GROUP BY v.name, v.id
),
prior AS (
SELECT v.id::text AS vendor_id,
ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_days
FROM stock_snapshots ss
JOIN vendors v ON v.id = ss.source_vendor_id
JOIN transceivers t ON t.id = ss.transceiver_id
WHERE ss.scraped_at >= NOW() - INTERVAL '60 days'
AND ss.scraped_at < NOW() - INTERVAL '30 days'
AND ss.lead_time_days > 0
${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""}
GROUP BY v.id
)
SELECT
c.vendor_name, c.vendor_id,
c.avg_days AS current_30d_avg,
p.avg_days AS prior_30d_avg,
ROUND((c.avg_days - COALESCE(p.avg_days, c.avg_days))::numeric, 1) AS delta_days,
c.obs
FROM cur c
LEFT JOIN prior p ON p.vendor_id = c.vendor_id
ORDER BY c.avg_days DESC
LIMIT ${limit}
`),
// Speed-tier breakdown — which form factors have longest lead times right now
pool.query(`
SELECT
t.speed_gbps::text, t.form_factor,
ROUND(AVG(ss.lead_time_days)::numeric, 1) AS avg_lead_days,
COUNT(DISTINCT ss.source_vendor_id)::int AS vendors_reporting,
COUNT(*)::int AS total_obs
FROM stock_snapshots ss
JOIN transceivers t ON t.id = ss.transceiver_id
WHERE ss.scraped_at >= NOW() - INTERVAL '30 days'
AND ss.lead_time_days > 0
GROUP BY t.speed_gbps, t.form_factor
HAVING COUNT(*) >= 3
ORDER BY avg_lead_days DESC
LIMIT 20
`),
]);
res.json({
success: true,
filters: { days, form_factor: ff || null, speed_gbps: spd || null },
weekly_trend: weekly.rows,
vendor_summary: summary.rows,
speed_tier_breakdown: concentration.rows,
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/supply-concentration — Single-vendor dependency risk
// Flags SKUs where >70% of price observations come from one vendor (30d)
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/supply-concentration", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
WITH obs_30d AS (
SELECT
po.transceiver_id,
po.source_vendor_id,
COUNT(*) AS vendor_obs
FROM price_observations po
WHERE po.time >= NOW() - INTERVAL '30 days'
AND po.price > 0
AND COALESCE(po.is_anomalous, false) = false
GROUP BY po.transceiver_id, po.source_vendor_id
),
totals AS (
SELECT transceiver_id, SUM(vendor_obs) AS total_obs
FROM obs_30d GROUP BY transceiver_id
),
ranked AS (
SELECT
o.transceiver_id,
o.source_vendor_id,
o.vendor_obs,
t.total_obs,
ROUND((o.vendor_obs::numeric / NULLIF(t.total_obs,0)) * 100, 1) AS share_pct,
ROW_NUMBER() OVER (PARTITION BY o.transceiver_id ORDER BY o.vendor_obs DESC) AS rnk
FROM obs_30d o JOIN totals t ON t.transceiver_id = o.transceiver_id
)
SELECT
tx.id::text, tx.part_number, tx.form_factor,
tx.speed_gbps::text,
tx.standard_name,
v.name AS dominant_vendor,
r.share_pct,
r.total_obs::int,
r.vendor_obs::int AS dominant_obs,
CASE
WHEN r.share_pct >= 90 THEN 'critical'
WHEN r.share_pct >= 75 THEN 'high'
ELSE 'medium'
END AS risk_level
FROM ranked r
JOIN transceivers tx ON tx.id = r.transceiver_id
JOIN vendors v ON v.id = r.source_vendor_id
WHERE r.rnk = 1
AND r.share_pct >= 70
AND r.total_obs >= 5
ORDER BY r.share_pct DESC
LIMIT 50
`);
const rows = result.rows;
res.json({
success: true,
concentrated: rows,
stats: {
total_at_risk: rows.length,
critical: rows.filter(r => r.risk_level === "critical").length,
high: rows.filter(r => r.risk_level === "high").length,
medium: rows.filter(r => r.risk_level === "medium").length,
},
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// GET /api/procurement/price-movers — SKUs with biggest price delta vs prior period
procurementRouter.get("/price-movers", async (req: Request, res: Response) => {
const days = Math.min(parseInt(req.query.days as string) || 7, 90);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
try {
const result = await pool.query(`
WITH cur AS (
SELECT transceiver_id, source_vendor_id, currency,
AVG(price) AS avg_price,
COUNT(*) AS obs
FROM price_observations
WHERE time >= NOW() - INTERVAL '${days} days'
AND price > 0 AND COALESCE(is_anomalous, false) = false
GROUP BY transceiver_id, source_vendor_id, currency
),
prior AS (
SELECT transceiver_id, source_vendor_id,
AVG(price) AS avg_price
FROM price_observations
WHERE time >= NOW() - INTERVAL '${days * 2} days'
AND time < NOW() - INTERVAL '${days} days'
AND price > 0 AND COALESCE(is_anomalous, false) = false
GROUP BY transceiver_id, source_vendor_id
)
SELECT
t.id, t.part_number, t.form_factor,
t.speed_gbps::text AS speed_gbps,
t.standard_name,
sv.name AS vendor_name,
ROUND(c.avg_price::numeric, 2) AS current_avg,
ROUND(p.avg_price::numeric, 2) AS prior_avg,
ROUND(((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100)::numeric, 1) AS delta_pct,
c.currency,
c.obs::int AS observations
FROM cur c
JOIN prior p ON p.transceiver_id = c.transceiver_id
AND p.source_vendor_id = c.source_vendor_id
JOIN transceivers t ON t.id = c.transceiver_id
JOIN vendors sv ON sv.id = c.source_vendor_id
WHERE ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) >= 2
AND c.obs::int >= 2
ORDER BY ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) DESC
LIMIT ${limit * 2}
`);
const rows = result.rows;
const gainers = rows.filter((r) => parseFloat(r.delta_pct) > 0).slice(0, limit);
const losers = rows.filter((r) => parseFloat(r.delta_pct) < 0).slice(0, limit);
const avgOf = (arr: typeof gainers, key: string) =>
arr.length ? Math.round(arr.reduce((s, r) => s + parseFloat(r[key]), 0) / arr.length * 10) / 10 : 0;
res.json({
success: true,
days,
gainers,
losers,
stats: {
totalMovers: rows.length,
gainersCount: gainers.length,
losersCount: losers.length,
avgGainPct: avgOf(gainers, "delta_pct"),
avgLossPct: avgOf(losers, "delta_pct"),
},
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});

View File

@ -1,215 +0,0 @@
/**
* RFQ Analyzer POST /api/rfq/analyze
*
* Paste a vendor quote (list of part numbers + quantities + prices) and
* get back: current market rates, cheapest alternative via equivalences,
* total savings opportunity, and per-line delta.
*
* Request body:
* {
* lines: [
* { part_number: string, quantity: number, unit_price: number, currency?: string }
* ],
* currency?: "USD" | "EUR"
* }
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
import { sendCSV } from "../utils/csv";
export const rfqRouter = Router();
interface RfqLine {
part_number: string;
quantity: number;
unit_price: number;
currency?: string;
}
// POST /api/rfq/analyze
rfqRouter.post("/analyze", async (req: Request, res: Response) => {
const { lines, currency: preferredCurrency = "USD" } = req.body as {
lines: RfqLine[];
currency?: string;
};
if (!Array.isArray(lines) || lines.length === 0) {
return res.status(400).json({ success: false, error: "lines[] required" });
}
if (lines.length > 200) {
return res.status(400).json({ success: false, error: "Max 200 lines per RFQ" });
}
try {
const results = await Promise.all(
lines.map(async (line) => {
const pn = String(line.part_number || "").trim();
const qty = Math.max(1, Number(line.quantity) || 1);
const quotedPrice = Number(line.unit_price) || 0;
if (!pn) {
return { part_number: pn, error: "Empty part_number", resolved: false };
}
// 1. Resolve transceiver by part_number or standard_name
const txResult = await pool.query(
`SELECT id, part_number, standard_name, form_factor, speed_gbps, speed
FROM transceivers
WHERE part_number ILIKE $1 OR standard_name ILIKE $1
LIMIT 1`,
[pn]
);
if (txResult.rows.length === 0) {
return { part_number: pn, quantity: qty, quoted_unit_price: quotedPrice, resolved: false, error: "Not found in catalog" };
}
const tx = txResult.rows[0];
// 2. Get current market prices for this transceiver
const marketResult = await pool.query(
`SELECT
v.name AS vendor_name, v.website,
po.price, po.currency, po.stock_level, po.url, po.time AS observed_at
FROM (
SELECT DISTINCT ON (source_vendor_id)
source_vendor_id, price, currency, stock_level, url, time
FROM price_observations
WHERE transceiver_id = $1
AND price > 0
AND COALESCE(is_anomalous, false) = false
ORDER BY source_vendor_id, time DESC
) po
JOIN vendors v ON v.id = po.source_vendor_id
WHERE po.currency = $2 OR po.currency IS NULL
ORDER BY po.price ASC
LIMIT 10`,
[tx.id, preferredCurrency]
);
const prices = marketResult.rows.map(r => parseFloat(r.price)).filter(p => p > 0);
const marketMin = prices.length ? Math.min(...prices) : null;
const marketAvg = prices.length ? Math.round(prices.reduce((a, b) => a + b) / prices.length * 100) / 100 : null;
const cheapestVendor = marketResult.rows[0] || null;
// 3. Find cheapest equivalent via transceiver_equivalences
const equivResult = await pool.query(
`SELECT
te.competitor_id, t2.part_number AS equiv_part_number,
t2.standard_name AS equiv_standard_name,
te.confidence, te.match_basis,
(
SELECT po2.price FROM price_observations po2
WHERE po2.transceiver_id = te.competitor_id
AND po2.currency = $2
AND po2.price > 0
AND COALESCE(po2.is_anomalous, false) = false
ORDER BY po2.time DESC LIMIT 1
) AS equiv_price,
(
SELECT v2.name FROM price_observations po2
JOIN vendors v2 ON v2.id = po2.source_vendor_id
WHERE po2.transceiver_id = te.competitor_id
AND po2.currency = $2 AND po2.price > 0
ORDER BY po2.price ASC, po2.time DESC LIMIT 1
) AS equiv_cheapest_vendor
FROM transceiver_equivalences te
JOIN transceivers t2 ON t2.id = te.competitor_id
WHERE (te.flexoptix_id = $1 OR te.competitor_id = $1)
AND te.status = 'approved'
AND te.confidence >= 0.7
ORDER BY te.confidence DESC
LIMIT 5`,
[tx.id, preferredCurrency]
);
const equivalents = equivResult.rows
.filter(e => e.equiv_price !== null)
.map(e => ({
part_number: e.equiv_part_number,
standard_name: e.equiv_standard_name,
confidence: parseFloat(e.confidence),
match_basis: e.match_basis,
unit_price: parseFloat(e.equiv_price),
vendor: e.equiv_cheapest_vendor,
}));
const cheapestEquiv = equivalents.sort((a, b) => a.unit_price - b.unit_price)[0] || null;
// 4. Calculate savings
const savingsVsMarketMin = marketMin !== null && quotedPrice > 0
? Math.round((quotedPrice - marketMin) * qty * 100) / 100
: null;
const savingsVsEquiv = cheapestEquiv && quotedPrice > 0
? Math.round((quotedPrice - cheapestEquiv.unit_price) * qty * 100) / 100
: null;
return {
part_number: pn,
resolved: true,
transceiver: {
id: tx.id,
standard_name: tx.standard_name,
form_factor: tx.form_factor,
speed: tx.speed,
},
quantity: qty,
quoted_unit_price: quotedPrice,
quoted_total: Math.round(quotedPrice * qty * 100) / 100,
market: {
min_price: marketMin,
avg_price: marketAvg,
vendor_count: prices.length,
cheapest_vendor: cheapestVendor ? {
name: cheapestVendor.vendor_name,
price: parseFloat(cheapestVendor.price),
stock_level: cheapestVendor.stock_level,
url: cheapestVendor.url,
} : null,
},
equivalents,
cheapest_equivalent: cheapestEquiv,
savings: {
vs_market_min: savingsVsMarketMin,
vs_equiv: savingsVsEquiv,
best_saving: Math.max(savingsVsMarketMin || 0, savingsVsEquiv || 0) || null,
},
currency: preferredCurrency,
};
})
);
// Aggregate totals
const resolved = results.filter(r => r.resolved);
const totalQuoted = resolved.reduce((s, r) => s + (r.quoted_total as number || 0), 0);
const totalSavingsMarket = resolved.reduce((s, r) => {
const sv = (r.savings as any)?.vs_market_min;
return s + (sv && sv > 0 ? sv : 0);
}, 0);
const totalSavingsEquiv = resolved.reduce((s, r) => {
const sv = (r.savings as any)?.vs_equiv;
return s + (sv && sv > 0 ? sv : 0);
}, 0);
res.json({
success: true,
currency: preferredCurrency,
line_count: lines.length,
resolved_count: resolved.length,
lines: results,
totals: {
quoted: Math.round(totalQuoted * 100) / 100,
potential_savings_vs_market: Math.round(totalSavingsMarket * 100) / 100,
potential_savings_vs_equiv: Math.round(totalSavingsEquiv * 100) / 100,
best_total_saving: Math.round(Math.max(totalSavingsMarket, totalSavingsEquiv) * 100) / 100,
},
});
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});
// GET /api/rfq/export — Export last RFQ result as CSV (pass lines as query params)
rfqRouter.get("/export", async (req: Request, res: Response) => {
res.status(405).json({ error: "Use POST /api/rfq/analyze with ?format=csv to export" });
});

View File

@ -1,178 +0,0 @@
/**
* ROI Calculator /api/roi
*
* Calculates total cost of ownership and switching savings for transceiver decisions.
*
* POST /api/roi/calculate
* Input:
* {
* ports: number, Number of ports to equip
* current_price_per_port: number,
* target_form_factor?: string,
* target_speed_gbps?: number,
* years?: number, TCO horizon (default 3)
* switch_cost?: number, One-time switching cost (labor, downtime est.)
* currency?: string
* }
* Output:
* - Current total cost (ports × price)
* - Market min/avg for target spec
* - 1/2/3 year TCO at current vs market-min price
* - Savings over TCO horizon
* - Break-even months
* - Top 5 cheapest vendors for the target spec
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const roiRouter = Router();
roiRouter.post("/calculate", async (req: Request, res: Response) => {
const {
ports,
current_price_per_port,
target_form_factor,
target_speed_gbps,
years = 3,
switch_cost = 0,
currency = "USD",
} = req.body as Record<string, any>;
if (!ports || isNaN(parseInt(ports)) || parseInt(ports) <= 0) {
return res.status(400).json({ success: false, error: "ports must be a positive integer" });
}
if (!current_price_per_port || isNaN(parseFloat(current_price_per_port))) {
return res.status(400).json({ success: false, error: "current_price_per_port required" });
}
if (!target_form_factor && !target_speed_gbps) {
return res.status(400).json({ success: false, error: "Provide target_form_factor and/or target_speed_gbps" });
}
const portCount = parseInt(ports);
const currentPrice = parseFloat(current_price_per_port);
const switchingCost = parseFloat(switch_cost) || 0;
const tcoYears = Math.min(Math.max(parseInt(years) || 3, 1), 10);
const curr = String(currency).toUpperCase();
try {
// Find market prices for target spec
const marketResult = await pool.query(`
WITH latest AS (
SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id)
po.transceiver_id, po.source_vendor_id, po.price, po.currency, po.stock_level, po.url, po.time
FROM price_observations po
WHERE po.price > 0
AND COALESCE(po.is_anomalous, false) = false
AND po.currency = $1
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
)
SELECT
v.name AS vendor_name,
v.website,
t.standard_name,
t.form_factor,
t.speed_gbps::text,
ROUND(MIN(l.price)::numeric, 2) AS min_price,
ROUND(AVG(l.price)::numeric, 2) AS avg_price,
COUNT(DISTINCT l.source_vendor_id)::int AS vendor_count,
l.price AS vendor_price,
l.stock_level,
l.url
FROM latest l
JOIN transceivers t ON t.id = l.transceiver_id
JOIN vendors v ON v.id = l.source_vendor_id
WHERE ($2::text IS NULL OR t.form_factor = $2)
AND ($3::numeric IS NULL OR t.speed_gbps = $3)
GROUP BY v.name, v.website, t.standard_name, t.form_factor, t.speed_gbps,
l.price, l.stock_level, l.url
ORDER BY l.price ASC
LIMIT 50
`, [curr, target_form_factor || null, target_speed_gbps ? parseFloat(target_speed_gbps) : null]);
const allPrices = marketResult.rows.map(r => parseFloat(r.vendor_price)).filter(p => p > 0);
const marketMin = allPrices.length ? Math.min(...allPrices) : null;
const marketAvg = allPrices.length ? Math.round(allPrices.reduce((a,b) => a+b) / allPrices.length * 100) / 100 : null;
// Top 5 cheapest vendors (distinct vendors)
const vendorPrices = new Map<string, { vendor_name: string; price: number; stock_level: string; url: string; standard_name: string }>();
for (const r of marketResult.rows) {
if (!vendorPrices.has(r.vendor_name)) {
vendorPrices.set(r.vendor_name, {
vendor_name: r.vendor_name,
price: parseFloat(r.vendor_price),
stock_level: r.stock_level,
url: r.url,
standard_name: r.standard_name,
});
}
}
const top5Vendors = [...vendorPrices.values()].slice(0, 5);
// TCO calculations
const currentTotal = Math.round(portCount * currentPrice * 100) / 100;
const marketMinTotal = marketMin !== null ? Math.round(portCount * marketMin * 100) / 100 : null;
const marketAvgTotal = marketAvg !== null ? Math.round(portCount * marketAvg * 100) / 100 : null;
// Annual OpEx: assume 15% of hardware cost for maintenance/replacement (industry standard)
const annualOpExFactor = 0.15;
const currentTCO = Math.round((currentTotal + (currentTotal * annualOpExFactor * tcoYears)) * 100) / 100;
const targetTCOMin = marketMinTotal !== null
? Math.round((marketMinTotal + switchingCost + (marketMinTotal * annualOpExFactor * tcoYears)) * 100) / 100
: null;
const targetTCOAvg = marketAvgTotal !== null
? Math.round((marketAvgTotal + switchingCost + (marketAvgTotal * annualOpExFactor * tcoYears)) * 100) / 100
: null;
const savingsMin = targetTCOMin !== null ? Math.round((currentTCO - targetTCOMin) * 100) / 100 : null;
const savingsAvg = targetTCOAvg !== null ? Math.round((currentTCO - targetTCOAvg) * 100) / 100 : null;
// Break-even: months until switching cost is recovered from per-unit savings
const monthlyHardwareSavingsMin = marketMin !== null
? Math.round(portCount * (currentPrice - marketMin) / 12 * 100) / 100
: null;
const breakEvenMonths = monthlyHardwareSavingsMin && monthlyHardwareSavingsMin > 0 && switchingCost > 0
? Math.ceil(switchingCost / monthlyHardwareSavingsMin)
: 0;
return res.json({
success: true,
input: {
ports: portCount, current_price_per_port: currentPrice,
target_form_factor: target_form_factor || null,
target_speed_gbps: target_speed_gbps ? parseFloat(target_speed_gbps) : null,
tco_years: tcoYears, switch_cost: switchingCost, currency: curr,
},
current: {
total_hardware: currentTotal,
tco_estimate: currentTCO,
price_per_port: currentPrice,
},
market: {
min_price: marketMin,
avg_price: marketAvg,
vendor_count: allPrices.length,
min_total_hardware: marketMinTotal,
avg_total_hardware: marketAvgTotal,
},
tco_comparison: {
current: currentTCO,
target_min: targetTCOMin,
target_avg: targetTCOAvg,
savings_vs_min: savingsMin,
savings_vs_avg: savingsAvg,
savings_pct_min: savingsMin && currentTCO > 0
? Math.round(savingsMin / currentTCO * 1000) / 10
: null,
},
switching: {
cost: switchingCost,
break_even_months: breakEvenMonths || null,
monthly_savings: monthlyHardwareSavingsMin,
},
top_vendors: top5Vendors,
currency: curr,
});
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -8,7 +8,6 @@
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { semanticSearch, getCollectionInfo, CollectionName } from "../embeddings/client"; import { semanticSearch, getCollectionInfo, CollectionName } from "../embeddings/client";
import { searchTransceivers } from "../db/queries";
export const searchRouter = Router(); export const searchRouter = Router();
@ -44,20 +43,11 @@ searchRouter.get("/", async (req: Request, res: Response) => {
} }
try { try {
let results: any[]; const results = await semanticSearch(collection, query, limit);
let usedFallback = false;
if (collection === "product_embeddings") {
const fts = await searchTransceivers({ q: query, limit });
results = (((fts as any).data) || []).map((t: any) => ({ id: t.id, score: 0.5, payload: t }));
usedFallback = true;
} else {
results = await semanticSearch(collection, query, limit);
}
res.json({ res.json({
success: true, success: true,
query, query,
collection, collection,
fallback: usedFallback ? "fts" : undefined,
results: results.map((r) => ({ results: results.map((r) => ({
id: r.id, id: r.id,
score: Math.round(r.score * 1000) / 1000, score: Math.round(r.score * 1000) / 1000,
@ -66,14 +56,6 @@ searchRouter.get("/", async (req: Request, res: Response) => {
count: results.length, count: results.length,
}); });
} catch (err) { } catch (err) {
if (collection === "product_embeddings") {
try {
const fts = await searchTransceivers({ q: query, limit });
const results = (((fts as any).data) || []).map((t: any) => ({ id: t.id, score: 0.5, ...t }));
res.json({ success: true, query, collection, fallback: "fts", results, count: results.length });
return;
} catch (e2) { /* fall through */ }
}
res.status(503).json({ res.status(503).json({
success: false, success: false,
error: "Vector search unavailable", error: "Vector search unavailable",

View File

@ -7,8 +7,6 @@
* Routes: * Routes:
* GET /api/stock Latest obs per transceiver × vendor (paginated) * GET /api/stock Latest obs per transceiver × vendor (paginated)
* GET /api/stock/summary Aggregate warehouse stats (totals, top movers) * GET /api/stock/summary Aggregate warehouse stats (totals, top movers)
* GET /api/stock/velocity Abverkauf velocity results (paginated, filterable)
* GET /api/stock/velocity/:id Velocity + event history for one transceiver
* GET /api/stock/:transceiverIdOrSku Full obs history for one transceiver * GET /api/stock/:transceiverIdOrSku Full obs history for one transceiver
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
@ -285,239 +283,6 @@ stockRouter.get("/summary", async (req: Request, res: Response) => {
} }
}); });
// ─── GET /api/stock/velocity ─────────────────────────────────────────────────
/**
* Paginated Abverkauf velocity results from the stock_velocity table.
* Query params:
* vendor_id filter by vendor UUID
* confidence "high" | "medium" | "low" | "insufficient"
* stockout_days only products with estimated_stockout_days <= N (0 = already out)
* min_sell_rate minimum avg_daily_sell_rate
* part_number partial match
* limit default 50, max 200
* offset default 0
*/
stockRouter.get("/velocity", async (req: Request, res: Response) => {
try {
const limit = Math.min(intParam(req, "limit", 50), 200);
const offset = intParam(req, "offset", 0);
const vendorId = req.query.vendor_id ? String(req.query.vendor_id) : null;
const confidence = req.query.confidence ? String(req.query.confidence) : null;
const stockoutDays = req.query.stockout_days !== undefined
? parseInt(String(req.query.stockout_days), 10)
: null;
const minSellRate = req.query.min_sell_rate
? parseFloat(String(req.query.min_sell_rate))
: null;
const partNumber = req.query.part_number ? String(req.query.part_number) : null;
const conditions: string[] = [];
const params: unknown[] = [];
let p = 1;
if (vendorId) {
conditions.push(`sv.vendor_id = $${p++}`);
params.push(vendorId);
}
if (confidence) {
conditions.push(`sv.velocity_confidence = $${p++}`);
params.push(confidence);
}
if (stockoutDays !== null && Number.isFinite(stockoutDays)) {
conditions.push(`sv.estimated_stockout_days <= $${p++}`);
params.push(stockoutDays);
}
if (minSellRate !== null && Number.isFinite(minSellRate)) {
conditions.push(`sv.avg_daily_sell_rate >= $${p++}`);
params.push(minSellRate);
}
if (partNumber) {
conditions.push(`t.part_number ILIKE $${p++}`);
params.push(`%${partNumber}%`);
}
const whereClause = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT
sv.transceiver_id,
sv.vendor_id,
sv.computed_at,
sv.window_start,
sv.window_end,
sv.obs_count,
sv.avg_daily_sell_rate,
sv.peak_daily_sell_rate,
sv.total_sell_events,
sv.total_units_sold_implied,
sv.units_sold_counter_delta,
sv.units_sold_daily_rate,
sv.total_zulauf_events,
sv.total_units_zulauf,
sv.last_zulauf_at,
sv.next_expected_delivery,
sv.current_qty,
sv.current_backorder_qty,
sv.current_price_net,
sv.estimated_stockout_days,
sv.estimated_stockout_date,
sv.velocity_confidence,
t.part_number,
t.form_factor,
t.speed,
v.name AS vendor_name,
v.website AS vendor_website
FROM stock_velocity sv
JOIN transceivers t ON t.id = sv.transceiver_id
JOIN vendors v ON v.id = sv.vendor_id
${whereClause}
ORDER BY
CASE sv.velocity_confidence
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
WHEN 'insufficient' THEN 4
ELSE 5
END,
sv.avg_daily_sell_rate DESC NULLS LAST
LIMIT $${p++} OFFSET $${p++}
`;
params.push(limit, offset);
const countSql = `
SELECT COUNT(*)
FROM stock_velocity sv
JOIN transceivers t ON t.id = sv.transceiver_id
JOIN vendors v ON v.id = sv.vendor_id
${whereClause}
`;
const [rows, countRow] = await Promise.all([
pool.query(sql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
res.json({
success: true,
data: rows.rows,
meta: {
total: parseInt(countRow.rows[0].count, 10),
limit,
offset,
},
});
} catch (err) {
console.error("GET /api/stock/velocity error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// ─── GET /api/stock/velocity/:id ─────────────────────────────────────────────
/**
* Velocity summary + raw event history for one transceiver.
* :id can be a UUID or part_number (case-insensitive).
* Query params:
* vendor_id filter to a specific vendor (optional; returns all vendors if omitted)
* event_limit max events returned per vendor (default 200)
*/
stockRouter.get("/velocity/:id", async (req: Request, res: Response) => {
try {
const id = String(req.params.id);
const vendorId = req.query.vendor_id ? String(req.query.vendor_id) : null;
const eventLimit = Math.min(intParam(req, "event_limit", 200), 1000);
// Resolve UUID vs part_number
let transceiverUuid: string | null = null;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidRegex.test(id)) {
transceiverUuid = id;
} else {
const r = await pool.query(
`SELECT id FROM transceivers WHERE part_number ILIKE $1 LIMIT 1`,
[id]
);
if (r.rows.length > 0) transceiverUuid = r.rows[0].id;
}
if (!transceiverUuid) {
res.status(404).json({ success: false, error: "Transceiver not found" });
return;
}
const velocityParams: unknown[] = [transceiverUuid];
let vendorFilter = "";
if (vendorId) {
velocityParams.push(vendorId);
vendorFilter = `AND sv.vendor_id = $${velocityParams.length}`;
}
const eventParams: unknown[] = [transceiverUuid, eventLimit];
let eventVendorFilter = "";
if (vendorId) {
eventParams.push(vendorId);
eventVendorFilter = `AND sve.vendor_id = $${eventParams.length}`;
}
const [transceiver, velocity, events] = await Promise.all([
pool.query(
`SELECT t.*, v.name AS brand_name
FROM transceivers t LEFT JOIN vendors v ON v.id = t.brand_vendor_id
WHERE t.id = $1`,
[transceiverUuid]
),
pool.query(
`SELECT
sv.*,
v.name AS vendor_name,
v.website AS vendor_website
FROM stock_velocity sv
JOIN vendors v ON v.id = sv.vendor_id
WHERE sv.transceiver_id = $1 ${vendorFilter}
ORDER BY sv.velocity_confidence, sv.avg_daily_sell_rate DESC NULLS LAST`,
velocityParams
),
pool.query(
`SELECT
sve.event_at,
sve.event_type,
sve.units_delta,
sve.daily_rate,
sve.qty_before,
sve.qty_after,
sve.hours_elapsed,
v.name AS vendor_name
FROM stock_velocity_events sve
JOIN vendors v ON v.id = sve.vendor_id
WHERE sve.transceiver_id = $1 ${eventVendorFilter}
ORDER BY sve.event_at DESC
LIMIT $2`,
eventParams
),
]);
if (!transceiver.rows[0]) {
res.status(404).json({ success: false, error: "Transceiver not found" });
return;
}
res.json({
success: true,
data: {
transceiver: transceiver.rows[0],
velocity: velocity.rows,
events: events.rows,
meta: {
velocity_count: velocity.rows.length,
event_count: events.rows.length,
},
},
});
} catch (err) {
console.error("GET /api/stock/velocity/:id error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
}
});
// ─── GET /api/stock/:id ────────────────────────────────────────────────────── // ─── GET /api/stock/:id ──────────────────────────────────────────────────────
/** /**
* Full observation history for one transceiver. * Full observation history for one transceiver.

View File

@ -1,146 +0,0 @@
import { Router } from "express";
import { standardsLessons } from "../data/training/standards";
import { formFactorLessons } from "../data/training/form-factors";
import { switchesLessons } from "../data/training/switches";
import { infrastructureLessons } from "../data/training/infrastructure";
import { testingBuyingLessons } from "../data/training/testing-buying";
import type { TrainingLesson } from "../data/training/types";
export const trainingRouter = Router();
// ── Aggregate all lessons ────────────────────────────────────────────────────
const ALL_LESSONS: TrainingLesson[] = [
...standardsLessons,
...formFactorLessons,
...switchesLessons,
...infrastructureLessons,
...testingBuyingLessons,
];
// ── Category metadata ────────────────────────────────────────────────────────
const CATEGORIES = [
{
id: "standards",
title: "Standards",
title_de: "Standards",
icon: "📋",
description: "IEEE 802.3, MSA, DOM/DDMI — the foundation of optical networking",
description_de: "IEEE 802.3, MSA, DOM/DDMI — das Fundament optischer Netzwerke",
},
{
id: "form-factors",
title: "Form Factors",
title_de: "Formfaktoren",
icon: "🔌",
description: "SFP, QSFP-DD, OSFP, connectors — physical housings and interfaces",
description_de: "SFP, QSFP-DD, OSFP, Stecker — physikalische Gehäuse und Interfaces",
},
{
id: "switches",
title: "Switches & Compatibility",
title_de: "Switches & Kompatibilität",
icon: "🖥️",
description: "Vendor lock, FEC configuration, Cisco / Juniper / Arista CLI",
description_de: "Vendor-Lock, FEC-Konfiguration, Cisco / Juniper / Arista CLI",
},
{
id: "infrastructure",
title: "Fiber & Infrastructure",
title_de: "Glasfaser & Infrastruktur",
icon: "🔧",
description: "MMF/SMF grades, link budget math, MPO, WDM wavelength planning",
description_de: "MMF/SMF-Typen, Link-Budget-Berechnung, MPO, WDM-Wellenlägenplanung",
},
{
id: "testing-buying",
title: "Testing & Buying",
title_de: "Testen & Einkaufen",
icon: "🔬",
description: "Test equipment, OEM vs. compatible, datasheet interpretation, TCO",
description_de: "Messgeräte, OEM vs. kompatibel, Datenblatt-Interpretation, TCO",
},
];
// ── GET /api/training/categories ─────────────────────────────────────────────
trainingRouter.get("/categories", (_req, res) => {
const result = CATEGORIES.map((cat) => ({
...cat,
lesson_count: ALL_LESSONS.filter((l) => l.category === cat.id).length,
total_duration_min: ALL_LESSONS
.filter((l) => l.category === cat.id)
.reduce((sum, l) => sum + l.duration_min, 0),
quiz_count: ALL_LESSONS
.filter((l) => l.category === cat.id)
.reduce((sum, l) => sum + l.quiz.length, 0),
}));
res.json(result);
});
// ── GET /api/training/lessons?category= ──────────────────────────────────────
trainingRouter.get("/lessons", (req, res) => {
const { category } = req.query;
let lessons = ALL_LESSONS;
if (category && typeof category === "string") {
lessons = lessons.filter((l) => l.category === category);
}
// Return metadata only — omit heavy sections + quiz content
res.json(
lessons.map(({ id, category, title, title_de, level, duration_min, summary, summary_de, tags }) => ({
id,
category,
title,
title_de,
level,
duration_min,
summary,
summary_de,
tags,
}))
);
});
// ── GET /api/training/lessons/:id ────────────────────────────────────────────
trainingRouter.get("/lessons/:id", (req, res) => {
const lesson = ALL_LESSONS.find((l) => l.id === req.params.id);
if (!lesson) {
return res.status(404).json({ error: "Lesson not found" });
}
return res.json(lesson);
});
// ── GET /api/training/quiz?lesson=&category= ─────────────────────────────────
trainingRouter.get("/quiz", (req, res) => {
const { lesson, category } = req.query;
let questions = ALL_LESSONS.flatMap((l) => l.quiz);
if (lesson && typeof lesson === "string") {
questions = questions.filter((q) => q.lesson === lesson);
} else if (category && typeof category === "string") {
const catLessonIds = ALL_LESSONS.filter((l) => l.category === category).map((l) => l.id);
questions = questions.filter((q) => catLessonIds.includes(q.lesson));
}
res.json(questions);
});
// ── GET /api/training/stats ───────────────────────────────────────────────────
trainingRouter.get("/stats", (_req, res) => {
res.json({
total_lessons: ALL_LESSONS.length,
total_quiz_questions: ALL_LESSONS.reduce((s, l) => s + l.quiz.length, 0),
total_duration_min: ALL_LESSONS.reduce((s, l) => s + l.duration_min, 0),
categories: CATEGORIES.length,
levels: {
beginner: ALL_LESSONS.filter((l) => l.level === "beginner").length,
intermediate: ALL_LESSONS.filter((l) => l.level === "intermediate").length,
advanced: ALL_LESSONS.filter((l) => l.level === "advanced").length,
},
});
});

View File

@ -1,192 +0,0 @@
/**
* Vendor Reliability Scores Redesigned (v2)
*
* Scoring methodology (100 pts total):
* 30 pts Data Freshness: How recently were prices scraped?
* 25 pts SKU Coverage: How many unique transceivers covered in 60d?
* 25 pts Price Consistency: Price anomaly rate (low anomalies = reliable)
* 20 pts Stock Accuracy: Does vendor report stock status? How often in_stock?
*
* Routes:
* GET /api/vendor-reliability All vendor scores
* GET /api/vendor-reliability/:id Single vendor detail + breakdown
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const vendorReliabilityRouter = Router();
async function computeReliability() {
const result = await pool.query(`
WITH
-- Freshness + volume (30 pts)
freshness AS (
SELECT
po.source_vendor_id AS vendor_id,
MAX(po.time) AS last_seen,
EXTRACT(EPOCH FROM (NOW() - MAX(po.time))) / 86400.0 AS days_since_last,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '7 days') AS obs_7d
FROM price_observations po
WHERE po.time >= NOW() - INTERVAL '90 days'
GROUP BY po.source_vendor_id
),
-- SKU coverage breadth (25 pts)
coverage AS (
SELECT
po.source_vendor_id AS vendor_id,
COUNT(DISTINCT po.transceiver_id) AS skus_60d,
COUNT(DISTINCT po.transceiver_id)
FILTER (WHERE po.time >= NOW() - INTERVAL '7 days') AS skus_7d
FROM price_observations po
WHERE po.time >= NOW() - INTERVAL '60 days'
AND po.price > 0
GROUP BY po.source_vendor_id
),
-- Price consistency: anomaly rate (25 pts)
consistency AS (
SELECT
po.source_vendor_id AS vendor_id,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS total_30d,
COUNT(*) FILTER (
WHERE po.time >= NOW() - INTERVAL '30 days'
AND COALESCE(po.is_anomalous, false) = true
) AS anomalies_30d,
-- Price variance: std dev / mean (coefficient of variation, lower = more stable)
CASE WHEN AVG(po.price) > 0 THEN
ROUND((STDDEV(po.price) / AVG(po.price) * 100)::numeric, 1)
END AS price_cv_pct
FROM price_observations po
WHERE po.time >= NOW() - INTERVAL '30 days'
AND po.price > 0
GROUP BY po.source_vendor_id
),
-- Stock accuracy: does vendor report stock? reliability of in_stock flag (20 pts)
stock_acc AS (
SELECT
so.source_vendor_id AS vendor_id,
COUNT(*)::int AS stock_obs,
COUNT(*) FILTER (WHERE so.in_stock = true)::int AS in_stock_count,
COUNT(*) FILTER (WHERE so.in_stock = false)::int AS out_of_stock_count
FROM stock_observations so
WHERE so.time >= NOW() - INTERVAL '30 days'
GROUP BY so.source_vendor_id
)
SELECT
v.id::text AS vendor_id,
v.name AS vendor_name,
v.type,
v.website,
-- Raw metrics
f.last_seen,
f.days_since_last,
f.obs_30d,
f.obs_7d,
c.skus_60d,
c.skus_7d,
co.total_30d,
co.anomalies_30d,
co.price_cv_pct,
s.stock_obs,
s.in_stock_count,
s.out_of_stock_count
FROM freshness f
JOIN vendors v ON v.id = f.vendor_id
LEFT JOIN coverage c ON c.vendor_id = f.vendor_id
LEFT JOIN consistency co ON co.vendor_id = f.vendor_id
LEFT JOIN stock_acc s ON s.vendor_id = f.vendor_id
ORDER BY f.last_seen DESC
`);
return result.rows.map(row => {
// ── Freshness score (30 pts) ──────────────────────────────────────────
const days = parseFloat(row.days_since_last || "999");
const freshnessScore =
days <= 1 ? 30 :
days <= 3 ? 27 :
days <= 7 ? 22 :
days <= 14 ? 15 :
days <= 30 ? 8 : 0;
// ── Coverage score (25 pts) ───────────────────────────────────────────
const skus60d = parseInt(row.skus_60d || "0");
const MAX_SKUS = 1000;
const coverageScore = Math.min(Math.round((skus60d / MAX_SKUS) * 25), 25);
// ── Consistency score (25 pts) — low anomaly rate = good ─────────────
const total30d = parseInt(row.total_30d || "0");
const anomalies30d = parseInt(row.anomalies_30d || "0");
const anomalyRate = total30d > 0 ? anomalies30d / total30d : 0;
const consistencyScore =
total30d === 0 ? 0 :
anomalyRate <= 0.01 ? 25 :
anomalyRate <= 0.03 ? 20 :
anomalyRate <= 0.05 ? 15 :
anomalyRate <= 0.10 ? 10 : 5;
// ── Stock accuracy score (20 pts) ─────────────────────────────────────
const stockObs = parseInt(row.stock_obs || "0");
const stockScore = Math.min(Math.round((stockObs / 100) * 20), 20);
const totalScore = freshnessScore + coverageScore + consistencyScore + stockScore;
const grade =
totalScore >= 85 ? "A" :
totalScore >= 70 ? "B" :
totalScore >= 50 ? "C" :
totalScore >= 30 ? "D" : "F";
return {
vendor_id: row.vendor_id,
vendor_name: row.vendor_name,
type: row.type,
website: row.website,
reliability_score: totalScore,
grade,
breakdown: {
freshness: { score: freshnessScore, max: 30, days_since_last: Math.round(days * 10) / 10 },
coverage: { score: coverageScore, max: 25, skus_60d: skus60d, skus_7d: parseInt(row.skus_7d || "0") },
consistency: { score: consistencyScore, max: 25, anomaly_rate_pct: Math.round(anomalyRate * 1000) / 10, obs_30d: total30d },
stock_accuracy: { score: stockScore, max: 20, stock_obs_30d: stockObs, in_stock: parseInt(row.in_stock_count || "0") },
},
last_seen: row.last_seen,
obs_30d: parseInt(row.obs_30d || "0"),
};
});
}
// ── GET /api/vendor-reliability ────────────────────────────────────────────
vendorReliabilityRouter.get("/", async (_req: Request, res: Response) => {
try {
const vendors = await computeReliability();
vendors.sort((a, b) => b.reliability_score - a.reliability_score);
res.json({ success: true, vendors, scored_at: new Date().toISOString() });
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/vendor-reliability/:id — Single vendor deep-dive ──────────────
vendorReliabilityRouter.get("/:id", async (req: Request, res: Response) => {
try {
const all = await computeReliability();
const vendor = all.find(v => v.vendor_id === req.params.id);
if (!vendor) return res.status(404).json({ success: false, error: "Vendor not found" });
// Price history for sparkline
const priceHistory = await pool.query(`
SELECT DATE_TRUNC('week', time)::date AS week,
ROUND(AVG(price)::numeric, 2) AS avg_price,
COUNT(*)::int AS observations
FROM price_observations
WHERE source_vendor_id = $1::uuid
AND time >= NOW() - INTERVAL '90 days'
AND price > 0
GROUP BY DATE_TRUNC('week', time)
ORDER BY week ASC
`, [req.params.id]).catch(() => ({ rows: [] }));
res.json({ success: true, vendor, price_history: priceHistory.rows });
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -136,164 +136,3 @@ vendorRouter.get("/:id", async (req: Request, res: Response) => {
return res.status(500).json({ success: false, error: "Internal server error" }); return res.status(500).json({ success: false, error: "Internal server error" });
} }
}); });
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/vendors/market-share — Weekly SKU-coverage share per vendor over time
// Shows which vendors are gaining/losing market presence
// Query params: speed_gbps, form_factor, days (default 90)
// ─────────────────────────────────────────────────────────────────────────────
vendorRouter.get("/market-share", async (req: Request, res: Response) => {
const days = Math.min(parseInt(req.query.days as string) || 90, 365);
const spd = req.query.speed_gbps as string | undefined;
const ff = req.query.form_factor as string | undefined;
try {
const [weekly, current, momentum] = await Promise.all([
// Weekly SKU count per vendor — shows growth/shrink trends
pool.query(`
SELECT
DATE_TRUNC('week', po.time)::date AS week,
v.id::text AS vendor_id,
v.name AS vendor_name,
COUNT(DISTINCT po.transceiver_id)::int AS sku_count
FROM price_observations po
JOIN vendors v ON v.id = po.source_vendor_id
JOIN transceivers t ON t.id = po.transceiver_id
WHERE po.time >= NOW() - INTERVAL '${days} days'
AND po.price > 0
AND COALESCE(po.is_anomalous, false) = false
${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""}
${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
GROUP BY DATE_TRUNC('week', po.time), v.id, v.name
ORDER BY week ASC, sku_count DESC
`),
// Current snapshot: SKU share % per vendor (last 30d)
pool.query(`
WITH totals AS (
SELECT COUNT(DISTINCT transceiver_id)::float AS total
FROM price_observations
WHERE time >= NOW() - INTERVAL '30 days'
AND price > 0 AND COALESCE(is_anomalous, false) = false
)
SELECT
v.id::text AS vendor_id,
v.name AS vendor_name,
v.type,
COUNT(DISTINCT po.transceiver_id)::int AS sku_count,
ROUND((COUNT(DISTINCT po.transceiver_id)::numeric / NULLIF(t.total,0)) * 100, 1) AS market_share_pct,
COUNT(po.id)::int AS total_obs,
MAX(po.time) AS last_seen
FROM price_observations po
JOIN vendors v ON v.id = po.source_vendor_id
JOIN transceivers tx ON tx.id = po.transceiver_id
CROSS JOIN totals t
WHERE po.time >= NOW() - INTERVAL '30 days'
AND po.price > 0
AND COALESCE(po.is_anomalous, false) = false
${spd ? `AND tx.speed_gbps = ${parseFloat(spd)}` : ""}
${ff ? `AND tx.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
GROUP BY v.id, v.name, v.type, t.total
ORDER BY sku_count DESC
LIMIT 30
`),
// Momentum: compare last 30d vs prior 30d SKU count per vendor
pool.query(`
WITH cur AS (
SELECT source_vendor_id, COUNT(DISTINCT transceiver_id)::int AS sku_count
FROM price_observations po
JOIN transceivers t ON t.id = po.transceiver_id
WHERE po.time >= NOW() - INTERVAL '30 days'
AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false
${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""}
${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
GROUP BY source_vendor_id
),
prior AS (
SELECT source_vendor_id, COUNT(DISTINCT transceiver_id)::int AS sku_count
FROM price_observations po
JOIN transceivers t ON t.id = po.transceiver_id
WHERE po.time >= NOW() - INTERVAL '60 days'
AND po.time < NOW() - INTERVAL '30 days'
AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false
${spd ? `AND t.speed_gbps = ${parseFloat(spd)}` : ""}
${ff ? `AND t.form_factor = '${ff.replace(/'/g,"''")}'` : ""}
GROUP BY source_vendor_id
)
SELECT
v.name AS vendor_name, v.id::text AS vendor_id,
c.sku_count AS current_skus,
COALESCE(p.sku_count, 0) AS prior_skus,
(c.sku_count - COALESCE(p.sku_count, 0)) AS delta_skus,
CASE
WHEN COALESCE(p.sku_count, 0) = 0 THEN NULL
ELSE ROUND(((c.sku_count - p.sku_count)::numeric / p.sku_count) * 100, 1)
END AS delta_pct
FROM cur c
JOIN vendors v ON v.id = c.source_vendor_id
LEFT JOIN prior p ON p.source_vendor_id = c.source_vendor_id
ORDER BY delta_skus DESC
LIMIT 20
`),
]);
// Compute share % per week for chart (normalize across vendors per week)
const weekTotals = new Map<string, number>();
for (const row of weekly.rows) {
const k = row.week;
weekTotals.set(k, (weekTotals.get(k) || 0) + row.sku_count);
}
const weeklyWithShare = weekly.rows.map(r => ({
...r,
share_pct: weekTotals.get(r.week)
? Math.round((r.sku_count / weekTotals.get(r.week)!) * 1000) / 10
: 0,
}));
res.json({
success: true,
filters: { days, speed_gbps: spd || null, form_factor: ff || null },
weekly_trend: weeklyWithShare,
current_share: current.rows,
momentum: momentum.rows,
});
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});
// GET /api/vendors/intelligence — per-vendor price + SKU market stats (last 30d)
vendorRouter.get("/intelligence", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
v.id,
v.name,
v.type,
v.website,
COUNT(DISTINCT po.transceiver_id)::int AS sku_count,
COUNT(po.id)::int AS price_obs,
ROUND(AVG(po.price)::numeric, 2) AS avg_price,
ROUND(MIN(po.price)::numeric, 2) AS min_price,
ROUND(MAX(po.price)::numeric, 2) AS max_price,
MAX(po.time) AS last_seen,
(SELECT currency FROM price_observations
WHERE source_vendor_id = v.id
ORDER BY time DESC LIMIT 1) AS currency
FROM vendors v
LEFT JOIN price_observations po
ON po.source_vendor_id = v.id
AND po.time > NOW() - INTERVAL '30 days'
AND po.price > 0
AND COALESCE(po.is_anomalous, false) = false
GROUP BY v.id, v.name, v.type, v.website
HAVING COUNT(DISTINCT po.transceiver_id) > 0
ORDER BY COUNT(DISTINCT po.transceiver_id) DESC
LIMIT 60
`);
res.json({ success: true, data: result.rows });
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -1,189 +0,0 @@
/**
* Win/Loss Intelligence /api/win-loss
*
* Record and analyze deal outcomes: who won, who lost, at what price, in which segment.
*
* Routes:
* POST /api/win-loss Record a win/loss event
* GET /api/win-loss List events (filterable)
* GET /api/win-loss/summary Aggregate win rate, avg price delta, segments
* GET /api/win-loss/competitors Ranking by competitor vendor (loss analysis)
*/
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
import { sendCSV } from "../utils/csv";
export const winLossRouter = Router();
// ── POST /api/win-loss — Record a deal outcome ──────────────────────────────
winLossRouter.post("/", async (req: Request, res: Response) => {
const {
outcome, transceiver_id, competitor_vendor,
our_price, competitor_price, currency = "USD",
quantity, customer_segment, deal_source,
form_factor, speed_gbps, notes, deal_date,
} = req.body as Record<string, any>;
if (!outcome || !["won","lost","unknown"].includes(outcome)) {
return res.status(400).json({ success: false, error: "outcome must be: won | lost | unknown" });
}
try {
const result = await pool.query(
`INSERT INTO win_loss_events
(outcome, transceiver_id, competitor_vendor, our_price, competitor_price,
currency, quantity, customer_segment, deal_source, form_factor, speed_gbps, notes, deal_date)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
COALESCE($13::date, CURRENT_DATE))
RETURNING *`,
[
outcome,
transceiver_id || null,
competitor_vendor || null,
our_price ? parseFloat(our_price) : null,
competitor_price ? parseFloat(competitor_price) : null,
currency,
quantity ? parseInt(quantity) : null,
customer_segment || null,
deal_source || null,
form_factor || null,
speed_gbps ? parseFloat(speed_gbps) : null,
notes || null,
deal_date || null,
]
);
return res.status(201).json({ success: true, event: result.rows[0] });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/win-loss — List events ─────────────────────────────────────────
winLossRouter.get("/", async (req: Request, res: Response) => {
const outcome = req.query.outcome as string | undefined;
const segment = req.query.customer_segment as string | undefined;
const days = Math.min(parseInt(req.query.days as string) || 90, 730);
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const fmt = req.query.format as string | undefined;
try {
const result = await pool.query(`
SELECT wl.*,
t.standard_name, t.form_factor AS tx_form_factor, t.speed_gbps AS tx_speed
FROM win_loss_events wl
LEFT JOIN transceivers t ON t.id = wl.transceiver_id
WHERE wl.deal_date >= CURRENT_DATE - INTERVAL '${days} days'
${outcome ? `AND wl.outcome = '${outcome.replace(/'/g,"''")}'` : ""}
${segment ? `AND wl.customer_segment = '${segment.replace(/'/g,"''")}'` : ""}
ORDER BY wl.deal_date DESC
LIMIT ${limit}
`);
if (fmt === "csv") {
return sendCSV(res, result.rows, `tip-win-loss-${new Date().toISOString().slice(0,10)}.csv`);
}
return res.json({ success: true, events: result.rows, count: result.rows.length });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/win-loss/summary — Aggregate analytics ─────────────────────────
winLossRouter.get("/summary", async (req: Request, res: Response) => {
const days = Math.min(parseInt(req.query.days as string) || 90, 730);
try {
const [overall, bySegment, byFormFactor, priceDeltas] = await Promise.all([
pool.query(`
SELECT
COUNT(*) AS total_events,
COUNT(*) FILTER (WHERE outcome = 'won') AS won,
COUNT(*) FILTER (WHERE outcome = 'lost') AS lost,
ROUND(
COUNT(*) FILTER (WHERE outcome = 'won')::numeric
/ NULLIF(COUNT(*) FILTER (WHERE outcome IN ('won','lost')), 0) * 100, 1
) AS win_rate_pct,
ROUND(AVG(our_price) FILTER (WHERE outcome = 'won')::numeric, 2) AS avg_win_price,
ROUND(AVG(our_price) FILTER (WHERE outcome = 'lost')::numeric, 2) AS avg_loss_price
FROM win_loss_events
WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days'
`),
pool.query(`
SELECT customer_segment,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE outcome = 'won') AS won,
COUNT(*) FILTER (WHERE outcome = 'lost') AS lost,
ROUND(COUNT(*) FILTER (WHERE outcome = 'won')::numeric
/ NULLIF(COUNT(*) FILTER (WHERE outcome IN ('won','lost')),0)*100,1) AS win_rate_pct
FROM win_loss_events
WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days'
AND customer_segment IS NOT NULL
GROUP BY customer_segment
ORDER BY total DESC
`),
pool.query(`
SELECT COALESCE(wl.form_factor, tx.form_factor) AS form_factor,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE outcome = 'won') AS won,
COUNT(*) FILTER (WHERE outcome = 'lost') AS lost
FROM win_loss_events wl
LEFT JOIN transceivers tx ON tx.id = wl.transceiver_id
WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days'
GROUP BY COALESCE(wl.form_factor, tx.form_factor)
HAVING COALESCE(wl.form_factor, tx.form_factor) IS NOT NULL
ORDER BY total DESC
`),
// Price delta analysis: where we lost — how far off were we?
pool.query(`
SELECT
ROUND(AVG(competitor_price - our_price)::numeric, 2) AS avg_price_gap,
ROUND(AVG((competitor_price - our_price) / NULLIF(our_price,0) * 100)::numeric, 1) AS avg_gap_pct,
COUNT(*) AS events_with_prices
FROM win_loss_events
WHERE outcome = 'lost'
AND our_price IS NOT NULL AND competitor_price IS NOT NULL
AND deal_date >= CURRENT_DATE - INTERVAL '${days} days'
`),
]);
return res.json({
success: true,
days,
overall: overall.rows[0],
by_segment: bySegment.rows,
by_form_factor: byFormFactor.rows,
price_delta: priceDeltas.rows[0],
});
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});
// ── GET /api/win-loss/competitors — Competitor ranking ───────────────────────
winLossRouter.get("/competitors", async (req: Request, res: Response) => {
const days = Math.min(parseInt(req.query.days as string) || 90, 730);
try {
const result = await pool.query(`
SELECT
competitor_vendor,
COUNT(*) AS encounters,
COUNT(*) FILTER (WHERE outcome = 'lost') AS losses_to,
COUNT(*) FILTER (WHERE outcome = 'won') AS wins_against,
ROUND(AVG(competitor_price - our_price)
FILTER (WHERE outcome = 'lost' AND our_price IS NOT NULL AND competitor_price IS NOT NULL)
::numeric, 2) AS avg_price_advantage, -- negative = they beat us on price
ROUND(AVG(competitor_price)::numeric, 2) AS avg_competitor_price
FROM win_loss_events
WHERE deal_date >= CURRENT_DATE - INTERVAL '${days} days'
AND competitor_vendor IS NOT NULL
GROUP BY competitor_vendor
ORDER BY losses_to DESC, encounters DESC
LIMIT 30
`);
return res.json({ success: true, competitors: result.rows });
} catch (err) {
return res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -1,35 +0,0 @@
/**
* Minimal CSV serializer no external dependencies.
* Converts an array of flat objects to RFC 4180-compliant CSV text.
*/
function escapeCell(value: unknown): string {
if (value === null || value === undefined) return "";
const str = String(value);
// Quote if contains comma, quote, newline, or leading/trailing whitespace
if (/[",\n\r]/.test(str) || str !== str.trim()) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
export function toCSV(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return "";
const headers = Object.keys(rows[0]);
const lines = [
headers.join(","),
...rows.map(row => headers.map(h => escapeCell(row[h])).join(",")),
];
return lines.join("\r\n");
}
/**
* Send a CSV response with proper headers.
*/
import type { Response } from "express";
export function sendCSV(res: Response, rows: Record<string, unknown>[], filename: string): void {
const csv = toCSV(rows);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send("" + csv); // BOM for Excel UTF-8 compatibility
}

View File

@ -31,28 +31,13 @@
} }
blogPipelineRunning = true; blogPipelineRunning = true;
// Fetch the current active model name so we never show a stale hardcoded version.
var initialModelLabel = (window._activeFoBlogModel) || 'FO_BlogLLM';
fetch(API + '/api/blog/llm/status', { headers: authHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
var m = data && data.llm && data.llm.model;
if (m) {
window._activeFoBlogModel = m;
var s = document.getElementById('bp-step');
if (s && s.textContent.indexOf('Connecting to FO_BlogLLM') === 0) {
s.textContent = 'Connecting to FO_BlogLLM (' + m + ')';
}
}
}).catch(function() {});
var pipelineEl = document.getElementById('blog-pipeline-status'); var pipelineEl = document.getElementById('blog-pipeline-status');
if (pipelineEl) { if (pipelineEl) {
pipelineEl.innerHTML = pipelineEl.innerHTML =
'<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' + '<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' +
'<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' + '<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' +
'<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' + '<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' +
'<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to ' + initialModelLabel + '</div>' + '<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to FO_BlogLLM (fo-blog-v7)</div>' +
'<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' + '<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' +
'<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' + '<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' +
'<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' + '<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' +
@ -152,7 +137,7 @@
if (bar) bar.style.width = prog.pct + '%'; if (bar) bar.style.width = prog.pct + '%';
if (pct) pct.textContent = prog.pct + '%'; if (pct) pct.textContent = prog.pct + '%';
if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); } if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); }
if (step) step.textContent = 'Step ' + prog.step + '/10 · ' + (window._activeFoBlogModel || 'fo-blog-v10') + ' via adapter bridge'; if (step) step.textContent = 'Step ' + prog.step + '/10 · fo-blog-v7 via adapter bridge';
} else { } else {
_stallCount++; _stallCount++;
// After 5 consecutive non-running polls (~40s), show stall warning // After 5 consecutive non-running polls (~40s), show stall warning

File diff suppressed because it is too large Load Diff

View File

@ -222,10 +222,6 @@ export async function upsertPriceObservation(params: {
leadTimeDays?: number; leadTimeDays?: number;
url?: string; url?: string;
contentHash: string; contentHash: string;
/** Vendor slug or marketplace name (e.g. "fs-com", "ebay"). Derived from vendor slug if omitted. */
marketplace?: string;
/** How the data was collected. Defaults to "crawlee". */
scrapeMethod?: string;
}): Promise<boolean> { }): Promise<boolean> {
// Normalize price to USD for sanity check (rough conversion) // Normalize price to USD for sanity check (rough conversion)
const priceUsd = params.currency === "EUR" ? params.price * 1.09 const priceUsd = params.currency === "EUR" ? params.price * 1.09
@ -251,16 +247,12 @@ export async function upsertPriceObservation(params: {
[params.transceiverId, params.sourceVendorId] [params.transceiverId, params.sourceVendorId]
); );
// Check if vendor is a competitor (non-Flexoptix) for competitor_verified flag. // Check if vendor is a competitor (non-Flexoptix) for competitor_verified flag
// Also fetch slug so we can tag price_observations.marketplace automatically.
const vendorRow = await pool.query( const vendorRow = await pool.query(
`SELECT is_competitor, slug FROM vendors WHERE id = $1`, `SELECT is_competitor FROM vendors WHERE id = $1`,
[params.sourceVendorId] [params.sourceVendorId]
); );
const isCompetitor = vendorRow.rows[0]?.is_competitor === true; const isCompetitor = vendorRow.rows[0]?.is_competitor === true;
const vendorSlug = (vendorRow.rows[0]?.slug as string | undefined) ?? null;
const resolvedMarketplace = params.marketplace ?? vendorSlug;
const resolvedScrapeMethod = params.scrapeMethod ?? "crawlee";
// Price unchanged AND observation is fresh (< 7 days old) → skip insertion // Price unchanged AND observation is fresh (< 7 days old) → skip insertion
const REFRESH_DAYS = 7; const REFRESH_DAYS = 7;
@ -307,10 +299,9 @@ export async function upsertPriceObservation(params: {
await pool.query( await pool.query(
`INSERT INTO price_observations ( `INSERT INTO price_observations (
time, transceiver_id, source_vendor_id, price, currency, stock_level, time, transceiver_id, source_vendor_id, price, currency, stock_level,
quantity_available, lead_time_days, url, content_hash, is_verified, verified_at, quantity_available, lead_time_days, url, content_hash, is_verified, verified_at
marketplace, scrape_method
) )
VALUES (NOW(), $1, $2, $3, $4, $5, $6, $7, $8, $9, true, NOW(), $10, $11)`, VALUES (NOW(), $1, $2, $3, $4, $5, $6, $7, $8, $9, true, NOW())`,
[ [
params.transceiverId, params.transceiverId,
params.sourceVendorId, params.sourceVendorId,
@ -321,8 +312,6 @@ export async function upsertPriceObservation(params: {
params.leadTimeDays || null, params.leadTimeDays || null,
params.url || null, params.url || null,
params.contentHash, params.contentHash,
resolvedMarketplace,
resolvedScrapeMethod,
] ]
); );

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,29 +1,20 @@
{ {
"raw_pairs": 11635, "raw_pairs": 11508,
"duplicates_removed": 100, "duplicates_removed": 100,
"training_pairs": 11535, "training_pairs": 11408,
"train_pairs": 10381, "train_pairs": 10267,
"eval_pairs": 1154, "eval_pairs": 1141,
"sources": { "sources": {
"external:vendor-deep-dives.jsonl": 11200, "external:vendor-deep-dives.jsonl": 11200,
"blog-training-data/blog-164-network-research-innovation-emerging-technologies.md": 1,
"external:technical-deep-dives.jsonl": 84, "external:technical-deep-dives.jsonl": 84,
"blog-training-data/blog-174-network-performance-testing-rfc2544-y1564.md": 1,
"blog-training-data/blog-179-data-center-physical-infrastructure-design.md": 1,
"blog-training-data/blog-025-sfp28-lab-vs-rack.md": 1, "blog-training-data/blog-025-sfp28-lab-vs-rack.md": 1,
"blog-training-data/blog-091-wavelength-selective-switch-wss-explainer.md": 1, "blog-training-data/blog-091-wavelength-selective-switch-wss-explainer.md": 1,
"blog-training-data/blog-008-oem-vs-compatible-real-numbers.md": 1, "blog-training-data/blog-008-oem-vs-compatible-real-numbers.md": 1,
"blog-training-data/blog-150-comprehensive-optical-network-program-management.md": 1,
"blog-training-data/blog-014-800g-new-products-what-ships.md": 1, "blog-training-data/blog-014-800g-new-products-what-ships.md": 1,
"blog-training-data/blog-045-osnr-link-budget-practical-guide.md": 1, "blog-training-data/blog-045-osnr-link-budget-practical-guide.md": 1,
"blog-training-data/blog-178-outside-plant-construction-cable-installation.md": 1,
"blog-training-data/blog-024-rx-power-budgets-400g.md": 1, "blog-training-data/blog-024-rx-power-budgets-400g.md": 1,
"blog-training-data/blog-187-ab-testing-conversion-optimization-b2b-content.md": 1,
"blog-training-data/blog-151-optical-network-troubleshooting-advanced-scenarios.md": 1,
"blog-training-data/blog-107-dwdm-when-you-need-it.md": 1,
"blog-training-data/blog-017-dom-readings-lie.md": 1, "blog-training-data/blog-017-dom-readings-lie.md": 1,
"blog-training-data/blog-010-qsfp-dd-vs-osfp-form-factor-reality.md": 1, "blog-training-data/blog-010-qsfp-dd-vs-osfp-form-factor-reality.md": 1,
"blog-training-data/blog-153-optical-deployment-best-practices-comprehensive.md": 1,
"blog-training-data/blog-072-optical-amplifier-edfa-raman-basics.md": 1, "blog-training-data/blog-072-optical-amplifier-edfa-raman-basics.md": 1,
"blog-training-data/blog-028-400g-dac-3m-vs-5m.md": 1, "blog-training-data/blog-028-400g-dac-3m-vs-5m.md": 1,
"blog-training-data/blog-011-transceiver-procurement-checklist.md": 1, "blog-training-data/blog-011-transceiver-procurement-checklist.md": 1,
@ -31,205 +22,87 @@
"blog-training-data/blog-083-fiber-optic-testing-otdr-basics.md": 1, "blog-training-data/blog-083-fiber-optic-testing-otdr-basics.md": 1,
"blog-training-data/blog-038-cpo-pluggable-future.md": 1, "blog-training-data/blog-038-cpo-pluggable-future.md": 1,
"blog-training-data/blog-054-multimode-fiber-om3-om4-om5-guide.md": 1, "blog-training-data/blog-054-multimode-fiber-om3-om4-om5-guide.md": 1,
"blog-training-data/blog-127-streaming-cdn-content-delivery.md": 1,
"blog-training-data/blog-015-compatible-vendor-comparison-who-to-trust.md": 1, "blog-training-data/blog-015-compatible-vendor-comparison-who-to-trust.md": 1,
"blog-training-data/blog-063-100g-zr-coherent-pluggable-timing.md": 1, "blog-training-data/blog-063-100g-zr-coherent-pluggable-timing.md": 1,
"blog-training-data/blog-195-case-study-craft-stories-drive-decisions.md": 1,
"blog-training-data/blog-221-content-attribution-multi-touch-modeling.md": 1,
"blog-training-data/blog-192-ai-prompt-engineering-technical-content.md": 1,
"blog-training-data/blog-135-network-security-optical-physical-layer.md": 1,
"blog-training-data/blog-144-network-virtualization-overlays-optical.md": 1,
"blog-training-data/blog-125-optical-network-troubleshooting-mastery.md": 1,
"blog-training-data/blog-197-content-analytics-roi-measurement.md": 1,
"blog-training-data/blog-219-content-governance-compliance-regulated-industries.md": 1,
"blog-training-data/blog-171-fiber-types-specifications-complete-reference.md": 1,
"blog-training-data/blog-069-optical-budget-calculator-guide.md": 1, "blog-training-data/blog-069-optical-budget-calculator-guide.md": 1,
"blog-training-data/blog-169-optical-networking-competitive-landscape-analysis.md": 1,
"blog-training-data/blog-070-mtp-mpo-cassette-fiber-management.md": 1, "blog-training-data/blog-070-mtp-mpo-cassette-fiber-management.md": 1,
"blog-training-data/blog-134-cloud-networking-optical-transceiver-strategy.md": 1,
"blog-training-data/blog-138-network-observability-telemetry-optical.md": 1,
"blog-training-data/blog-159-optical-network-incident-management-emergency.md": 1,
"blog-training-data/blog-092-sfp-sfp-plus-backward-compatibility.md": 1, "blog-training-data/blog-092-sfp-sfp-plus-backward-compatibility.md": 1,
"blog-training-data/blog-086-hyperscale-optics-purchasing-strategy.md": 1, "blog-training-data/blog-086-hyperscale-optics-purchasing-strategy.md": 1,
"blog-training-data/blog-055-transceiver-lifecycle-management-enterprise.md": 1, "blog-training-data/blog-055-transceiver-lifecycle-management-enterprise.md": 1,
"blog-training-data/blog-161-optical-network-mergers-acquisitions-integration.md": 1,
"blog-training-data/blog-066-400g-zr-interoperability-matrix.md": 1, "blog-training-data/blog-066-400g-zr-interoperability-matrix.md": 1,
"blog-training-data/blog-228-economics-content-marketing-business-model.md": 1,
"blog-training-data/blog-193-advanced-seo-b2b-technical-content.md": 1,
"blog-training-data/blog-166-osi-model-optical-networking-complete-layer-analysis.md": 1,
"blog-training-data/blog-093-google-meta-microsoft-optics-strategy.md": 1, "blog-training-data/blog-093-google-meta-microsoft-optics-strategy.md": 1,
"blog-training-data/blog-019-cleaning-fiber-400g-tolerance.md": 1, "blog-training-data/blog-019-cleaning-fiber-400g-tolerance.md": 1,
"blog-training-data/blog-102-compliance-checklist-imported-transceivers.md": 1,
"blog-training-data/blog-175-cloud-networking-deep-dive-vpc-containers-mesh.md": 1,
"blog-training-data/blog-026-400g-zr-vs-zrplus.md": 1, "blog-training-data/blog-026-400g-zr-vs-zrplus.md": 1,
"blog-training-data/blog-035-esd-damage-transceivers.md": 1, "blog-training-data/blog-035-esd-damage-transceivers.md": 1,
"blog-training-data/blog-199-industry-analyst-relations-gartner-forrester.md": 1,
"blog-training-data/blog-124-network-automation-optical-infrastructure.md": 1,
"blog-training-data/blog-123-silicon-photonics-co-packaged-optics.md": 1,
"blog-training-data/blog-087-rj45-vs-sfp-copper-1g-switches.md": 1, "blog-training-data/blog-087-rj45-vs-sfp-copper-1g-switches.md": 1,
"blog-training-data/blog-132-quantum-networking-optical-infrastructure.md": 1,
"blog-training-data/blog-120-telco-5g-6g-fronthaul-midhaul-backhaul.md": 1,
"blog-training-data/blog-009-100g-to-400g-migration-what-breaks.md": 1, "blog-training-data/blog-009-100g-to-400g-migration-what-breaks.md": 1,
"blog-training-data/blog-104-ai-chip-shortage-optics-supply.md": 1,
"blog-training-data/blog-034-grey-optics-vs-dwdm-metro-aggregation.md": 1, "blog-training-data/blog-034-grey-optics-vs-dwdm-metro-aggregation.md": 1,
"blog-training-data/blog-167-security-layers-defense-depth-optical-networks.md": 1,
"blog-training-data/blog-154-optical-network-roi-business-value-analysis.md": 1,
"blog-training-data/blog-082-coherent-dsp-power-consumption.md": 1, "blog-training-data/blog-082-coherent-dsp-power-consumption.md": 1,
"blog-training-data/blog-062-transceiver-inventory-management-excel-vs-cmdb.md": 1, "blog-training-data/blog-062-transceiver-inventory-management-excel-vs-cmdb.md": 1,
"blog-training-data/blog-088-transceiver-sff-committee-history.md": 1, "blog-training-data/blog-088-transceiver-sff-committee-history.md": 1,
"blog-training-data/blog-098-carrier-ethernet-timing-syncE-ptp-optics.md": 1, "blog-training-data/blog-098-carrier-ethernet-timing-syncE-ptp-optics.md": 1,
"blog-training-data/blog-122-pam4-pam8-modulation-data-center.md": 1,
"blog-training-data/blog-003-silicon-photonics.md": 1, "blog-training-data/blog-003-silicon-photonics.md": 1,
"blog-training-data/blog-130-edge-computing-network-optics-future.md": 1,
"blog-training-data/blog-037-fec-deep-dive.md": 1, "blog-training-data/blog-037-fec-deep-dive.md": 1,
"blog-training-data/blog-099-transceiver-market-2026-pricing-forecast.md": 1, "blog-training-data/blog-099-transceiver-market-2026-pricing-forecast.md": 1,
"blog-training-data/blog-155-optical-networking-knowledge-management.md": 1,
"blog-training-data/blog-021-validating-compatible-optics.md": 1, "blog-training-data/blog-021-validating-compatible-optics.md": 1,
"blog-training-data/blog-176-greenfield-network-infrastructure-complete-build.md": 1,
"blog-training-data/blog-023-pam4-800g-fec-errors.md": 1, "blog-training-data/blog-023-pam4-800g-fec-errors.md": 1,
"blog-training-data/blog-204-customer-marketing-advocacy-programs.md": 1,
"blog-training-data/blog-030-when-to-upgrade-from-10g.md": 1, "blog-training-data/blog-030-when-to-upgrade-from-10g.md": 1,
"blog-training-data/blog-131-telco-carrier-grade-optical-operations.md": 1,
"blog-training-data/blog-002-vendor-lock-in-optics.md": 1, "blog-training-data/blog-002-vendor-lock-in-optics.md": 1,
"blog-training-data/blog-198-complete-content-engine-operating-system.md": 1,
"blog-training-data/blog-173-internet-architecture-deep-dive-bgp-ixps-peering.md": 1,
"blog-training-data/blog-220-investor-relations-content-tech-companies.md": 1,
"blog-training-data/blog-225-privacy-data-protection-content-practices.md": 1,
"blog-training-data/blog-180-network-project-management-permitting-execution.md": 1,
"blog-training-data/blog-224-generative-ai-future-content-marketing.md": 1,
"blog-training-data/blog-081-transceiver-rma-process-best-practices.md": 1, "blog-training-data/blog-081-transceiver-rma-process-best-practices.md": 1,
"blog-training-data/blog-013-price-drop-timing-when-to-buy.md": 1, "blog-training-data/blog-013-price-drop-timing-when-to-buy.md": 1,
"blog-training-data/blog-160-future-of-optical-networking-comprehensive.md": 1,
"blog-training-data/blog-095-optical-lan-versus-fiber-ethernet.md": 1, "blog-training-data/blog-095-optical-lan-versus-fiber-ethernet.md": 1,
"blog-training-data/blog-117-submarine-cable-coherent-long-haul.md": 1,
"blog-training-data/blog-067-single-mode-fiber-types-g652-g657.md": 1, "blog-training-data/blog-067-single-mode-fiber-types-g652-g657.md": 1,
"blog-training-data/blog-177-site-survey-capacity-planning-methodology.md": 1,
"blog-training-data/blog-039-cmis-400g-management.md": 1, "blog-training-data/blog-039-cmis-400g-management.md": 1,
"blog-training-data/blog-213-original-research-proprietary-data.md": 1,
"blog-training-data/blog-226-accessibility-inclusive-content-design.md": 1,
"blog-training-data/blog-142-network-design-patterns-optical-architecture.md": 1,
"blog-training-data/blog-113-rma-warranty-optimization.md": 1,
"blog-training-data/blog-071-sff-8024-transceiver-id-codes.md": 1, "blog-training-data/blog-071-sff-8024-transceiver-id-codes.md": 1,
"blog-training-data/blog-097-liquid-cooling-impact-optical-transceivers.md": 1, "blog-training-data/blog-097-liquid-cooling-impact-optical-transceivers.md": 1,
"blog-training-data/blog-007-800g-readiness.md": 1, "blog-training-data/blog-007-800g-readiness.md": 1,
"blog-training-data/blog-058-arista-eos-optic-compatibility.md": 1, "blog-training-data/blog-058-arista-eos-optic-compatibility.md": 1,
"blog-training-data/blog-136-emerging-protocols-cxl-roce-rdma.md": 1,
"blog-training-data/blog-068-25g-vs-10g-upgrade-path-decision.md": 1, "blog-training-data/blog-068-25g-vs-10g-upgrade-path-decision.md": 1,
"blog-training-data/blog-170-network-management-protocols-comprehensive-snmp-netconf.md": 1,
"blog-training-data/blog-061-cfp2-cfp4-qsfp28-form-factor-migration.md": 1, "blog-training-data/blog-061-cfp2-cfp4-qsfp28-form-factor-migration.md": 1,
"blog-training-data/blog-147-optical-network-testing-validation-procedures.md": 1,
"blog-training-data/blog-079-ip-optical-integration-disaggregation.md": 1, "blog-training-data/blog-079-ip-optical-integration-disaggregation.md": 1,
"blog-training-data/blog-129-manufacturing-iot-industrial-network.md": 1,
"blog-training-data/blog-046-transceiver-counterfeit-detection.md": 1, "blog-training-data/blog-046-transceiver-counterfeit-detection.md": 1,
"blog-training-data/blog-183-perfect-hooks-teasers-curiosity-gap.md": 1,
"blog-training-data/blog-056-cisco-qsfp28-compatibility-list.md": 1, "blog-training-data/blog-056-cisco-qsfp28-compatibility-list.md": 1,
"blog-training-data/blog-005-coherent-400zr-reality.md": 1, "blog-training-data/blog-005-coherent-400zr-reality.md": 1,
"blog-training-data/blog-203-executive-personal-brand-technical-leaders.md": 1,
"blog-training-data/blog-109-third-party-optics-validation-lab-testing.md": 1,
"blog-training-data/blog-065-dwdm-channel-plan-100ghz-vs-50ghz.md": 1, "blog-training-data/blog-065-dwdm-channel-plan-100ghz-vs-50ghz.md": 1,
"blog-training-data/blog-227-emerging-platforms-content-innovation.md": 1,
"blog-training-data/blog-078-pon-gpon-xgspon-optics-explainer.md": 1, "blog-training-data/blog-078-pon-gpon-xgspon-optics-explainer.md": 1,
"blog-training-data/blog-051-spine-leaf-transceiver-strategy.md": 1, "blog-training-data/blog-051-spine-leaf-transceiver-strategy.md": 1,
"blog-training-data/blog-032-msa-compliance-vs-interoperability.md": 1, "blog-training-data/blog-032-msa-compliance-vs-interoperability.md": 1,
"blog-training-data/blog-064-optic-burn-in-testing.md": 1, "blog-training-data/blog-064-optic-burn-in-testing.md": 1,
"blog-training-data/blog-114-counterfeit-detection-supply-chain.md": 1,
"blog-training-data/blog-133-disaggregated-networking-future-architecture.md": 1,
"blog-training-data/blog-105-why-it-teams-care-optics.md": 1,
"blog-training-data/blog-001-400g-dr4-price-war.md": 1, "blog-training-data/blog-001-400g-dr4-price-war.md": 1,
"blog-training-data/blog-040-evaluating-compatible-vendor.md": 1, "blog-training-data/blog-040-evaluating-compatible-vendor.md": 1,
"blog-training-data/blog-211-employee-advocacy-internal-content.md": 1,
"blog-training-data/blog-202-video-podcast-content-b2b-tech.md": 1,
"blog-training-data/blog-042-800g-osfp-vs-qsfp-dd-port-density.md": 1, "blog-training-data/blog-042-800g-osfp-vs-qsfp-dd-port-density.md": 1,
"blog-training-data/blog-140-future-optical-networking-2030.md": 1,
"blog-training-data/blog-139-disaster-recovery-business-continuity-optical.md": 1,
"blog-training-data/blog-148-vendor-relationship-strategic-partnerships.md": 1,
"blog-training-data/blog-100-flexoptix-programming-service-technical.md": 1, "blog-training-data/blog-100-flexoptix-programming-service-technical.md": 1,
"blog-training-data/blog-118-ai-ml-workload-network-optics.md": 1,
"blog-training-data/blog-076-cisco-nexus-vs-catalyst-optic-behavior.md": 1, "blog-training-data/blog-076-cisco-nexus-vs-catalyst-optic-behavior.md": 1,
"blog-training-data/blog-053-cisco-juniper-arista-optic-lock-in.md": 1, "blog-training-data/blog-053-cisco-juniper-arista-optic-lock-in.md": 1,
"blog-training-data/blog-044-laser-safety-class-1m-transceivers.md": 1, "blog-training-data/blog-044-laser-safety-class-1m-transceivers.md": 1,
"blog-training-data/blog-152-optical-network-architecture-evolution-2025-2030.md": 1,
"blog-training-data/blog-094-transceiver-programming-eeprom-guide.md": 1, "blog-training-data/blog-094-transceiver-programming-eeprom-guide.md": 1,
"blog-training-data/blog-222-content-marketing-team-development.md": 1,
"blog-training-data/blog-085-ai-inference-cluster-optics-requirements.md": 1, "blog-training-data/blog-085-ai-inference-cluster-optics-requirements.md": 1,
"blog-training-data/blog-206-crisis-communications-reputation-management.md": 1,
"blog-training-data/blog-188-email-marketing-b2b-technical-content.md": 1,
"blog-training-data/blog-149-network-security-zero-trust-optical-implementation.md": 1,
"blog-training-data/blog-216-ai-ethics-responsible-content-creation.md": 1,
"blog-training-data/blog-182-science-of-perfect-blog-writing.md": 1,
"blog-training-data/blog-194-brand-voice-architecture-technical-companies.md": 1,
"blog-training-data/blog-163-network-skills-careers-optical-engineering-future.md": 1,
"blog-training-data/blog-052-roa-replacing-optics-proactively.md": 1, "blog-training-data/blog-052-roa-replacing-optics-proactively.md": 1,
"blog-training-data/blog-162-network-as-a-service-cloud-native-optical.md": 1,
"blog-training-data/blog-090-optics-for-5g-fronthaul-midhaul.md": 1, "blog-training-data/blog-090-optics-for-5g-fronthaul-midhaul.md": 1,
"blog-training-data/blog-126-fintech-financial-services-network-optics.md": 1,
"blog-training-data/blog-201-sales-enablement-content-strategy.md": 1,
"blog-training-data/blog-186-perfect-blog-engine-architecture-synthesis.md": 1,
"blog-training-data/blog-041-silicon-photonics-co-packaging-2026.md": 1, "blog-training-data/blog-041-silicon-photonics-co-packaging-2026.md": 1,
"blog-training-data/blog-156-network-protocols-l1-encryption-deep-dive.md": 1,
"blog-training-data/blog-096-dark-fiber-leasing-optics-considerations.md": 1, "blog-training-data/blog-096-dark-fiber-leasing-optics-considerations.md": 1,
"blog-training-data/blog-108-advanced-fiber-contamination-diagnostics.md": 1,
"blog-training-data/blog-215-recruiting-employer-branding-content.md": 1,
"blog-training-data/blog-112-open-networking-optics-ecosystem.md": 1,
"blog-training-data/blog-121-400g-800g-coherent-optics-deep-dive.md": 1,
"blog-training-data/blog-084-ieee-802.3-standards-transceiver-reference.md": 1, "blog-training-data/blog-084-ieee-802.3-standards-transceiver-reference.md": 1,
"blog-training-data/blog-012-coherent-vs-direct-detect-decision.md": 1, "blog-training-data/blog-012-coherent-vs-direct-detect-decision.md": 1,
"blog-training-data/blog-165-optical-networking-comprehensive-reference-guide.md": 1,
"blog-training-data/blog-004-400g-migration-fiber-plant.md": 1, "blog-training-data/blog-004-400g-migration-fiber-plant.md": 1,
"blog-training-data/blog-115-healthcare-network-optics-compliance.md": 1,
"blog-training-data/blog-119-sustainability-carbon-footprint-optical.md": 1,
"blog-training-data/blog-060-fiber-connector-cleaning-protocol.md": 1, "blog-training-data/blog-060-fiber-connector-cleaning-protocol.md": 1,
"blog-training-data/blog-143-network-protocols-modern-optical-infrastructure.md": 1,
"blog-training-data/blog-172-transceiver-form-factors-complete-reference.md": 1,
"blog-training-data/blog-207-localization-international-content-strategy.md": 1,
"blog-training-data/blog-106-fiber-diagnostics-eye-diagrams.md": 1,
"blog-training-data/blog-158-network-time-synchronization-precision-timing.md": 1,
"blog-training-data/blog-217-strategic-partnerships-co-marketing.md": 1,
"blog-training-data/blog-027-fiber-plant-audit-100g-upgrade.md": 1, "blog-training-data/blog-027-fiber-plant-audit-100g-upgrade.md": 1,
"blog-training-data/blog-016-400g-qsfp-dd-after-fiber-moves.md": 1, "blog-training-data/blog-016-400g-qsfp-dd-after-fiber-moves.md": 1,
"blog-training-data/blog-145-data-center-interconnect-dci-optical-design.md": 1,
"blog-training-data/blog-205-product-launch-content-strategy.md": 1,
"blog-training-data/blog-074-fiber-optic-patch-cord-standards.md": 1, "blog-training-data/blog-074-fiber-optic-patch-cord-standards.md": 1,
"blog-training-data/blog-057-juniper-optic-unlock-ex-qfx.md": 1, "blog-training-data/blog-057-juniper-optic-unlock-ex-qfx.md": 1,
"blog-training-data/blog-196-newsletter-strategy-technical-audiences.md": 1,
"blog-training-data/blog-214-press-relations-media-strategy.md": 1,
"blog-training-data/blog-022-oem-vs-compatible-lab-tests.md": 1, "blog-training-data/blog-022-oem-vs-compatible-lab-tests.md": 1,
"blog-training-data/blog-218-sustainable-content-marketing-practice.md": 1,
"blog-training-data/blog-020-100g-link-drops-temperature.md": 1, "blog-training-data/blog-020-100g-link-drops-temperature.md": 1,
"blog-training-data/blog-191-editorial-operations-content-engine-management.md": 1,
"blog-training-data/blog-146-optical-network-capacity-planning-bandwidth.md": 1,
"blog-training-data/blog-050-optical-transceiver-temperature-grades.md": 1, "blog-training-data/blog-050-optical-transceiver-temperature-grades.md": 1,
"blog-training-data/blog-208-community-building-technical-content.md": 1,
"blog-training-data/blog-111-cisco-arista-juniper-optics-strategies.md": 1,
"blog-training-data/blog-141-optical-network-cost-engineering-tco.md": 1,
"blog-training-data/blog-036-coherent-tunable-vs-fixed-wavelength.md": 1, "blog-training-data/blog-036-coherent-tunable-vs-fixed-wavelength.md": 1,
"blog-training-data/blog-181-neurolinguistic-persuasion-blog-writing.md": 1,
"blog-training-data/blog-209-account-based-marketing-abm-content.md": 1,
"blog-training-data/blog-200-webinar-virtual-event-content-strategy.md": 1,
"blog-training-data/blog-077-pam4-vs-nrz-modulation-transceivers.md": 1, "blog-training-data/blog-077-pam4-vs-nrz-modulation-transceivers.md": 1,
"blog-training-data/blog-212-interactive-content-calculators-tools.md": 1,
"blog-training-data/blog-080-fcoe-fibre-channel-sfp-differences.md": 1, "blog-training-data/blog-080-fcoe-fibre-channel-sfp-differences.md": 1,
"blog-training-data/blog-168-optical-transceiver-manufacturers-comprehensive-landscape.md": 1,
"blog-training-data/blog-043-zr-zr-plus-coherent-pluggables-comparison.md": 1, "blog-training-data/blog-043-zr-zr-plus-coherent-pluggables-comparison.md": 1,
"blog-training-data/blog-049-wavelength-division-multiplexing-primer.md": 1, "blog-training-data/blog-049-wavelength-division-multiplexing-primer.md": 1,
"blog-training-data/blog-089-metro-dwdm-open-vs-proprietary.md": 1, "blog-training-data/blog-089-metro-dwdm-open-vs-proprietary.md": 1,
"blog-training-data/blog-128-government-federal-network-optics.md": 1,
"blog-training-data/blog-116-carrier-isp-optics-operations.md": 1,
"blog-training-data/blog-073-qsfp-dd-800g-ecosystem-2026.md": 1, "blog-training-data/blog-073-qsfp-dd-800g-ecosystem-2026.md": 1,
"blog-training-data/blog-210-marketing-automation-lead-nurturing.md": 1,
"blog-training-data/blog-189-linkedin-social-distribution-b2b-tech.md": 1,
"blog-training-data/blog-018-800g-sr8-dr8-fr8-comparison.md": 1, "blog-training-data/blog-018-800g-sr8-dr8-fr8-comparison.md": 1,
"blog-training-data/blog-029-800g-osfp-spineleaf-checklist.md": 1, "blog-training-data/blog-029-800g-osfp-spineleaf-checklist.md": 1,
"blog-training-data/blog-110-wavelength-tuning-dwdm.md": 1,
"blog-training-data/blog-103-carbon-footprint-oem-compatible-tco.md": 1,
"blog-training-data/blog-137-regional-optical-network-considerations-global.md": 1,
"blog-training-data/blog-006-dom-diagnostics.md": 1, "blog-training-data/blog-006-dom-diagnostics.md": 1,
"blog-training-data/blog-157-multicast-video-broadcast-optical-networks.md": 1,
"blog-training-data/blog-185-b2b-decision-psychology-trust-signals.md": 1,
"blog-training-data/blog-223-final-capstone-sustainable-excellence.md": 1,
"blog-training-data/blog-075-transceiver-failure-root-cause-analysis.md": 1, "blog-training-data/blog-075-transceiver-failure-root-cause-analysis.md": 1,
"blog-training-data/blog-190-content-repurposing-multi-format-strategy.md": 1,
"blog-training-data/blog-184-perfect-visuals-infographics-header-design.md": 1,
"blog-training-data/blog-048-400g-dr4-fr4-lr4-comparison.md": 1, "blog-training-data/blog-048-400g-dr4-fr4-lr4-comparison.md": 1,
"blog-training-data/blog-031-cwdm4-vs-psm4-100g-datacenter.md": 1, "blog-training-data/blog-031-cwdm4-vs-psm4-100g-datacenter.md": 1,
"blog-training-data/blog-059-100g-sr4-multimode-distance-limits.md": 1, "blog-training-data/blog-059-100g-sr4-multimode-distance-limits.md": 1,

View File

@ -1,13 +1,13 @@
{ {
"generated_at": "2026-05-13T19:32:40.656Z", "generated_at": "2026-04-25T21:56:31.560Z",
"version": "TIP-LearningPool-v1", "version": "TIP-LearningPool-v1",
"lanes": { "lanes": {
"tip_llm": { "tip_llm": {
"raw_pairs": 12268, "raw_pairs": 12141,
"duplicates_removed": 269, "duplicates_removed": 269,
"training_pairs": 11999, "training_pairs": 11872,
"train_pairs": 10799, "train_pairs": 10684,
"eval_pairs": 1200, "eval_pairs": 1188,
"sources": { "sources": {
"external:vendor-deep-dives.jsonl": 11200, "external:vendor-deep-dives.jsonl": 11200,
"external:technical-deep-dives.jsonl": 84, "external:technical-deep-dives.jsonl": 84,
@ -16,10 +16,8 @@
"external:synthesized-training-samples.jsonl": 219, "external:synthesized-training-samples.jsonl": 219,
"external:nanog-ripe-labs-content.jsonl": 34, "external:nanog-ripe-labs-content.jsonl": 34,
"external:academic-research-synthesis.jsonl": 109, "external:academic-research-synthesis.jsonl": 109,
"training-data/tip-llm-pricing-v1.jsonl": 80, "training-data/tip-llm-capabilities-v1.jsonl": 34,
"training-data/tip-llm-capabilities-v1.jsonl": 69,
"external:market-business-analysis-part6.jsonl": 5, "external:market-business-analysis-part6.jsonl": 5,
"robot-control-high.jsonl": 12,
"external:market-business-analysis-part5.jsonl": 7, "external:market-business-analysis-part5.jsonl": 7,
"external:market-business-analysis-part4.jsonl": 5, "external:market-business-analysis-part4.jsonl": 5,
"external:market-business-analysis-part2.jsonl": 8, "external:market-business-analysis-part2.jsonl": 8,
@ -33,31 +31,22 @@
} }
}, },
"blog_llm": { "blog_llm": {
"raw_pairs": 11635, "raw_pairs": 11508,
"duplicates_removed": 100, "duplicates_removed": 100,
"training_pairs": 11535, "training_pairs": 11408,
"train_pairs": 10381, "train_pairs": 10267,
"eval_pairs": 1154, "eval_pairs": 1141,
"sources": { "sources": {
"external:vendor-deep-dives.jsonl": 11200, "external:vendor-deep-dives.jsonl": 11200,
"blog-training-data/blog-164-network-research-innovation-emerging-technologies.md": 1,
"external:technical-deep-dives.jsonl": 84, "external:technical-deep-dives.jsonl": 84,
"blog-training-data/blog-174-network-performance-testing-rfc2544-y1564.md": 1,
"blog-training-data/blog-179-data-center-physical-infrastructure-design.md": 1,
"blog-training-data/blog-025-sfp28-lab-vs-rack.md": 1, "blog-training-data/blog-025-sfp28-lab-vs-rack.md": 1,
"blog-training-data/blog-091-wavelength-selective-switch-wss-explainer.md": 1, "blog-training-data/blog-091-wavelength-selective-switch-wss-explainer.md": 1,
"blog-training-data/blog-008-oem-vs-compatible-real-numbers.md": 1, "blog-training-data/blog-008-oem-vs-compatible-real-numbers.md": 1,
"blog-training-data/blog-150-comprehensive-optical-network-program-management.md": 1,
"blog-training-data/blog-014-800g-new-products-what-ships.md": 1, "blog-training-data/blog-014-800g-new-products-what-ships.md": 1,
"blog-training-data/blog-045-osnr-link-budget-practical-guide.md": 1, "blog-training-data/blog-045-osnr-link-budget-practical-guide.md": 1,
"blog-training-data/blog-178-outside-plant-construction-cable-installation.md": 1,
"blog-training-data/blog-024-rx-power-budgets-400g.md": 1, "blog-training-data/blog-024-rx-power-budgets-400g.md": 1,
"blog-training-data/blog-187-ab-testing-conversion-optimization-b2b-content.md": 1,
"blog-training-data/blog-151-optical-network-troubleshooting-advanced-scenarios.md": 1,
"blog-training-data/blog-107-dwdm-when-you-need-it.md": 1,
"blog-training-data/blog-017-dom-readings-lie.md": 1, "blog-training-data/blog-017-dom-readings-lie.md": 1,
"blog-training-data/blog-010-qsfp-dd-vs-osfp-form-factor-reality.md": 1, "blog-training-data/blog-010-qsfp-dd-vs-osfp-form-factor-reality.md": 1,
"blog-training-data/blog-153-optical-deployment-best-practices-comprehensive.md": 1,
"blog-training-data/blog-072-optical-amplifier-edfa-raman-basics.md": 1, "blog-training-data/blog-072-optical-amplifier-edfa-raman-basics.md": 1,
"blog-training-data/blog-028-400g-dac-3m-vs-5m.md": 1, "blog-training-data/blog-028-400g-dac-3m-vs-5m.md": 1,
"blog-training-data/blog-011-transceiver-procurement-checklist.md": 1, "blog-training-data/blog-011-transceiver-procurement-checklist.md": 1,
@ -65,205 +54,87 @@
"blog-training-data/blog-083-fiber-optic-testing-otdr-basics.md": 1, "blog-training-data/blog-083-fiber-optic-testing-otdr-basics.md": 1,
"blog-training-data/blog-038-cpo-pluggable-future.md": 1, "blog-training-data/blog-038-cpo-pluggable-future.md": 1,
"blog-training-data/blog-054-multimode-fiber-om3-om4-om5-guide.md": 1, "blog-training-data/blog-054-multimode-fiber-om3-om4-om5-guide.md": 1,
"blog-training-data/blog-127-streaming-cdn-content-delivery.md": 1,
"blog-training-data/blog-015-compatible-vendor-comparison-who-to-trust.md": 1, "blog-training-data/blog-015-compatible-vendor-comparison-who-to-trust.md": 1,
"blog-training-data/blog-063-100g-zr-coherent-pluggable-timing.md": 1, "blog-training-data/blog-063-100g-zr-coherent-pluggable-timing.md": 1,
"blog-training-data/blog-195-case-study-craft-stories-drive-decisions.md": 1,
"blog-training-data/blog-221-content-attribution-multi-touch-modeling.md": 1,
"blog-training-data/blog-192-ai-prompt-engineering-technical-content.md": 1,
"blog-training-data/blog-135-network-security-optical-physical-layer.md": 1,
"blog-training-data/blog-144-network-virtualization-overlays-optical.md": 1,
"blog-training-data/blog-125-optical-network-troubleshooting-mastery.md": 1,
"blog-training-data/blog-197-content-analytics-roi-measurement.md": 1,
"blog-training-data/blog-219-content-governance-compliance-regulated-industries.md": 1,
"blog-training-data/blog-171-fiber-types-specifications-complete-reference.md": 1,
"blog-training-data/blog-069-optical-budget-calculator-guide.md": 1, "blog-training-data/blog-069-optical-budget-calculator-guide.md": 1,
"blog-training-data/blog-169-optical-networking-competitive-landscape-analysis.md": 1,
"blog-training-data/blog-070-mtp-mpo-cassette-fiber-management.md": 1, "blog-training-data/blog-070-mtp-mpo-cassette-fiber-management.md": 1,
"blog-training-data/blog-134-cloud-networking-optical-transceiver-strategy.md": 1,
"blog-training-data/blog-138-network-observability-telemetry-optical.md": 1,
"blog-training-data/blog-159-optical-network-incident-management-emergency.md": 1,
"blog-training-data/blog-092-sfp-sfp-plus-backward-compatibility.md": 1, "blog-training-data/blog-092-sfp-sfp-plus-backward-compatibility.md": 1,
"blog-training-data/blog-086-hyperscale-optics-purchasing-strategy.md": 1, "blog-training-data/blog-086-hyperscale-optics-purchasing-strategy.md": 1,
"blog-training-data/blog-055-transceiver-lifecycle-management-enterprise.md": 1, "blog-training-data/blog-055-transceiver-lifecycle-management-enterprise.md": 1,
"blog-training-data/blog-161-optical-network-mergers-acquisitions-integration.md": 1,
"blog-training-data/blog-066-400g-zr-interoperability-matrix.md": 1, "blog-training-data/blog-066-400g-zr-interoperability-matrix.md": 1,
"blog-training-data/blog-228-economics-content-marketing-business-model.md": 1,
"blog-training-data/blog-193-advanced-seo-b2b-technical-content.md": 1,
"blog-training-data/blog-166-osi-model-optical-networking-complete-layer-analysis.md": 1,
"blog-training-data/blog-093-google-meta-microsoft-optics-strategy.md": 1, "blog-training-data/blog-093-google-meta-microsoft-optics-strategy.md": 1,
"blog-training-data/blog-019-cleaning-fiber-400g-tolerance.md": 1, "blog-training-data/blog-019-cleaning-fiber-400g-tolerance.md": 1,
"blog-training-data/blog-102-compliance-checklist-imported-transceivers.md": 1,
"blog-training-data/blog-175-cloud-networking-deep-dive-vpc-containers-mesh.md": 1,
"blog-training-data/blog-026-400g-zr-vs-zrplus.md": 1, "blog-training-data/blog-026-400g-zr-vs-zrplus.md": 1,
"blog-training-data/blog-035-esd-damage-transceivers.md": 1, "blog-training-data/blog-035-esd-damage-transceivers.md": 1,
"blog-training-data/blog-199-industry-analyst-relations-gartner-forrester.md": 1,
"blog-training-data/blog-124-network-automation-optical-infrastructure.md": 1,
"blog-training-data/blog-123-silicon-photonics-co-packaged-optics.md": 1,
"blog-training-data/blog-087-rj45-vs-sfp-copper-1g-switches.md": 1, "blog-training-data/blog-087-rj45-vs-sfp-copper-1g-switches.md": 1,
"blog-training-data/blog-132-quantum-networking-optical-infrastructure.md": 1,
"blog-training-data/blog-120-telco-5g-6g-fronthaul-midhaul-backhaul.md": 1,
"blog-training-data/blog-009-100g-to-400g-migration-what-breaks.md": 1, "blog-training-data/blog-009-100g-to-400g-migration-what-breaks.md": 1,
"blog-training-data/blog-104-ai-chip-shortage-optics-supply.md": 1,
"blog-training-data/blog-034-grey-optics-vs-dwdm-metro-aggregation.md": 1, "blog-training-data/blog-034-grey-optics-vs-dwdm-metro-aggregation.md": 1,
"blog-training-data/blog-167-security-layers-defense-depth-optical-networks.md": 1,
"blog-training-data/blog-154-optical-network-roi-business-value-analysis.md": 1,
"blog-training-data/blog-082-coherent-dsp-power-consumption.md": 1, "blog-training-data/blog-082-coherent-dsp-power-consumption.md": 1,
"blog-training-data/blog-062-transceiver-inventory-management-excel-vs-cmdb.md": 1, "blog-training-data/blog-062-transceiver-inventory-management-excel-vs-cmdb.md": 1,
"blog-training-data/blog-088-transceiver-sff-committee-history.md": 1, "blog-training-data/blog-088-transceiver-sff-committee-history.md": 1,
"blog-training-data/blog-098-carrier-ethernet-timing-syncE-ptp-optics.md": 1, "blog-training-data/blog-098-carrier-ethernet-timing-syncE-ptp-optics.md": 1,
"blog-training-data/blog-122-pam4-pam8-modulation-data-center.md": 1,
"blog-training-data/blog-003-silicon-photonics.md": 1, "blog-training-data/blog-003-silicon-photonics.md": 1,
"blog-training-data/blog-130-edge-computing-network-optics-future.md": 1,
"blog-training-data/blog-037-fec-deep-dive.md": 1, "blog-training-data/blog-037-fec-deep-dive.md": 1,
"blog-training-data/blog-099-transceiver-market-2026-pricing-forecast.md": 1, "blog-training-data/blog-099-transceiver-market-2026-pricing-forecast.md": 1,
"blog-training-data/blog-155-optical-networking-knowledge-management.md": 1,
"blog-training-data/blog-021-validating-compatible-optics.md": 1, "blog-training-data/blog-021-validating-compatible-optics.md": 1,
"blog-training-data/blog-176-greenfield-network-infrastructure-complete-build.md": 1,
"blog-training-data/blog-023-pam4-800g-fec-errors.md": 1, "blog-training-data/blog-023-pam4-800g-fec-errors.md": 1,
"blog-training-data/blog-204-customer-marketing-advocacy-programs.md": 1,
"blog-training-data/blog-030-when-to-upgrade-from-10g.md": 1, "blog-training-data/blog-030-when-to-upgrade-from-10g.md": 1,
"blog-training-data/blog-131-telco-carrier-grade-optical-operations.md": 1,
"blog-training-data/blog-002-vendor-lock-in-optics.md": 1, "blog-training-data/blog-002-vendor-lock-in-optics.md": 1,
"blog-training-data/blog-198-complete-content-engine-operating-system.md": 1,
"blog-training-data/blog-173-internet-architecture-deep-dive-bgp-ixps-peering.md": 1,
"blog-training-data/blog-220-investor-relations-content-tech-companies.md": 1,
"blog-training-data/blog-225-privacy-data-protection-content-practices.md": 1,
"blog-training-data/blog-180-network-project-management-permitting-execution.md": 1,
"blog-training-data/blog-224-generative-ai-future-content-marketing.md": 1,
"blog-training-data/blog-081-transceiver-rma-process-best-practices.md": 1, "blog-training-data/blog-081-transceiver-rma-process-best-practices.md": 1,
"blog-training-data/blog-013-price-drop-timing-when-to-buy.md": 1, "blog-training-data/blog-013-price-drop-timing-when-to-buy.md": 1,
"blog-training-data/blog-160-future-of-optical-networking-comprehensive.md": 1,
"blog-training-data/blog-095-optical-lan-versus-fiber-ethernet.md": 1, "blog-training-data/blog-095-optical-lan-versus-fiber-ethernet.md": 1,
"blog-training-data/blog-117-submarine-cable-coherent-long-haul.md": 1,
"blog-training-data/blog-067-single-mode-fiber-types-g652-g657.md": 1, "blog-training-data/blog-067-single-mode-fiber-types-g652-g657.md": 1,
"blog-training-data/blog-177-site-survey-capacity-planning-methodology.md": 1,
"blog-training-data/blog-039-cmis-400g-management.md": 1, "blog-training-data/blog-039-cmis-400g-management.md": 1,
"blog-training-data/blog-213-original-research-proprietary-data.md": 1,
"blog-training-data/blog-226-accessibility-inclusive-content-design.md": 1,
"blog-training-data/blog-142-network-design-patterns-optical-architecture.md": 1,
"blog-training-data/blog-113-rma-warranty-optimization.md": 1,
"blog-training-data/blog-071-sff-8024-transceiver-id-codes.md": 1, "blog-training-data/blog-071-sff-8024-transceiver-id-codes.md": 1,
"blog-training-data/blog-097-liquid-cooling-impact-optical-transceivers.md": 1, "blog-training-data/blog-097-liquid-cooling-impact-optical-transceivers.md": 1,
"blog-training-data/blog-007-800g-readiness.md": 1, "blog-training-data/blog-007-800g-readiness.md": 1,
"blog-training-data/blog-058-arista-eos-optic-compatibility.md": 1, "blog-training-data/blog-058-arista-eos-optic-compatibility.md": 1,
"blog-training-data/blog-136-emerging-protocols-cxl-roce-rdma.md": 1,
"blog-training-data/blog-068-25g-vs-10g-upgrade-path-decision.md": 1, "blog-training-data/blog-068-25g-vs-10g-upgrade-path-decision.md": 1,
"blog-training-data/blog-170-network-management-protocols-comprehensive-snmp-netconf.md": 1,
"blog-training-data/blog-061-cfp2-cfp4-qsfp28-form-factor-migration.md": 1, "blog-training-data/blog-061-cfp2-cfp4-qsfp28-form-factor-migration.md": 1,
"blog-training-data/blog-147-optical-network-testing-validation-procedures.md": 1,
"blog-training-data/blog-079-ip-optical-integration-disaggregation.md": 1, "blog-training-data/blog-079-ip-optical-integration-disaggregation.md": 1,
"blog-training-data/blog-129-manufacturing-iot-industrial-network.md": 1,
"blog-training-data/blog-046-transceiver-counterfeit-detection.md": 1, "blog-training-data/blog-046-transceiver-counterfeit-detection.md": 1,
"blog-training-data/blog-183-perfect-hooks-teasers-curiosity-gap.md": 1,
"blog-training-data/blog-056-cisco-qsfp28-compatibility-list.md": 1, "blog-training-data/blog-056-cisco-qsfp28-compatibility-list.md": 1,
"blog-training-data/blog-005-coherent-400zr-reality.md": 1, "blog-training-data/blog-005-coherent-400zr-reality.md": 1,
"blog-training-data/blog-203-executive-personal-brand-technical-leaders.md": 1,
"blog-training-data/blog-109-third-party-optics-validation-lab-testing.md": 1,
"blog-training-data/blog-065-dwdm-channel-plan-100ghz-vs-50ghz.md": 1, "blog-training-data/blog-065-dwdm-channel-plan-100ghz-vs-50ghz.md": 1,
"blog-training-data/blog-227-emerging-platforms-content-innovation.md": 1,
"blog-training-data/blog-078-pon-gpon-xgspon-optics-explainer.md": 1, "blog-training-data/blog-078-pon-gpon-xgspon-optics-explainer.md": 1,
"blog-training-data/blog-051-spine-leaf-transceiver-strategy.md": 1, "blog-training-data/blog-051-spine-leaf-transceiver-strategy.md": 1,
"blog-training-data/blog-032-msa-compliance-vs-interoperability.md": 1, "blog-training-data/blog-032-msa-compliance-vs-interoperability.md": 1,
"blog-training-data/blog-064-optic-burn-in-testing.md": 1, "blog-training-data/blog-064-optic-burn-in-testing.md": 1,
"blog-training-data/blog-114-counterfeit-detection-supply-chain.md": 1,
"blog-training-data/blog-133-disaggregated-networking-future-architecture.md": 1,
"blog-training-data/blog-105-why-it-teams-care-optics.md": 1,
"blog-training-data/blog-001-400g-dr4-price-war.md": 1, "blog-training-data/blog-001-400g-dr4-price-war.md": 1,
"blog-training-data/blog-040-evaluating-compatible-vendor.md": 1, "blog-training-data/blog-040-evaluating-compatible-vendor.md": 1,
"blog-training-data/blog-211-employee-advocacy-internal-content.md": 1,
"blog-training-data/blog-202-video-podcast-content-b2b-tech.md": 1,
"blog-training-data/blog-042-800g-osfp-vs-qsfp-dd-port-density.md": 1, "blog-training-data/blog-042-800g-osfp-vs-qsfp-dd-port-density.md": 1,
"blog-training-data/blog-140-future-optical-networking-2030.md": 1,
"blog-training-data/blog-139-disaster-recovery-business-continuity-optical.md": 1,
"blog-training-data/blog-148-vendor-relationship-strategic-partnerships.md": 1,
"blog-training-data/blog-100-flexoptix-programming-service-technical.md": 1, "blog-training-data/blog-100-flexoptix-programming-service-technical.md": 1,
"blog-training-data/blog-118-ai-ml-workload-network-optics.md": 1,
"blog-training-data/blog-076-cisco-nexus-vs-catalyst-optic-behavior.md": 1, "blog-training-data/blog-076-cisco-nexus-vs-catalyst-optic-behavior.md": 1,
"blog-training-data/blog-053-cisco-juniper-arista-optic-lock-in.md": 1, "blog-training-data/blog-053-cisco-juniper-arista-optic-lock-in.md": 1,
"blog-training-data/blog-044-laser-safety-class-1m-transceivers.md": 1, "blog-training-data/blog-044-laser-safety-class-1m-transceivers.md": 1,
"blog-training-data/blog-152-optical-network-architecture-evolution-2025-2030.md": 1,
"blog-training-data/blog-094-transceiver-programming-eeprom-guide.md": 1, "blog-training-data/blog-094-transceiver-programming-eeprom-guide.md": 1,
"blog-training-data/blog-222-content-marketing-team-development.md": 1,
"blog-training-data/blog-085-ai-inference-cluster-optics-requirements.md": 1, "blog-training-data/blog-085-ai-inference-cluster-optics-requirements.md": 1,
"blog-training-data/blog-206-crisis-communications-reputation-management.md": 1,
"blog-training-data/blog-188-email-marketing-b2b-technical-content.md": 1,
"blog-training-data/blog-149-network-security-zero-trust-optical-implementation.md": 1,
"blog-training-data/blog-216-ai-ethics-responsible-content-creation.md": 1,
"blog-training-data/blog-182-science-of-perfect-blog-writing.md": 1,
"blog-training-data/blog-194-brand-voice-architecture-technical-companies.md": 1,
"blog-training-data/blog-163-network-skills-careers-optical-engineering-future.md": 1,
"blog-training-data/blog-052-roa-replacing-optics-proactively.md": 1, "blog-training-data/blog-052-roa-replacing-optics-proactively.md": 1,
"blog-training-data/blog-162-network-as-a-service-cloud-native-optical.md": 1,
"blog-training-data/blog-090-optics-for-5g-fronthaul-midhaul.md": 1, "blog-training-data/blog-090-optics-for-5g-fronthaul-midhaul.md": 1,
"blog-training-data/blog-126-fintech-financial-services-network-optics.md": 1,
"blog-training-data/blog-201-sales-enablement-content-strategy.md": 1,
"blog-training-data/blog-186-perfect-blog-engine-architecture-synthesis.md": 1,
"blog-training-data/blog-041-silicon-photonics-co-packaging-2026.md": 1, "blog-training-data/blog-041-silicon-photonics-co-packaging-2026.md": 1,
"blog-training-data/blog-156-network-protocols-l1-encryption-deep-dive.md": 1,
"blog-training-data/blog-096-dark-fiber-leasing-optics-considerations.md": 1, "blog-training-data/blog-096-dark-fiber-leasing-optics-considerations.md": 1,
"blog-training-data/blog-108-advanced-fiber-contamination-diagnostics.md": 1,
"blog-training-data/blog-215-recruiting-employer-branding-content.md": 1,
"blog-training-data/blog-112-open-networking-optics-ecosystem.md": 1,
"blog-training-data/blog-121-400g-800g-coherent-optics-deep-dive.md": 1,
"blog-training-data/blog-084-ieee-802.3-standards-transceiver-reference.md": 1, "blog-training-data/blog-084-ieee-802.3-standards-transceiver-reference.md": 1,
"blog-training-data/blog-012-coherent-vs-direct-detect-decision.md": 1, "blog-training-data/blog-012-coherent-vs-direct-detect-decision.md": 1,
"blog-training-data/blog-165-optical-networking-comprehensive-reference-guide.md": 1,
"blog-training-data/blog-004-400g-migration-fiber-plant.md": 1, "blog-training-data/blog-004-400g-migration-fiber-plant.md": 1,
"blog-training-data/blog-115-healthcare-network-optics-compliance.md": 1,
"blog-training-data/blog-119-sustainability-carbon-footprint-optical.md": 1,
"blog-training-data/blog-060-fiber-connector-cleaning-protocol.md": 1, "blog-training-data/blog-060-fiber-connector-cleaning-protocol.md": 1,
"blog-training-data/blog-143-network-protocols-modern-optical-infrastructure.md": 1,
"blog-training-data/blog-172-transceiver-form-factors-complete-reference.md": 1,
"blog-training-data/blog-207-localization-international-content-strategy.md": 1,
"blog-training-data/blog-106-fiber-diagnostics-eye-diagrams.md": 1,
"blog-training-data/blog-158-network-time-synchronization-precision-timing.md": 1,
"blog-training-data/blog-217-strategic-partnerships-co-marketing.md": 1,
"blog-training-data/blog-027-fiber-plant-audit-100g-upgrade.md": 1, "blog-training-data/blog-027-fiber-plant-audit-100g-upgrade.md": 1,
"blog-training-data/blog-016-400g-qsfp-dd-after-fiber-moves.md": 1, "blog-training-data/blog-016-400g-qsfp-dd-after-fiber-moves.md": 1,
"blog-training-data/blog-145-data-center-interconnect-dci-optical-design.md": 1,
"blog-training-data/blog-205-product-launch-content-strategy.md": 1,
"blog-training-data/blog-074-fiber-optic-patch-cord-standards.md": 1, "blog-training-data/blog-074-fiber-optic-patch-cord-standards.md": 1,
"blog-training-data/blog-057-juniper-optic-unlock-ex-qfx.md": 1, "blog-training-data/blog-057-juniper-optic-unlock-ex-qfx.md": 1,
"blog-training-data/blog-196-newsletter-strategy-technical-audiences.md": 1,
"blog-training-data/blog-214-press-relations-media-strategy.md": 1,
"blog-training-data/blog-022-oem-vs-compatible-lab-tests.md": 1, "blog-training-data/blog-022-oem-vs-compatible-lab-tests.md": 1,
"blog-training-data/blog-218-sustainable-content-marketing-practice.md": 1,
"blog-training-data/blog-020-100g-link-drops-temperature.md": 1, "blog-training-data/blog-020-100g-link-drops-temperature.md": 1,
"blog-training-data/blog-191-editorial-operations-content-engine-management.md": 1,
"blog-training-data/blog-146-optical-network-capacity-planning-bandwidth.md": 1,
"blog-training-data/blog-050-optical-transceiver-temperature-grades.md": 1, "blog-training-data/blog-050-optical-transceiver-temperature-grades.md": 1,
"blog-training-data/blog-208-community-building-technical-content.md": 1,
"blog-training-data/blog-111-cisco-arista-juniper-optics-strategies.md": 1,
"blog-training-data/blog-141-optical-network-cost-engineering-tco.md": 1,
"blog-training-data/blog-036-coherent-tunable-vs-fixed-wavelength.md": 1, "blog-training-data/blog-036-coherent-tunable-vs-fixed-wavelength.md": 1,
"blog-training-data/blog-181-neurolinguistic-persuasion-blog-writing.md": 1,
"blog-training-data/blog-209-account-based-marketing-abm-content.md": 1,
"blog-training-data/blog-200-webinar-virtual-event-content-strategy.md": 1,
"blog-training-data/blog-077-pam4-vs-nrz-modulation-transceivers.md": 1, "blog-training-data/blog-077-pam4-vs-nrz-modulation-transceivers.md": 1,
"blog-training-data/blog-212-interactive-content-calculators-tools.md": 1,
"blog-training-data/blog-080-fcoe-fibre-channel-sfp-differences.md": 1, "blog-training-data/blog-080-fcoe-fibre-channel-sfp-differences.md": 1,
"blog-training-data/blog-168-optical-transceiver-manufacturers-comprehensive-landscape.md": 1,
"blog-training-data/blog-043-zr-zr-plus-coherent-pluggables-comparison.md": 1, "blog-training-data/blog-043-zr-zr-plus-coherent-pluggables-comparison.md": 1,
"blog-training-data/blog-049-wavelength-division-multiplexing-primer.md": 1, "blog-training-data/blog-049-wavelength-division-multiplexing-primer.md": 1,
"blog-training-data/blog-089-metro-dwdm-open-vs-proprietary.md": 1, "blog-training-data/blog-089-metro-dwdm-open-vs-proprietary.md": 1,
"blog-training-data/blog-128-government-federal-network-optics.md": 1,
"blog-training-data/blog-116-carrier-isp-optics-operations.md": 1,
"blog-training-data/blog-073-qsfp-dd-800g-ecosystem-2026.md": 1, "blog-training-data/blog-073-qsfp-dd-800g-ecosystem-2026.md": 1,
"blog-training-data/blog-210-marketing-automation-lead-nurturing.md": 1,
"blog-training-data/blog-189-linkedin-social-distribution-b2b-tech.md": 1,
"blog-training-data/blog-018-800g-sr8-dr8-fr8-comparison.md": 1, "blog-training-data/blog-018-800g-sr8-dr8-fr8-comparison.md": 1,
"blog-training-data/blog-029-800g-osfp-spineleaf-checklist.md": 1, "blog-training-data/blog-029-800g-osfp-spineleaf-checklist.md": 1,
"blog-training-data/blog-110-wavelength-tuning-dwdm.md": 1,
"blog-training-data/blog-103-carbon-footprint-oem-compatible-tco.md": 1,
"blog-training-data/blog-137-regional-optical-network-considerations-global.md": 1,
"blog-training-data/blog-006-dom-diagnostics.md": 1, "blog-training-data/blog-006-dom-diagnostics.md": 1,
"blog-training-data/blog-157-multicast-video-broadcast-optical-networks.md": 1,
"blog-training-data/blog-185-b2b-decision-psychology-trust-signals.md": 1,
"blog-training-data/blog-223-final-capstone-sustainable-excellence.md": 1,
"blog-training-data/blog-075-transceiver-failure-root-cause-analysis.md": 1, "blog-training-data/blog-075-transceiver-failure-root-cause-analysis.md": 1,
"blog-training-data/blog-190-content-repurposing-multi-format-strategy.md": 1,
"blog-training-data/blog-184-perfect-visuals-infographics-header-design.md": 1,
"blog-training-data/blog-048-400g-dr4-fr4-lr4-comparison.md": 1, "blog-training-data/blog-048-400g-dr4-fr4-lr4-comparison.md": 1,
"blog-training-data/blog-031-cwdm4-vs-psm4-100g-datacenter.md": 1, "blog-training-data/blog-031-cwdm4-vs-psm4-100g-datacenter.md": 1,
"blog-training-data/blog-059-100g-sr4-multimode-distance-limits.md": 1, "blog-training-data/blog-059-100g-sr4-multimode-distance-limits.md": 1,

View File

@ -1,9 +1,9 @@
{ {
"raw_pairs": 12268, "raw_pairs": 12141,
"duplicates_removed": 269, "duplicates_removed": 269,
"training_pairs": 11999, "training_pairs": 11872,
"train_pairs": 10799, "train_pairs": 10684,
"eval_pairs": 1200, "eval_pairs": 1188,
"sources": { "sources": {
"external:vendor-deep-dives.jsonl": 11200, "external:vendor-deep-dives.jsonl": 11200,
"external:technical-deep-dives.jsonl": 84, "external:technical-deep-dives.jsonl": 84,
@ -12,10 +12,8 @@
"external:synthesized-training-samples.jsonl": 219, "external:synthesized-training-samples.jsonl": 219,
"external:nanog-ripe-labs-content.jsonl": 34, "external:nanog-ripe-labs-content.jsonl": 34,
"external:academic-research-synthesis.jsonl": 109, "external:academic-research-synthesis.jsonl": 109,
"training-data/tip-llm-pricing-v1.jsonl": 80, "training-data/tip-llm-capabilities-v1.jsonl": 34,
"training-data/tip-llm-capabilities-v1.jsonl": 69,
"external:market-business-analysis-part6.jsonl": 5, "external:market-business-analysis-part6.jsonl": 5,
"robot-control-high.jsonl": 12,
"external:market-business-analysis-part5.jsonl": 7, "external:market-business-analysis-part5.jsonl": 7,
"external:market-business-analysis-part4.jsonl": 5, "external:market-business-analysis-part4.jsonl": 5,
"external:market-business-analysis-part2.jsonl": 8, "external:market-business-analysis-part2.jsonl": 8,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long