Compare commits
No commits in common. "172ec324f2e32d4e5a230c9da8af526c21055f6b" and "9979b794342e086093fad7a353831273d1aef68e" have entirely different histories.
172ec324f2
...
9979b79434
@ -1,20 +1,7 @@
|
||||
# TIP Changelog
|
||||
|
||||
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-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-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-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":"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
163
package-lock.json
generated
@ -11,11 +11,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"pdf-parse": "^1.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"tsx": "^4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"xlsx": "^0.18.5"
|
||||
@ -1666,16 +1662,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
@ -1685,16 +1671,6 @@
|
||||
"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": {
|
||||
"version": "8.20.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@ -2068,23 +2038,6 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz",
|
||||
@ -2379,21 +2332,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@ -4284,68 +4222,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||
@ -4379,12 +4255,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": {
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
@ -4698,22 +4568,6 @@
|
||||
"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": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
@ -5509,14 +5363,6 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@ -5728,12 +5574,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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@ -6236,15 +6076,12 @@
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
"multer": "^2.1.1",
|
||||
"pdf-parse": "^1.1.4",
|
||||
"pg": "^8.13.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@ -25,12 +25,8 @@
|
||||
"url": "https://github.com/renefichtmueller/transceiver-db"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"tsx": "^4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"pdf-parse": "^1.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,22 +10,19 @@
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.13.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
"multer": "^2.1.1",
|
||||
"pdf-parse": "^1.1.4",
|
||||
"pg": "^8.13.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.3"
|
||||
"@types/cors": "^2.8.17",
|
||||
"typescript": "^5.9.3",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 12–14W in standard configurations. As 800G modules and coherent ZR modules demand 20–35W, 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 12–14W in Standardkonfigurationen. Da 800G-Module und Coherent-ZR-Module 20–35W 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 2025–2027; pluggables will remain dominant for 5–10+ 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 2025–2027; Pluggables bleiben 5–10+ 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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -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 3–5 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 3–5 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: ~10–20% 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: ~10–20 % 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",
|
||||
"3–5 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",
|
||||
"3–5 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 3–5 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 3–5 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.3–0.5 dB", "Use 0.5 dB for budget"],
|
||||
["LC/APC connector mating", "0.1–0.3 dB", "Better return loss"],
|
||||
["Fusion splice", "0.05–0.15 dB", "Good splice; use 0.1 dB"],
|
||||
["Mechanical splice", "0.2–0.5 dB", "Avoid where possible"],
|
||||
["Patch panel (2 connectors)", "0.6–1.0 dB total", "2× connector losses"],
|
||||
["MPO-to-LC cassette (12-fiber)", "0.7–1.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.5–3 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,5–3 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 12–32 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 12–32 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 (1530–1565nm) or L-band (1565–1625nm). 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 (1530–1565 nm) oder L-Band (1565–1625 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 (1530–1565 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
@ -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 0–1) contain the 'Identifier' and 'Extended Identifier'. Bytes 20–35 store the vendor name in ASCII. Bytes 68–83 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 0–1) enthalten den 'Identifier' und 'Extended Identifier'. Bytes 20–35 speichern den Herstellernamen in ASCII. Bytes 68–83 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 20–35) und Hersteller-OUI (Bytes 68–83) 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).",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -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", "$150–500"],
|
||||
["Light Source", "Stable reference power output", "Paired with OPM for IL testing", "$100–400"],
|
||||
["OPM + Source Kit", "Insertion Loss (IL)", "Full link acceptance testing", "$300–800"],
|
||||
["OTDR", "Distance, loss per event, fiber trace", "Long fiber runs, splice/connector maps, breaks", "$2,000–25,000"],
|
||||
["VFL (Visual Fault Locator)", "Visible red light in fiber", "Find bends, breaks, continuity check, ID fibers", "$50–200"],
|
||||
["Fiber Inspection Microscope / Video Probe", "Connector endface contamination", "Before mating any connector", "$200–1,500"],
|
||||
["Optical Spectrum Analyzer (OSA)", "All DWDM channel powers and wavelengths", "WDM system commissioning and troubleshooting", "$15,000–100,000"],
|
||||
["BERT (Bit Error Rate Tester)", "End-to-end bit error rate", "Final acceptance, troubleshooting transient errors", "$5,000–50,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 5–50m near the OTDR is unmeasurable — use launch cable (25–50m) 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 5–50 m nahe dem OTDR sind nicht messbar — Startkabel (25–50 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 1–40 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 1–40 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 5–50m) is unmeasurable because the receiver is still recovering from the large pulse. A 25–50m launch cable 'pushes' the dead zone past the first connector of interest, allowing it to be measured.",
|
||||
explanation_de:
|
||||
"Die OTDR-Totzone (erste 5–50 m) ist unmessbar, weil der Empfaenger noch vom grossen Impuls erholt. Ein 25–50-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,000–300,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", "5–85% non-cond.", "5–95% 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 60–80% 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 60–80 % 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 3–10× more expensive than Tier 1 compatible",
|
||||
"Power cost: P(W) × hours/year × $/kWh — usually small per port but significant at scale",
|
||||
"Spares cost: maintain 2–5% 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 3–10× teurer als Tier-1-kompatibel",
|
||||
"Energiekosten: P(W) × Stunden/Jahr × EUR/kWh — pro Port meist gering, bei Skalierung erheblich",
|
||||
"Ersatzteilkosten: 2–5% 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 ($10–50K 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.000–50.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 2–5% 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 15–25°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: [
|
||||
"2–5% 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 15–25°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.000–800.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", "2–5%", "10–15%", "50% — one spare per port"],
|
||||
options_de: ["0% — nur bei Ausfall ersetzen", "2–5%", "10–15%", "50% — ein Ersatzgeraet pro Port"],
|
||||
answer: 1,
|
||||
explanation:
|
||||
"A 2–5% spare pool covers typical annual failure rates (0.5–1%) while minimizing tied-up capital. For critical links, maintain at least 2 individual spares regardless of percentage.",
|
||||
explanation_de:
|
||||
"Ein 2–5%-Ersatzteilpool deckt typische jaehrliche Ausfallraten (0,5–1%) 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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -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[];
|
||||
}
|
||||
@ -28,7 +28,7 @@ export async function searchTransceivers(params: SearchParams) {
|
||||
let idx = 1;
|
||||
|
||||
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);
|
||||
idx++;
|
||||
}
|
||||
@ -98,8 +98,8 @@ export async function searchTransceivers(params: SearchParams) {
|
||||
|
||||
// Add relevance ranking when full-text search is used
|
||||
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 fully_verified DESC NULLS LAST, has_image DESC NULLS LAST, speed_gbps DESC NULLS LAST, reach_meters ASC NULLS LAST`;
|
||||
? `ORDER BY ts_rank(search_vector, plainto_tsquery('english', $1)) DESC`
|
||||
: `ORDER BY speed_gbps DESC, reach_meters ASC`;
|
||||
|
||||
const query = `
|
||||
SELECT t.*, v.name as vendor_name
|
||||
|
||||
@ -35,19 +35,6 @@ import { selflearningRouter } from "./routes/selflearning";
|
||||
import { internalDemandRouter } from "./routes/internal-demand";
|
||||
import { formFactorsRouter } from "./routes/form-factors";
|
||||
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();
|
||||
|
||||
@ -57,7 +44,7 @@ app.set("trust proxy", 1);
|
||||
// Middleware
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: "30mb" })); // 30MB to support base64-encoded PDF uploads
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
@ -80,7 +67,6 @@ app.use("/api", (req, res, next) => {
|
||||
if (req.path.startsWith("/proxy")) return next();
|
||||
if (req.path.startsWith("/hot-topics")) return next();
|
||||
if (req.path.startsWith("/price-comparison")) return next();
|
||||
if (req.path.startsWith("/training")) return next();
|
||||
requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
@ -116,31 +102,6 @@ app.use("/api/form-factors", formFactorsRouter);
|
||||
app.use("/api/internal/demand", internalDemandRouter);
|
||||
// tip-llm-v1 guided inference
|
||||
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)
|
||||
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
|
||||
|
||||
@ -22,191 +22,36 @@ const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:325
|
||||
// ── Runtime-switchable provider state ──────────────────────────────────────
|
||||
// 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.
|
||||
//
|
||||
// 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 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 {
|
||||
provider: string;
|
||||
ollamaModel: string;
|
||||
/** When set, auto-upgrade is disabled and this exact version is used. */
|
||||
pinnedVersion?: string;
|
||||
}
|
||||
interface LlmSettings { provider: string; ollamaModel: string }
|
||||
|
||||
function loadSettingsRaw(): LlmSettings {
|
||||
function loadSettings(): LlmSettings {
|
||||
try {
|
||||
if (existsSync(SETTINGS_FILE)) {
|
||||
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<LlmSettings>;
|
||||
return {
|
||||
provider: raw.provider || process.env.BLOG_LLM_PROVIDER || "ollama",
|
||||
ollamaModel: raw.ollamaModel || process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
|
||||
pinnedVersion: raw.pinnedVersion || undefined,
|
||||
};
|
||||
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings;
|
||||
return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" };
|
||||
}
|
||||
} catch { /* ignore corrupt file */ }
|
||||
return {
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
let _settings = loadSettings();
|
||||
|
||||
interface OllamaTag { name: string }
|
||||
interface OllamaTagsResponse { models: OllamaTag[] }
|
||||
|
||||
/** 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 };
|
||||
/** Switch the active LLM provider at runtime. Persists to settings file. */
|
||||
export function setLlmProvider(provider: string, ollamaModel?: string): void {
|
||||
_settings = { provider, ollamaModel: ollamaModel || _settings.ollamaModel };
|
||||
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
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 };
|
||||
console.log(`[LLM] Provider switched → ${provider}${ollamaModel ? ` (${ollamaModel})` : ""}`);
|
||||
}
|
||||
|
||||
/** Returns the currently active provider config. */
|
||||
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)
|
||||
function provider(): string { return _settings.provider; }
|
||||
function llmModel(): string { return _settings.ollamaModel; }
|
||||
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -10,11 +10,8 @@
|
||||
* Voice: Senior optical network engineer, not marketing.
|
||||
*/
|
||||
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 { 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 */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
async function runLlmPipeline(
|
||||
draftId: string,
|
||||
@ -1167,12 +993,6 @@ async function runLlmPipeline(
|
||||
data: Awaited<ReturnType<typeof gatherBlogData>>,
|
||||
additionalContext?: string,
|
||||
): 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
|
||||
const {
|
||||
FO_BLOG_SYSTEM_PROMPT,
|
||||
@ -1220,48 +1040,7 @@ async function runLlmPipeline(
|
||||
})));
|
||||
} catch { /* no feedback yet, that's fine */ }
|
||||
|
||||
// For external content (from-url / from-pdf), the Flexoptix optical-networking persona
|
||||
// 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);
|
||||
const systemPrompt = withCalibration(FO_BLOG_SYSTEM_PROMPT + feedbackContext);
|
||||
|
||||
// Warmup
|
||||
await generate("Test", "OK", { temperature: 0.1, maxTokens: 8, timeoutMs: 60000 }).catch(() => {});
|
||||
@ -1321,23 +1100,11 @@ ${writingStyleRules}`;
|
||||
// ═══ STEP 1: Topic Expansion ═══
|
||||
console.log(" 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,
|
||||
step1TopicPrefix + STEP1_TOPIC_EXPANSION
|
||||
.replace("{{TOPIC}}", isExternalContent ? extractedTopicName : title)
|
||||
STEP1_TOPIC_EXPANSION
|
||||
.replace("{{TOPIC}}", title)
|
||||
.replace("{{ADDITIONAL_CONTEXT}}", additionalContext
|
||||
? `\n\n---\nBACKGROUND REFERENCE (use as factual direction ONLY — do not copy verbatim):\n${additionalContext.slice(0, 4000)}\n\n` +
|
||||
(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.`)
|
||||
? `\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.`
|
||||
: ""),
|
||||
LLM_OPTS
|
||||
);
|
||||
@ -1580,12 +1347,10 @@ ${writingStyleRules}`;
|
||||
const finalIssues = validateArticle(draftContent);
|
||||
|
||||
// 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(
|
||||
`UPDATE blog_drafts
|
||||
SET title = $9, draft_content = $1, word_count = $2,
|
||||
generated_by = $10,
|
||||
generated_by = 'fo-blog-engine-v7',
|
||||
pipeline_version = 'v7',
|
||||
pipeline_steps_completed = $3,
|
||||
auto_qa_score = $4,
|
||||
@ -1612,7 +1377,6 @@ ${writingStyleRules}`;
|
||||
linkedinCharCount,
|
||||
draftId,
|
||||
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(/&/g, "&").replace(/</g, "<").replace(/>/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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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
|
||||
blogRouter.get("/", async (_req: Request, res: Response) => {
|
||||
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" });
|
||||
});
|
||||
|
||||
// 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)
|
||||
// Body: { provider: "claude-code" | "anthropic" | "ollama", model?: "fo-blog-v10" | "qwen2.5:14b" | ... }
|
||||
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/progress — Real-time pipeline step progress (in-memory)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -11,7 +11,6 @@
|
||||
*/
|
||||
import { Router, Request, Response } from "express";
|
||||
import { pool } from "../db/client";
|
||||
import { sendCSV } from "../utils/csv";
|
||||
|
||||
export const priceComparisonRouter = Router();
|
||||
|
||||
@ -97,10 +96,9 @@ priceComparisonRouter.get("/summary", async (_req: Request, res: Response) => {
|
||||
// ─── GET /api/price-comparison ───────────────────────────────────────────────
|
||||
/**
|
||||
* 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) => {
|
||||
const fmt = req.query.format as string | undefined;
|
||||
priceComparisonRouter.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
WITH latest AS (
|
||||
@ -140,9 +138,6 @@ priceComparisonRouter.get("/", async (req: Request, res: Response) => {
|
||||
LIMIT 50
|
||||
`);
|
||||
|
||||
if (fmt === "csv") {
|
||||
return sendCSV(res, result.rows, `tip-price-comparison-${new Date().toISOString().slice(0,10)}.csv`);
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -9,12 +9,9 @@
|
||||
* GET /api/procurement/market-intel — Market intelligence events
|
||||
* GET /api/procurement/stock-trends/:id — Stock history for a transceiver
|
||||
* 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 { pool } from "../db/client";
|
||||
import { sendCSV } from "../utils/csv";
|
||||
|
||||
export const procurementRouter = Router();
|
||||
|
||||
@ -25,13 +22,11 @@ procurementRouter.get("/overview", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [signals, abc, intel, lifecycle] = await Promise.all([
|
||||
pool.query(`
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (transceiver_id) signal
|
||||
SELECT signal, COUNT(*) AS count
|
||||
FROM reorder_signals
|
||||
WHERE expires_at > NOW()
|
||||
ORDER BY transceiver_id, computed_at DESC
|
||||
)
|
||||
SELECT signal, COUNT(*) AS count FROM latest GROUP BY signal
|
||||
AND computed_at = (SELECT MAX(r2.computed_at) FROM reorder_signals r2 WHERE r2.transceiver_id = reorder_signals.transceiver_id)
|
||||
GROUP BY signal
|
||||
`),
|
||||
pool.query(`
|
||||
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"
|
||||
} = req.query;
|
||||
|
||||
// Use DISTINCT ON with the existing idx_reorder_transceiver index instead of
|
||||
// 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
|
||||
)
|
||||
let sql = `
|
||||
SELECT rs.*,
|
||||
t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
|
||||
t.reach_label, t.image_url, t.image_r2_key,
|
||||
ac.abc_class, ac.demand_score, ac.supply_risk,
|
||||
v.name AS vendor_name
|
||||
FROM latest rs
|
||||
FROM reorder_signals rs
|
||||
JOIN transceivers t ON rs.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
|
||||
WHERE 1=1${signalFilter}${abcFilter}${ffFilter}${speedFilter}
|
||||
ORDER BY rs.signal_strength DESC
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}
|
||||
WHERE rs.expires_at > NOW()
|
||||
AND rs.computed_at = (
|
||||
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);
|
||||
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));
|
||||
|
||||
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 });
|
||||
} catch (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" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -8,7 +8,6 @@
|
||||
*/
|
||||
import { Router, Request, Response } from "express";
|
||||
import { semanticSearch, getCollectionInfo, CollectionName } from "../embeddings/client";
|
||||
import { searchTransceivers } from "../db/queries";
|
||||
|
||||
export const searchRouter = Router();
|
||||
|
||||
@ -44,20 +43,11 @@ searchRouter.get("/", async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
let results: any[];
|
||||
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);
|
||||
}
|
||||
const results = await semanticSearch(collection, query, limit);
|
||||
res.json({
|
||||
success: true,
|
||||
query,
|
||||
collection,
|
||||
fallback: usedFallback ? "fts" : undefined,
|
||||
results: results.map((r) => ({
|
||||
id: r.id,
|
||||
score: Math.round(r.score * 1000) / 1000,
|
||||
@ -66,14 +56,6 @@ searchRouter.get("/", async (req: Request, res: Response) => {
|
||||
count: results.length,
|
||||
});
|
||||
} 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({
|
||||
success: false,
|
||||
error: "Vector search unavailable",
|
||||
|
||||
@ -7,8 +7,6 @@
|
||||
* Routes:
|
||||
* GET /api/stock — Latest obs per transceiver × vendor (paginated)
|
||||
* 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
|
||||
*/
|
||||
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 ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* Full observation history for one transceiver.
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -136,164 +136,3 @@ vendorRouter.get("/:id", async (req: Request, res: Response) => {
|
||||
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) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -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
|
||||
}
|
||||
@ -31,28 +31,13 @@
|
||||
}
|
||||
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');
|
||||
if (pipelineEl) {
|
||||
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="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-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 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>' +
|
||||
@ -152,7 +137,7 @@
|
||||
if (bar) bar.style.width = prog.pct + '%';
|
||||
if (pct) pct.textContent = prog.pct + '%';
|
||||
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 {
|
||||
_stallCount++;
|
||||
// After 5 consecutive non-running polls (~40s), show stall warning
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -222,10 +222,6 @@ export async function upsertPriceObservation(params: {
|
||||
leadTimeDays?: number;
|
||||
url?: 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> {
|
||||
// Normalize price to USD for sanity check (rough conversion)
|
||||
const priceUsd = params.currency === "EUR" ? params.price * 1.09
|
||||
@ -251,16 +247,12 @@ export async function upsertPriceObservation(params: {
|
||||
[params.transceiverId, params.sourceVendorId]
|
||||
);
|
||||
|
||||
// Check if vendor is a competitor (non-Flexoptix) for competitor_verified flag.
|
||||
// Also fetch slug so we can tag price_observations.marketplace automatically.
|
||||
// Check if vendor is a competitor (non-Flexoptix) for competitor_verified flag
|
||||
const vendorRow = await pool.query(
|
||||
`SELECT is_competitor, slug FROM vendors WHERE id = $1`,
|
||||
`SELECT is_competitor FROM vendors WHERE id = $1`,
|
||||
[params.sourceVendorId]
|
||||
);
|
||||
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
|
||||
const REFRESH_DAYS = 7;
|
||||
@ -307,10 +299,9 @@ export async function upsertPriceObservation(params: {
|
||||
await pool.query(
|
||||
`INSERT INTO price_observations (
|
||||
time, transceiver_id, source_vendor_id, price, currency, stock_level,
|
||||
quantity_available, lead_time_days, url, content_hash, is_verified, verified_at,
|
||||
marketplace, scrape_method
|
||||
quantity_available, lead_time_days, url, content_hash, is_verified, verified_at
|
||||
)
|
||||
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.sourceVendorId,
|
||||
@ -321,8 +312,6 @@ export async function upsertPriceObservation(params: {
|
||||
params.leadTimeDays || null,
|
||||
params.url || null,
|
||||
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
@ -1,29 +1,20 @@
|
||||
{
|
||||
"raw_pairs": 11635,
|
||||
"raw_pairs": 11508,
|
||||
"duplicates_removed": 100,
|
||||
"training_pairs": 11535,
|
||||
"train_pairs": 10381,
|
||||
"eval_pairs": 1154,
|
||||
"training_pairs": 11408,
|
||||
"train_pairs": 10267,
|
||||
"eval_pairs": 1141,
|
||||
"sources": {
|
||||
"external:vendor-deep-dives.jsonl": 11200,
|
||||
"blog-training-data/blog-164-network-research-innovation-emerging-technologies.md": 1,
|
||||
"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-091-wavelength-selective-switch-wss-explainer.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-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-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-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-028-400g-dac-3m-vs-5m.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-038-cpo-pluggable-future.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-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-169-optical-networking-competitive-landscape-analysis.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-086-hyperscale-optics-purchasing-strategy.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-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-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-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-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-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-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-062-transceiver-inventory-management-excel-vs-cmdb.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-122-pam4-pam8-modulation-data-center.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-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-176-greenfield-network-infrastructure-complete-build.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-131-telco-carrier-grade-optical-operations.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-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-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-177-site-survey-capacity-planning-methodology.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-097-liquid-cooling-impact-optical-transceivers.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-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-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-147-optical-network-testing-validation-procedures.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-183-perfect-hooks-teasers-curiosity-gap.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-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-227-emerging-platforms-content-innovation.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-032-msa-compliance-vs-interoperability.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-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-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-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-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-152-optical-network-architecture-evolution-2025-2030.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-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-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-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-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-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-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-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-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-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-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-218-sustainable-content-marketing-practice.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-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-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-212-interactive-content-calculators-tools.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-049-wavelength-division-multiplexing-primer.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-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-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-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-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-031-cwdm4-vs-psm4-100g-datacenter.md": 1,
|
||||
"blog-training-data/blog-059-100g-sr4-multimode-distance-limits.md": 1,
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
{
|
||||
"generated_at": "2026-05-13T19:32:40.656Z",
|
||||
"generated_at": "2026-04-25T21:56:31.560Z",
|
||||
"version": "TIP-LearningPool-v1",
|
||||
"lanes": {
|
||||
"tip_llm": {
|
||||
"raw_pairs": 12268,
|
||||
"raw_pairs": 12141,
|
||||
"duplicates_removed": 269,
|
||||
"training_pairs": 11999,
|
||||
"train_pairs": 10799,
|
||||
"eval_pairs": 1200,
|
||||
"training_pairs": 11872,
|
||||
"train_pairs": 10684,
|
||||
"eval_pairs": 1188,
|
||||
"sources": {
|
||||
"external:vendor-deep-dives.jsonl": 11200,
|
||||
"external:technical-deep-dives.jsonl": 84,
|
||||
@ -16,10 +16,8 @@
|
||||
"external:synthesized-training-samples.jsonl": 219,
|
||||
"external:nanog-ripe-labs-content.jsonl": 34,
|
||||
"external:academic-research-synthesis.jsonl": 109,
|
||||
"training-data/tip-llm-pricing-v1.jsonl": 80,
|
||||
"training-data/tip-llm-capabilities-v1.jsonl": 69,
|
||||
"training-data/tip-llm-capabilities-v1.jsonl": 34,
|
||||
"external:market-business-analysis-part6.jsonl": 5,
|
||||
"robot-control-high.jsonl": 12,
|
||||
"external:market-business-analysis-part5.jsonl": 7,
|
||||
"external:market-business-analysis-part4.jsonl": 5,
|
||||
"external:market-business-analysis-part2.jsonl": 8,
|
||||
@ -33,31 +31,22 @@
|
||||
}
|
||||
},
|
||||
"blog_llm": {
|
||||
"raw_pairs": 11635,
|
||||
"raw_pairs": 11508,
|
||||
"duplicates_removed": 100,
|
||||
"training_pairs": 11535,
|
||||
"train_pairs": 10381,
|
||||
"eval_pairs": 1154,
|
||||
"training_pairs": 11408,
|
||||
"train_pairs": 10267,
|
||||
"eval_pairs": 1141,
|
||||
"sources": {
|
||||
"external:vendor-deep-dives.jsonl": 11200,
|
||||
"blog-training-data/blog-164-network-research-innovation-emerging-technologies.md": 1,
|
||||
"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-091-wavelength-selective-switch-wss-explainer.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-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-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-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-028-400g-dac-3m-vs-5m.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-038-cpo-pluggable-future.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-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-169-optical-networking-competitive-landscape-analysis.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-086-hyperscale-optics-purchasing-strategy.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-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-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-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-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-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-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-062-transceiver-inventory-management-excel-vs-cmdb.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-122-pam4-pam8-modulation-data-center.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-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-176-greenfield-network-infrastructure-complete-build.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-131-telco-carrier-grade-optical-operations.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-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-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-177-site-survey-capacity-planning-methodology.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-097-liquid-cooling-impact-optical-transceivers.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-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-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-147-optical-network-testing-validation-procedures.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-183-perfect-hooks-teasers-curiosity-gap.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-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-227-emerging-platforms-content-innovation.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-032-msa-compliance-vs-interoperability.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-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-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-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-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-152-optical-network-architecture-evolution-2025-2030.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-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-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-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-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-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-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-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-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-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-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-218-sustainable-content-marketing-practice.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-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-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-212-interactive-content-calculators-tools.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-049-wavelength-division-multiplexing-primer.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-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-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-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-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-031-cwdm4-vs-psm4-100g-datacenter.md": 1,
|
||||
"blog-training-data/blog-059-100g-sr4-multimode-distance-limits.md": 1,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"raw_pairs": 12268,
|
||||
"raw_pairs": 12141,
|
||||
"duplicates_removed": 269,
|
||||
"training_pairs": 11999,
|
||||
"train_pairs": 10799,
|
||||
"eval_pairs": 1200,
|
||||
"training_pairs": 11872,
|
||||
"train_pairs": 10684,
|
||||
"eval_pairs": 1188,
|
||||
"sources": {
|
||||
"external:vendor-deep-dives.jsonl": 11200,
|
||||
"external:technical-deep-dives.jsonl": 84,
|
||||
@ -12,10 +12,8 @@
|
||||
"external:synthesized-training-samples.jsonl": 219,
|
||||
"external:nanog-ripe-labs-content.jsonl": 34,
|
||||
"external:academic-research-synthesis.jsonl": 109,
|
||||
"training-data/tip-llm-pricing-v1.jsonl": 80,
|
||||
"training-data/tip-llm-capabilities-v1.jsonl": 69,
|
||||
"training-data/tip-llm-capabilities-v1.jsonl": 34,
|
||||
"external:market-business-analysis-part6.jsonl": 5,
|
||||
"robot-control-high.jsonl": 12,
|
||||
"external:market-business-analysis-part5.jsonl": 7,
|
||||
"external:market-business-analysis-part4.jsonl": 5,
|
||||
"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
Loading…
x
Reference in New Issue
Block a user