Compare commits
113 Commits
erik-live-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529703df14 | ||
|
|
e24e9fa805 | ||
|
|
fd3467aa04 | ||
|
|
c9bfcdf7e0 | ||
|
|
3010f6f08d | ||
|
|
8475912083 | ||
|
|
d53acf53f4 | ||
|
|
71d828caea | ||
|
|
bc53c5ebf3 | ||
|
|
668911756c | ||
|
|
e7299ac779 | ||
|
|
723dfe7bdc | ||
|
|
9320a236c8 | ||
|
|
a4fffd05fd | ||
|
|
fcb1220586 | ||
|
|
7ec26c04ac | ||
|
|
f4a5aa4361 | ||
|
|
970a7ad9e8 | ||
|
|
6d03fac017 | ||
|
|
5280ac2001 | ||
|
|
b447db297a | ||
|
|
84a54f01e8 | ||
|
|
8d11a4d24d | ||
|
|
4806cdfd1c | ||
|
|
443c1f08c6 | ||
|
|
eb2888cb0e | ||
|
|
764f16d68f | ||
|
|
91d19e152d | ||
|
|
3ebb552647 | ||
|
|
73ad10f1a6 | ||
|
|
86d1531d52 | ||
|
|
3e0750eb3c | ||
| 201f5d3a47 | |||
|
|
332319975a | ||
|
|
60cffb3585 | ||
|
|
6e53cd4568 | ||
|
|
d9c37af642 | ||
|
|
b45027aa57 | ||
|
|
41e98ca5df | ||
|
|
479a0dbe56 | ||
|
|
98d0946975 | ||
|
|
414ea65fee | ||
|
|
0a586e0384 | ||
|
|
4299e24e13 | ||
|
|
dd2c917f05 | ||
|
|
7c9bd10941 | ||
|
|
1ba598e7ae | ||
|
|
3cbfabbb38 | ||
|
|
c6b5bcf844 | ||
|
|
5d69f0160c | ||
|
|
de40c65b01 | ||
|
|
00a5ac8e8b | ||
|
|
2dc21ee0aa | ||
|
|
3bec30bad3 | ||
|
|
eeaeda697a | ||
|
|
bd9d72d1bc | ||
|
|
df798d4430 | ||
|
|
7f13144c53 | ||
|
|
436f62bc2b | ||
|
|
2b7d5c7037 | ||
|
|
53518fa15a | ||
|
|
c66ffc92cb | ||
|
|
8c6df1028d | ||
|
|
121477810a | ||
|
|
87cd17808f | ||
|
|
bb4046bb1e | ||
|
|
c67fef1862 | ||
|
|
2432c0ddfc | ||
|
|
82934d3e0a | ||
|
|
a9f95fc552 | ||
|
|
0cf607040f | ||
|
|
03fdfa7d51 | ||
|
|
9bf7da3fda | ||
|
|
261135c544 | ||
|
|
0d3edd6ea9 | ||
|
|
8b232d942d | ||
|
|
8432a44574 | ||
|
|
f2b26e3286 | ||
|
|
e103a99822 | ||
|
|
842a85120b | ||
|
|
c6e79e9967 | ||
|
|
f067c0999d | ||
|
|
b720afc92c | ||
|
|
b5a961c3bd | ||
|
|
3577668cf3 | ||
|
|
b5925bc264 | ||
|
|
f2dad45c7c | ||
|
|
f81b67860b | ||
|
|
9b563b0378 | ||
|
|
eb954aab2e | ||
|
|
91b96a1e03 | ||
|
|
5a948245ff | ||
|
|
adfb590ad2 | ||
|
|
31434ba0f6 | ||
|
|
ae94fc8f47 | ||
|
|
e71b985c52 | ||
|
|
fb060ee40a | ||
|
|
d7c1c351fe | ||
|
|
bcab2b97af | ||
|
|
4bd16af9a5 | ||
|
|
10d13633fb | ||
|
|
13fe33eceb | ||
|
|
ea8be4aea3 | ||
|
|
67310c8fe7 | ||
|
|
e0f9656684 | ||
|
|
9b8b03e783 | ||
|
|
de179c4c7c | ||
|
|
0d7a92e749 | ||
|
|
637839e965 | ||
|
|
db6b97186a | ||
|
|
2f85571784 | ||
|
|
d1bde66e39 | ||
|
|
76492c17d5 |
105
.github/scripts/changelog-draft.py
vendored
Normal file
105
.github/scripts/changelog-draft.py
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draft CHANGELOG_PENDING.md entries from Conventional Commit messages
|
||||
pushed since the last run. Deterministic -- no LLM call, no network access.
|
||||
Human review still required before folding an entry into CHANGELOG.md.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
INCLUDE_TYPES = {
|
||||
"feat": "Added",
|
||||
"fix": "Fixed",
|
||||
"refactor": "Changed",
|
||||
"perf": "Changed",
|
||||
"security": "Fixed",
|
||||
}
|
||||
SKIP_TYPES = {"chore", "ci", "test", "docs", "style", "merge"}
|
||||
|
||||
# Anything mentioning internal infra by name/address is dropped rather than
|
||||
# drafted -- better a missing entry (caught in human review) than a leaked one.
|
||||
INTERNAL_PATTERN = re.compile(
|
||||
r"\b(erik|gitea\.context-x|192\.168\.|10\.10\.0\.|opnsense|ssh |systemd|pm2 |crontab)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
PENDING_HEADER = (
|
||||
"# Pending Changelog Entries\n\n"
|
||||
"Drafted automatically from commit messages since the last processed "
|
||||
"push. Review, edit as needed, and fold into CHANGELOG.md -- then clear "
|
||||
"this file.\n\n"
|
||||
"<!-- PENDING_ENTRIES -->\n"
|
||||
)
|
||||
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.check_output(cmd, shell=True, text=True).strip()
|
||||
|
||||
|
||||
def main():
|
||||
before = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
after = sys.argv[2] if len(sys.argv) > 2 else "HEAD"
|
||||
if not before or set(before) == {"0"}:
|
||||
print("No usable commit range (first push on branch) -- skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
log = run("git log {}..{} --format='%s'".format(before, after))
|
||||
except subprocess.CalledProcessError:
|
||||
print("Could not diff commit range -- skipping.")
|
||||
return
|
||||
if not log:
|
||||
print("No new commits.")
|
||||
return
|
||||
|
||||
buckets = {"Added": [], "Fixed": [], "Changed": []}
|
||||
for subject in log.splitlines():
|
||||
m = re.match(r"^(\w+)(\([\w./-]+\))?!?:\s*(.+)$", subject)
|
||||
if not m:
|
||||
continue
|
||||
ctype, _, desc = m.groups()
|
||||
ctype = ctype.lower()
|
||||
if ctype in SKIP_TYPES or ctype not in INCLUDE_TYPES:
|
||||
continue
|
||||
if INTERNAL_PATTERN.search(desc):
|
||||
continue
|
||||
buckets[INCLUDE_TYPES[ctype]].append(desc.strip())
|
||||
|
||||
if not any(buckets.values()):
|
||||
print("Nothing changelog-worthy in this push.")
|
||||
return
|
||||
|
||||
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
lines = ["## Pending -- {}\n".format(date)]
|
||||
for section in ("Added", "Fixed", "Changed"):
|
||||
if buckets[section]:
|
||||
lines.append("### {}".format(section))
|
||||
for item in buckets[section]:
|
||||
lines.append("- {}".format(item))
|
||||
lines.append("")
|
||||
entry = "\n".join(lines).rstrip() + "\n\n"
|
||||
|
||||
try:
|
||||
with open("CHANGELOG_PENDING.md", "r") as f:
|
||||
existing = f.read()
|
||||
if not existing.strip():
|
||||
existing = PENDING_HEADER # pre-existing but empty placeholder file
|
||||
except FileNotFoundError:
|
||||
existing = PENDING_HEADER
|
||||
|
||||
marker = "<!-- PENDING_ENTRIES -->\n"
|
||||
if marker in existing:
|
||||
head, tail = existing.split(marker, 1)
|
||||
updated = head + marker + entry + tail.lstrip("\n")
|
||||
else:
|
||||
updated = existing.rstrip("\n") + "\n\n" + entry
|
||||
|
||||
with open("CHANGELOG_PENDING.md", "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
print("Wrote entry:\n" + entry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
.github/workflows/changelog-draft.yml
vendored
Normal file
39
.github/workflows/changelog-draft.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: changelog-draft
|
||||
|
||||
# Drafts CHANGELOG_PENDING.md entries from Conventional Commit subjects on
|
||||
# every push to main. Deterministic -- regex-parses commit subjects only,
|
||||
# no LLM call, no network access. Buckets feat/fix/refactor/perf/security
|
||||
# into Added/Fixed/Changed; skips chore/ci/test/docs/style/merge commits and
|
||||
# anything mentioning internal infra hostnames/addresses. Never touches
|
||||
# CHANGELOG.md itself -- human review still required to fold an entry in.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
changelog-draft:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Draft pending changelog entry
|
||||
run: python3 .github/scripts/changelog-draft.py "${{ github.event.before }}" "${{ github.sha }}"
|
||||
|
||||
- name: Commit draft if changed
|
||||
run: |
|
||||
if ! git diff --quiet CHANGELOG_PENDING.md 2>/dev/null; then
|
||||
git config user.name "changelog-bot"
|
||||
git config user.email "changelog-bot@context-x.org"
|
||||
git add CHANGELOG_PENDING.md
|
||||
git commit -m "chore(changelog): draft pending entries [skip ci]"
|
||||
git push origin HEAD:main
|
||||
else
|
||||
echo "No changelog-worthy commits in this push."
|
||||
fi
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -21,3 +21,8 @@ storage-*/
|
||||
# Local credentials (never commit)
|
||||
.tip/.env
|
||||
run-fs-scraper-mac.sh.local
|
||||
|
||||
# Training / SFT datasets — bulk data + potential internal pricing/market intel, keep out of git
|
||||
*.jsonl
|
||||
training-data/runpod/**/*.jsonl
|
||||
training-data/*.jsonl
|
||||
|
||||
37
.security-scan-allowlist
Normal file
37
.security-scan-allowlist
Normal file
@ -0,0 +1,37 @@
|
||||
# transceiver-db pre-push scan allowlist. ERE, one pattern per line, matched against finding lines.
|
||||
# This repo is Gitea-internal and is never published publicly; entries below are confirmed false positives.
|
||||
# Risk note: a low-entropy secret sharing a line with an allowlisted token would be masked here.
|
||||
# High-entropy secrets are still caught by the gitleaks pass, which ignores this allowlist.
|
||||
# If this repo is ever made public, drop this file and run the internas sanitizer first.
|
||||
# --- vendor name that is legitimate market-intelligence dataset subject matter ---
|
||||
flexoptix
|
||||
# --- owner homelab infra referenced in runbooks, docs and deploy scripts ---
|
||||
82\.165\.222\.127
|
||||
192\.168\.178\.
|
||||
10\.10\.0\.
|
||||
\berik\b
|
||||
\bclaudi\b
|
||||
renefichtmueller
|
||||
fearghas
|
||||
/opt/[a-z]
|
||||
fichtmueller\.org
|
||||
context-x\.org
|
||||
# --- sibling project codenames referenced in the cross-project sync dev journal ---
|
||||
\bmagatama
|
||||
peercortex
|
||||
switchblade
|
||||
shieldx
|
||||
papercortex
|
||||
ctxevent
|
||||
ctxmeet
|
||||
nognet\.net
|
||||
\bpulso\b
|
||||
# --- env-resolved DB URIs and passwords, no literal secret present ---
|
||||
postgres(ql)?://\$\{
|
||||
\$\{DB_PASS\}
|
||||
process\.env\.PGPASSWORD
|
||||
# --- placeholder secret in example config ---
|
||||
CHANGE_ME
|
||||
# --- ML tokenizer special tokens, not credentials ---
|
||||
special_tokens
|
||||
pad_token
|
||||
@ -1,6 +1,12 @@
|
||||
# TIP Changelog
|
||||
|
||||
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
||||
{"d":"2026-07-17","t":"FIX","m":"Equivalence pipeline audit: found 2 more sources writing straight to auto_approved without the recent-price/standard_name discrimination catalog-reconcile.ts uses (spec-matcher.ts flat-0.85, no gate) vs one that already had it right (maintenance:find-equivalences enhanced mode implicitly requires standard_name to hit its 0.85 bar and pre-filters recent price). Fixed spec-matcher.ts to insert pending with re_research_due_at=NOW() instead of bypassing review; one-time reclassification of the 1,455 pre-existing spec-basis auto_approved rows (sql/140): 972 held up, 483 demoted (some permanent mismatch, some re-research in 21d for missing evidence)."}
|
||||
{"d":"2026-07-17","t":"FEAT","m":"Switch-side drift-guard (sql/141): closed the third direction of the physically-impossible-compat invariant (compatibility writes=126, transceiver speed changes=138, now switches.max_speed_gbps/ports_config changes=141). Currently a no-op live (nothing updates those columns today outside the one-time sql/125 backfill) -- preventive, ahead of the planned switch datasheet-enrichment automation. Verified with a synthetic in-transaction test (1250 rows correctly flagged, rolled back)."}
|
||||
{"d":"2026-07-12","t":"FIX","m":"Switch-DB compat_speed_guard drift gap closed (sql/138): the sql/126 trigger only validated compatibility writes, so a later transceivers.speed_gbps update (e.g. the daily enrich-modulation-fec.sh recovery) never re-checked existing compatible spec_match rows against the switch speed ceiling. Found live: 189 rows had drifted physically-impossible again. One-time remediation (189 rows -> incompatible, backed up to compat_speed_drift_backup_20260712) plus a new AFTER UPDATE OF speed_gbps trigger on transceivers closes the gap for both write directions. check_switch_quality() passes again."}
|
||||
{"d":"2026-07-11","t":"FEAT","m":"Modulation/FEC enrichment (sql/135): IMMUTABLE helpers derive modulation (NRZ/PAM4/DP-16QAM/DP-QPSK/Coherent), fec_type and inbuilt_fec from speed_gbps + form_factor + part_number/standard_name regex, incl. coherent detection (>=100G-floored ZR/DCO/OpenZR/QAM + CFP2-DCO/ACO via form_factor). Fill-only + idempotent via fn_tip_enrich_modulation_fec(); daily self-healing cron 03:30 (scripts/enrich-modulation-fec.sh). Modulation coverage >=100G rose from 3-8 percent to 46 percent (400G 59, 800G 56, 1600G 100); 4224 rows enriched. Rules built by a 4-lens optical-standards workflow and adversarially verified — the 10G-ZR mislabel, CFP2-DCO miss, AOC/DAC scope leak and bare-QSFP28 over-guess were all caught and fixed before apply. vendor_verified rows never overwritten; enriched rows tagged data_confidence=enriched_estimated + enriched_fields."}
|
||||
{"d":"2026-07-11","t":"FIX","m":"speed_gbps plausibility guard + cleanup (sql/136): ~22 percent of rows carried junk speed (2129 at <=0, 167 astronomical >1600 values, ~2400 sub-0.09 fragments) from cable/MTP-trunk part numbers parsed into speed. speed_gbps made nullable (unknown speed is honestly NULL); tip_speed_is_implausible() + BEFORE trigger trg_speed_plausibility sanitize junk to NULL on write and never abort ingestion; one-time cleanup nulled 4708 rows (id-keyed reversible snapshot in tip_speed_gbps_rollback). The 0.09..1600 gray band (~355 legit FC/SDI/CPRI/OTN line rates) is intentionally preserved. 87 percent of nulled rows are dac/aoc/accessory where speed is meaningless."}
|
||||
{"d":"2026-07-11","t":"FIX","m":"v_transceiver_speed_errors honest metric (sql/137): after 136 the view counted 4708 NULL-speed rows, but 4112 are dac/aoc/accessory where NULL is correct. It now flags only real optics (product_type not in dac/aoc/aec/accessory/switch/nic) with NULL/<=0/>1600 speed -> 596 genuine module gaps, down from 2134 mixed."}
|
||||
{"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."}
|
||||
@ -296,3 +302,49 @@ Types: FEAT · FIX · UI · DATA · AI · INFRA
|
||||
{"d":"2026-04-28","t":"FIX","m":"Product verification pipeline: image crawls now mark image_verified/image_verified_url, scraped product pages mark details_verified/details_source_url, maintenance reconcile backfills old product URLs/images/details, and --backfill-images exposes the existing image crawler via scraper CLI. Migration 102 reconciles existing data."}
|
||||
{"d":"2026-04-28","t":"FIX","m":"Blog Engine Hot Topics: diversified ranking with refresh shuffle/source caps/already-created-topic demotion, plus richer LLM context briefings passed into topic expansion and master-draft context via custom_title/additional_context."}
|
||||
{"d":"2026-04-29","t":"FEAT","m":"TIPLLM robot learning loop: verification robot controller writes status, TIPLLM plans, queue dry-runs/enqueues and crawler outcomes into the Gitea-backed TIP training pool; learning-pool build imports qa-pairs from TIP_TRAINING_REPO into the tip_llm lane. Removed hardcoded Gitea token fallback; existing git remotes or env tokens are used."}
|
||||
{"d": "2026-06-04", "t": "FIX", "m": "Blog fo-blog LLM reachable again: routed to Mac Studio Ollama over the Erik<->home WireGuard tunnel via new BLOG_OLLAMA_URL (search/embeddings stay Erik-local on OLLAMA_URL). Exposed Mac Ollama on 0.0.0.0 via LaunchAgent plist; adopted training candidate as fo-blog-v45. Verified model discovery + live inference over WG."}
|
||||
{"d": "2026-06-04", "t": "FEAT", "m": "Blog Ollama high-availability: dual SSH tunnels from Erik to Mac Studio loopback Ollama over independent transports (primary WireGuard localhost:11436, backup Cloudflare localhost:11435; both systemd, Restart=always, enabled on boot). Blog client auto-fails-over primary->backup (re-probed at startup + every 60s, self-heals to primary). Robust against the Mac training pipeline restarting Ollama or changing its bind."}
|
||||
{"d": "2026-06-04", "t": "FEAT", "m": "Hype-cycle market-signals endpoint (/api/hype-cycle/market-signals): data-driven per-technology market signal score (0-100) + drivers + buy/hold recommendation from the adoption model and real SKU/vendor/price data. Powers the dashboard Market Context cards (were empty)."}
|
||||
{"d": "2026-06-04", "t": "FIX", "m": "Vendor /market-share + /intelligence were shadowed by /:id (404) with latent SQL bugs (round(double,int); non-existent po.id). Routed past /:id via next() fallthrough; fixed SQL (numeric casts, COUNT(*)). Now 200 with real data."}
|
||||
{"d": "2026-06-04", "t": "FEAT", "m": "Vendor reliability endpoint (/api/vendors/reliability): per-vendor freshness/frequency/coverage sub-scores + composite reliability score from real data (211 vendors). Populates the reliability badges on vendor cards."}
|
||||
{"d": "2026-06-04", "t": "FIX", "m": "LLM-gym disk-watchdog keep-list now also protects the newest candidate per lane (not just adopted vN/-rN), fixing the churn that fully deleted fo-blog when the quality gate blocked adoption."}
|
||||
{"d":"2026-06-06","t":"FIX","m":"Supply-Squeeze Preis-Momentum: paarweiser per-SKU Vergleich statt Aggregat — eliminiert Katalog-Komposition-Bias (400G OSFP +151%→0%, Signale 21→8). Nur SKUs in beiden Perioden, Median der per-SKU-Deltas, Nicht-Transceiver/AOC/Switch-Filter."}
|
||||
{"d":"2026-06-06","t":"AI","m":"Research Robot: Handlungsempfehlungen + Action-Buttons. API klassifiziert fehlschlagende Jobs (auth/401, network, no-handler) → Empfehlung+Aktionen. POST /action: dispatch/pause/resume (pgboss, whitelist-validiert, reversibel). Dashboard rendert Empfehlungs-Karten mit Buttons. sync:flexoptix-catalog→critical auth, bietet Token-Hilfe+Pause."}
|
||||
{"d":"2026-06-06","t":"FEAT","m":"FS.COM Wettbewerber-Lagerbestand pro Technologie in Sales-Velocity-Tabelle (/api/stock/competitor-by-tech), Ampel-Färbung vs. Monatsbedarf."}
|
||||
{"d":"2026-06-06","t":"FEAT","m":"NADDOD-Scraper: per-Warehouse Stock-Parser (us/nl/sg/cn) + warehouse_global_qty Fallback → erscheint in Competitor-Stock-Vergleich."}
|
||||
{"d":"2026-06-06","t":"UI","m":"Preis-Chart-Modal beim Klick auf Signal-Karte (180d SVG, multi-vendor). fmtSpd() bereinigt Speed-Anzeige (1.00G→1G, 1600→1.6T). Flexoptix-Preis in Competitor-Vergleich."}
|
||||
{"d":"2026-06-06","t":"FIX","m":"sync:flexoptix-catalog 401 ROOT CAUSE: leerer FLEXOPTIX_API_TOKEN='' kurzschloss '?? null' (nur null/undefined), Script sendete leeren Bearer statt Username/Passwort-Login. Fix '|| null'. Credentials waren immer korrekt. Voller Sync 457 Produkte/345 Preis/36 Stock. Job completed, Robot-Empfehlung jetzt 'alle gesund'."}
|
||||
{"d":"2026-06-06","t":"DATA","m":"Voller Daten-Refresh: Hype-Cycle + ABC + Forecast + Reorder-Signale + Preis-Denorm neu gerechnet (alle completed). Hype-Phasen aktuell: 10G plateau, 800G peak, 1.6T innovation-trigger."}
|
||||
{"d":"2026-06-06","t":"FIX","m":"reorder_signals Daten-Bloat behoben: 4.49M Zeilen/1.19GB → 18.175/4.5MB (99.6% frei). Writer hängte bei jedem 4h-Lauf seit 09.04. neue Zeilen an, löschte nie (24h-TTL nur read-seitig gefiltert). 4.37M waren bereits abgelaufen. Fix: delete-before-insert pro Transceiver. VACUUM FULL. Backup reorder_signals_keep_bak_20260606. Verifiziert stabil bei 18.175."}
|
||||
{"d":"2026-06-06","t":"FIX","m":"Procurement-Pulse Buy-Signals-Karte rief 404-Endpoint /api/procurement/reorder auf → zeigte immer 0. Korrigiert auf /api/procurement/reorder-top (summary.buy_now=314)."}
|
||||
{"d":"2026-06-06","t":"INFRA","m":"System-Health-Audit: 24/24 API-Endpoints grün, keine Dauer-Failing-Jobs, Disk 35%/RAM 99Gi frei, tip-postgres healthy. reorder-signals war einzige Bloat-Quelle."}
|
||||
{"d":"2026-06-07","t":"INFRA","m":"Erik↔Gitea Reconciliation ABGESCHLOSSEN: 22 Erik-lokale Fixes + 28 Gitea-Feature-Commits gemergt (ort, 0 Konflikte automatisch). Einziger manueller Fix: doppelte /api/hype-cycle/market-signals Route dedupliziert (Erik-Handler behalten, matcht Dashboard). 3 Pakete 0 TS-Fehler, 11/11 Endpoints grün, market-signals liefert korrekte Shape. Push main b5925bc2..8c6df102, jetzt 0/0 sync. Safety: branch erik-pre-reconcile-20260607 + bundle (auch Fearghas) + bulk-price WIP in stash@{0}."}
|
||||
{"d":"2026-06-07","t":"DATA","m":"Reconciliation-Bonus: die 4 früher als 'kein Handler' deaktivierten Jobs (match:opn, match:spec, analyze:stock:velocity, enrich:flexoptix-details) wurden vom Merge selbst geheilt — Gitea-Branch lieferte Handler+Schedules, Daemon-Restart re-registrierte sie automatisch (korrekte Crons). Alle 4 test-gelaufen: completed, echte Daten (stock_velocity 262, +40 Equivalences, 100 Detail-Syncs). 284 Jobs geplant, 0 Failing. bulk-price: committe Version funktioniert, Varianten-WIP bleibt in stash@{0}."}
|
||||
{"d":"2026-06-11","t":"DATA","m":"Switch-Kompatibilität + Speed-Daten von der Quelle (kein Raten mehr). ROOT CAUSE: Flexoptix-Bulk-API hat KEIN Speed/Formfaktor-Feld -> Sync riet aus Produktnamen (FO-109010-CWDM -> 100000G). FIX: detail-enricher zieht jetzt form_factor aus Spec 'Form Factor' + speed aus 'Supported Protocols'/'Bandwidth' (autoritatives Datenblatt), überschreibt korrupte Bulk-Werte. Voll-Katalog re-enriched: 96.8% plausibel, 487 Teile, 0 Widersprüche bei Stichprobe."}
|
||||
{"d":"2026-06-11","t":"FIX","m":"getFlexoptixSuggestions: (1) physikalische Validierung per Port-Speed + Käfig-Mechanik aus ports_config (100G-QSFP28-Port -> nur <=100G QSFP-Module, nie 400G/QSFP-DD); (2) nur katalog-bestätigte Teile (fx_specifications IS NOT NULL). CX 10000-48Y6C: war 4 unmögliche Gruppen (400G/200G/128G/100T QSFP28) -> jetzt 630 Vorschläge, 0 unmöglich, 0 Phantom. Generalisiert: 400G-Switch zeigt bis 400G."}
|
||||
{"d":"2026-06-11","t":"DATA","m":"Befund: ~107 Teile waren fälschlich Vendor 'Flexoptix' zugeordnet (Bindestrich-SKUs FO-/FOT-, nicht im echten FX-Katalog der nur Punkt-SKUs nutzt). Aus 'BEI FLEXOPTIX BESTELLEN' gefiltert. Wettbewerber-Kataloge (ATGBICS/NADDOD) haben eigene Formfaktor-Parsing-Fehler -> separater Scraper-Fix nötig."}
|
||||
{"d":"2026-06-11","t":"DATA","m":"Flexoptix-Datenblatt-Audit: alle 805 katalog-bestätigten Teile programmatisch gegen gespeicherte fx_specifications (Datenblatt) geprüft. Nach Terabit-Parser-Fix + Re-Derive aus Specs: Form Factor 802/802 (100%), Speed 801/801 (100%), 0 Abweichungen. Jeder Wert kommt aus dem Datenblatt, nicht aus dem Namen."}
|
||||
{"d":"2026-06-11","t":"DATA","m":"Switch-Daten-Audit: 681 Switches, nur 267 (39%) mit Port-Config, 414 ohne (überwiegend Cisco-Bulk-Import). Vorhandene Port-Configs 99.3% plausibel, 248/267 mit Hersteller-URL, nur 35 mit datasheet_url. Demo-Switches (Aruba CX10000, Arista, Juniper) vollständig+korrekt. LÜCKE: autoritatives Port-Sourcing per Hersteller-Datenblatt für die 414 fehlt = separates Scraper-Projekt pro Vendor."}
|
||||
{"d":"2026-06-11","t":"FIX","m":"Switch-Kompatibilität = 'läuft wirklich': Matcher verlangt jetzt zusätzlich, dass Flexoptix den Transceiver für den Switch-VENDOR codieren kann (fx_compatibilities). Physisch-passend reicht nicht. CX 10000: 629 physisch -> 283 echt-Aruba-codierbar (283/283 verifiziert). Cisco 8818: 55 (alle Cisco-codierbar). Plus Chassis-Port-Format (max_per_slot_NNNG_FF) geparst. Jede Empfehlung jetzt: katalog-bestätigt + datenblatt-genau + passt physisch + codierbar = garantiert lauffähig."}
|
||||
{"d":"2026-07-04","t":"DATA","m":"product_type-Klassifikation (sql/119): neues Enum module|dac|aoc|aec|switch|nic|accessory, deterministisch per Trigger trg_transceivers_biu aus part_number/category/reach gesetzt (self-healing bei jedem Insert/Update, Preis-Updates unberührt). Backfill 41008 Zeilen: 36284 module, 2791 dac, 1835 aoc, 29 aec, 27 accessory, 26 switch, 16 nic. Kabel (DAC/AOC/AEC) und Switches/NICs (ConnectX/Quantum/N-Port) nicht mehr als Modul gezählt. BASE-T/RJ45-Kupfermodule vor Kabel-Demotion geschützt (Guard vor copper-Regel); AOC/DAC-Keyword schlägt Optik-Token-Guard (AOC ist optisch, trägt legitim nm/MMF/SR). Konsumenten filtern product_type='module'. Missed-cable=0 verifiziert. Per-Vendor 100G+: NADDOD 179 Module/423 Kabel/28 Switch-NIC, ATGBICS 1331/297, QSFPTEK 22/98, ProLabs 1690/818."}
|
||||
{"d":"2026-07-04","t":"DATA","m":"form_factor-Recovery (sql/120): korrigiert NUR beweisbar-falsche Zeilen (form_factors.max_speed < speed_gbps) mit explizitem Formfaktor-Token im Titel. 87 gefixt (u.a. 400G-Kabel als SFP+ getaggt -> QSFP-DD; QSFP-DD@800G -> QSFP-DD800; Q56DD -> QSFP-DD), 947 opake Zeilen (z.B. EOLS-1303-40-D, MGB-2GSR) unangetastet gelassen, nichts geraten. Gescraptes Original in neuer Spalte form_factor_raw bewahrt. Recovery auch im Ingest-Trigger (Neuzugänge self-healing)."}
|
||||
{"d":"2026-07-04","t":"DATA","m":"lead_time_days als NICHT ERFASST dokumentiert (sql/121, COMMENT auf price_observations/stock_observations/stock_snapshots): 0% befüllt über 1.378.984 + 85.406 + 0 Zeilen. Plumbing existiert (crawler-llm stock-schema erfasst es, upsertPriceObservation nimmt Param), aber aktiver Scraper-Pfad liefert nie einen Wert und LLM-Crawler-Tabelle stock_snapshots ist leer. Kein Default fabrizieren; NULL = unbekannt. Befüllen ist Scraper-Aufgabe."}
|
||||
{"d":"2026-07-04","t":"DATA","m":"Befund FS.COM-Untererfassung bestätigt (kein Fix, Scraper-Thema): 392 Zeilen/378 distinct SKUs vs ATGBICS 8420 (22x-Lücke). 391/392 sind bereits product_type='module' -> Klassifikation ist NICHT die Ursache, reine Crawl-Tiefe. Ursache in fs-com.ts + Seed-Scope (Kategorie-Abdeckung) + evtl. zu aggressive Quarantäne von fs.com/c/-URLs. Empfehlung: FS.com-Crawl-Seed erweitern (Scraper-Pipeline, nicht raten)."}
|
||||
{"d":"2026-07-07","t":"SECURITY","m":"Hardcodierte DB-Passwoerter aus dem Source entfernt und auf env umgestellt. tip-DB-Passwort (transceiver_db) lag in 14 Dateien im Klartext (quoted export, unquoted psql-Aufrufe, Python-env-Dicts, env-or-hardcoded-default-Fallbacks); llm_gateway-Passwort in seed-blog-training-data.py. Ersetzt durch PGPASSWORD-Env-Referenz mit Fail-Fast-Guard bzw. os.environ. training-data/*.jsonl (48 MB) aus Tracking genommen (git rm --cached, Datei bleibt) und gitignored. .security-scan-allowlist ergaenzt fuer legitime False-Positives (Vendor flexoptix, eigene Infra, sync-Journal-Codenamen, env-aufgeloeste URIs). Pre-Push-Leak-Scanner 397 auf 0 Funde, gruen ohne --no-verify. Offen: Rotation der geleakten Werte (llm_gateway zuerst, tip fleet-weit koordiniert); Git-History behaelt Altwerte."}
|
||||
{"d":"2026-07-10","t":"DATA","m":"cisco-eol-sync: 3 Bugs gefixt + erster Lauf. NCS-5000-Regex (passte nicht auf NCS-5001/5002/5011), FX/FX3-Boundary-Guard (verhindert falsche Matches der neueren Generation), source-aware Date-Routing (Format2-Announcement-Dates -> eos_date, nicht last_support_date). 13 Switches auf EoS_Announced gesetzt. Wöchentlicher Sync Sun 04:00 via pg-boss."}
|
||||
{"d":"2026-07-10","t":"DATA","m":"cisco-eol-sync Bug 3 fix: source-aware Date-Routing (Format2-Announcement-Dates → eos_date, nicht last_support_date)"}
|
||||
{"d":"2026-07-10","t":"DATA","m":"Run cisco-eol-sync for real to update 13 switches in DB (NCS-5001/5002/5011, NCS-5502/SE, NCS-55A1, N9K-C92300YC/C9272Q/C93120TX/C93180YC-FX/C9332C, ASR-9001/9901)"}
|
||||
{"d":"2026-07-11","t":"DATA","m":"Transceiver classification fields: 4 new columns added to transceivers table (dwdm_channel, cwdm_channel, cdr_type, protocol_family). Backfill from Flexoptix notes field: 1,209 standard_names enriched (up from 72, +1,579%), 1,177 cdr_types (all dual_cdr), 2,498 wdm_types (1,896 DWDM + 602 CWDM), 7,918 protocol_family values (7,645 ethernet + 273 fibre_channel). Migration 134 applied. Note: DWDM and CWDM are separate columns — never interchangeable; fibre_channel is a separate protocol family from ethernet."}
|
||||
{"d":"2026-07-11","t":"FIX","m":"flexoptix-api-sync.ts: API sync now writes standard_name (from protocol field), wdm_type (DWDM/CWDM from optics.dwdm/cwdm), cdr_type (dual_cdr/cdr from title regex), cdr_support (true when cdr_type set), and protocol_family (ethernet/fibre_channel/sonet/infiniband from title regex) on every Flexoptix catalog sync. Previously these columns were never populated from the API — only dwdm/cwdm booleans existed. This fixes the root cause of empty standard_name on 95.5% of auto_approved equivalences."}
|
||||
{"d":"2026-07-11","t":"FIX","m":"catalog-reconcile.ts: Hard rejects for incompatible entity types: protocol_family mismatch (ethernet≠fibre_channel), temp_range mismatch (COM≠IND), wdm_type mismatch (DWDM≠CWDM) → confidence=0, skip immediately. Standard_name mismatch now -25pts penalty (was no penalty). CDR type mismatch: -20pts + CDR match bonus +5pts. Auto-approve gate now requires fx.standard_name IS NOT NULL, blocking the 77,419/81,116 cases where FX had no standard_name. Confidence scoring schema extended with hardReject flag. Candidates and fxProducts queries now include cdr_type, temp_range, protocol_family, wdm_type."}
|
||||
|
||||
## Pending -- 2026-07-18
|
||||
|
||||
### Fixed
|
||||
- correct 14 false curated 10G-on-1G compatibility claims
|
||||
|
||||
## Pending -- 2026-07-18
|
||||
|
||||
### Fixed
|
||||
- clean 261 spec_match rows unmasked by the 144 curated-row fix
|
||||
|
||||
|
||||
@ -318,32 +318,86 @@ export async function getCompatibleTransceivers(switchId: string) {
|
||||
*/
|
||||
export async function getFlexoptixSuggestions(switchId: string) {
|
||||
const result = await pool.query(
|
||||
`WITH switch_form_factors AS (
|
||||
`WITH sw_vendor AS (
|
||||
-- Map the switch's vendor to the Flexoptix coding-matrix token. A transceiver
|
||||
-- only truly runs in the switch if Flexoptix can code it for this platform.
|
||||
SELECT
|
||||
CASE
|
||||
WHEN v.name ILIKE '%aruba%' OR v.name ILIKE '%hpe%' OR v.name ILIKE '%hewlett%' OR v.name ILIKE '%hp h3c%' OR v.name ILIKE '%hp %' THEN 'aruba'
|
||||
WHEN v.name ILIKE '%cisco%' THEN 'cisco'
|
||||
WHEN v.name ILIKE '%arista%' THEN 'arista'
|
||||
WHEN v.name ILIKE '%juniper%' THEN 'juniper'
|
||||
WHEN v.name ILIKE '%dell%' OR v.name ILIKE '%force10%' THEN 'dell'
|
||||
WHEN v.name ILIKE '%huawei%' THEN 'huawei'
|
||||
WHEN v.name ILIKE '%fortinet%' THEN 'fortinet'
|
||||
WHEN v.name ILIKE '%mikrotik%' THEN 'mikrotik'
|
||||
WHEN v.name ILIKE '%nvidia%' OR v.name ILIKE '%mellanox%' THEN 'mellanox'
|
||||
WHEN v.name ILIKE '%extreme%' OR v.name ILIKE '%enterasys%' THEN 'extreme'
|
||||
WHEN v.name ILIKE '%nokia%' OR v.name ILIKE '%alcatel%' THEN 'nokia'
|
||||
WHEN v.name ILIKE '%brocade%' OR v.name ILIKE '%ruckus%' THEN 'brocade'
|
||||
WHEN v.name ILIKE '%netgear%' THEN 'netgear'
|
||||
WHEN v.name ILIKE '%zyxel%' THEN 'zyxel'
|
||||
WHEN v.name ILIKE '%ubiquiti%' THEN 'ubiquiti'
|
||||
WHEN v.name ILIKE '%d-link%' OR v.name ILIKE '%dlink%' THEN 'd-link'
|
||||
WHEN v.name ILIKE '%allied telesis%' THEN 'allied telesis'
|
||||
WHEN v.name ILIKE '%adtran%' THEN 'adtran'
|
||||
WHEN v.name ILIKE '%ciena%' THEN 'ciena'
|
||||
ELSE NULL -- unmapped (whitebox e.g. Edgecore) -> MSA-standard fallback
|
||||
END AS vpat
|
||||
FROM switches s JOIN vendors v ON v.id = s.vendor_id WHERE s.id = $1
|
||||
),
|
||||
gate AS (
|
||||
-- Hard gate: a switch only presents compatibility if its port config has
|
||||
-- been verified against the manufacturer datasheet/source. Unverified =
|
||||
-- no claim (return nothing) rather than show potentially-wrong data.
|
||||
SELECT ports_verified FROM switches WHERE id = $1
|
||||
),
|
||||
switch_ports AS (
|
||||
-- Parse each ports_config key 'SPEED_FORMFACTOR' (e.g. '100G_QSFP28') into
|
||||
-- a numeric port speed (Gbps) + a cage family. Real transceivers are then
|
||||
-- bounded by the port speed so corrupt/oversized records cannot leak in.
|
||||
SELECT DISTINCT
|
||||
-- numeric speed prefix: 100G->100, 25G->25, 2.5G->2.5, 100M->0.1, 800G->800
|
||||
-- speed token may be at the start ('100G_QSFP28') or embedded
|
||||
-- ('max_per_slot_100G_QSFP28' on chassis switches). Match before the cage.
|
||||
CASE
|
||||
WHEN k ~ '[0-9.]+M[_A-Za-z]' THEN (substring(k from '([0-9.]+)M[_A-Za-z]')::numeric / 1000)
|
||||
WHEN k ~ '[0-9.]+T[_A-Za-z]' THEN (substring(k from '([0-9.]+)T[_A-Za-z]')::numeric * 1000)
|
||||
WHEN k ~ '[0-9.]+G[_A-Za-z]' THEN substring(k from '([0-9.]+)G[_A-Za-z]')::numeric
|
||||
WHEN k ~ '[0-9.]+G$' THEN substring(k from '([0-9.]+)G$')::numeric
|
||||
ELSE NULL
|
||||
END AS port_speed,
|
||||
-- the port's specific cage type (determines which modules mechanically seat)
|
||||
CASE
|
||||
WHEN k ILIKE '%QSFP-DD800%' THEN 'QSFP-DD800'
|
||||
WHEN k ILIKE '%QSFP-DD%' THEN 'QSFP-DD'
|
||||
WHEN k ILIKE '%OSFP224%' THEN 'OSFP224'
|
||||
WHEN k ILIKE '%OSFP%' THEN 'OSFP'
|
||||
WHEN k ILIKE '%QSFP112%' THEN 'QSFP112'
|
||||
WHEN k ILIKE '%QSFP56%' THEN 'QSFP56'
|
||||
WHEN k ILIKE '%QSFP28%' THEN 'QSFP28'
|
||||
WHEN k ILIKE '%QSFP+%' THEN 'QSFP+'
|
||||
WHEN k ILIKE '%QSFP%' THEN 'QSFP+'
|
||||
WHEN k ILIKE '%OSFP224%' THEN 'OSFP224'
|
||||
WHEN k ILIKE '%OSFP112%' THEN 'OSFP112'
|
||||
WHEN k ILIKE '%OSFP%' THEN 'OSFP'
|
||||
WHEN k ILIKE '%SFP56%' THEN 'SFP56'
|
||||
WHEN k ILIKE '%SFP28%' THEN 'SFP28'
|
||||
WHEN k ILIKE '%SFP+%' THEN 'SFP+'
|
||||
WHEN k ILIKE '%SFP%' THEN 'SFP+'
|
||||
WHEN k ILIKE '%CFP2%' THEN 'CFP2'
|
||||
WHEN k ILIKE '%CFP4%' THEN 'CFP4'
|
||||
WHEN k ILIKE '%CFP%' THEN 'CFP'
|
||||
END AS form_factor
|
||||
WHEN k ILIKE '%RJ45%' OR k ILIKE '%mGig%' THEN 'RJ45'
|
||||
ELSE NULL
|
||||
END AS port_ff
|
||||
FROM switches sw,
|
||||
jsonb_object_keys(sw.ports_config) AS k
|
||||
WHERE sw.id = $1 AND sw.ports_config IS NOT NULL
|
||||
)
|
||||
SELECT t.id, t.slug, t.part_number, t.standard_name, t.form_factor,
|
||||
t.speed, t.speed_gbps, t.reach_meters, t.reach_label,
|
||||
t.fiber_type, t.wavelength_nm, t.market_status,
|
||||
t.fiber_type, t.wavelength_tx_nm AS wavelength_nm, t.market_status,
|
||||
t.product_page_url, t.image_url,
|
||||
t.price_verified_eur, t.price_verified_at, t.price_verified_usd,
|
||||
t.price_verified_eur, t.price_verified_at, t.street_price_usd AS price_verified_usd,
|
||||
v.name AS vendor_name, v.website AS vendor_website,
|
||||
COALESCE(t.price_verified_eur,
|
||||
(SELECT po.price FROM price_observations po
|
||||
@ -352,12 +406,65 @@ export async function getFlexoptixSuggestions(switchId: string) {
|
||||
CASE WHEN t.price_verified_eur IS NOT NULL THEN 'EUR'
|
||||
ELSE (SELECT po.currency FROM price_observations po
|
||||
WHERE po.transceiver_id = t.id ORDER BY po.time DESC LIMIT 1)
|
||||
END AS latest_currency
|
||||
END AS latest_currency,
|
||||
so.warehouse_de_qty,
|
||||
so.warehouse_global_qty,
|
||||
so.backorder_qty,
|
||||
so.backorder_estimated_date
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON t.vendor_id = v.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT warehouse_de_qty, warehouse_global_qty, backorder_qty, backorder_estimated_date
|
||||
FROM stock_observations
|
||||
WHERE transceiver_id = t.id
|
||||
ORDER BY time DESC
|
||||
LIMIT 1
|
||||
) so ON true
|
||||
WHERE LOWER(v.name) = 'flexoptix'
|
||||
AND t.form_factor IN (
|
||||
SELECT form_factor FROM switch_form_factors WHERE form_factor IS NOT NULL
|
||||
AND (SELECT ports_verified FROM gate) IS TRUE
|
||||
-- Only parts confirmed in Flexoptix's live API catalog (have authoritative
|
||||
-- datasheet specs). Excludes phantom/misattributed parts not actually
|
||||
-- orderable from Flexoptix, and parts with no source-of-truth specs.
|
||||
AND t.fx_specifications IS NOT NULL
|
||||
AND t.speed_gbps IS NOT NULL AND t.speed_gbps > 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM switch_ports sp
|
||||
WHERE sp.port_ff IS NOT NULL
|
||||
AND sp.port_speed IS NOT NULL
|
||||
-- module must mechanically seat in this port cage. A cage accepts its own
|
||||
-- type plus smaller/backward-compatible modules, never a larger one:
|
||||
-- QSFP-DD/OSFP cages take QSFP-family; QSFP28/56 cages take QSFP+/28/56
|
||||
-- (NOT QSFP-DD); SFP cages take all SFP variants. No cross-family.
|
||||
AND t.form_factor = ANY (
|
||||
CASE sp.port_ff
|
||||
WHEN 'QSFP-DD800' THEN ARRAY['QSFP-DD800','QSFP-DD','QSFP112','QSFP56','QSFP28','QSFP+']
|
||||
WHEN 'QSFP-DD' THEN ARRAY['QSFP-DD','QSFP112','QSFP56','QSFP28','QSFP+']
|
||||
WHEN 'QSFP112' THEN ARRAY['QSFP112','QSFP56','QSFP28','QSFP+']
|
||||
WHEN 'QSFP56' THEN ARRAY['QSFP56','QSFP28','QSFP+']
|
||||
WHEN 'QSFP28' THEN ARRAY['QSFP28','QSFP+']
|
||||
WHEN 'QSFP+' THEN ARRAY['QSFP+']
|
||||
WHEN 'OSFP224' THEN ARRAY['OSFP224','OSFP112','OSFP']
|
||||
WHEN 'OSFP112' THEN ARRAY['OSFP112','OSFP']
|
||||
WHEN 'OSFP' THEN ARRAY['OSFP']
|
||||
WHEN 'SFP56' THEN ARRAY['SFP56','SFP28','SFP+','SFP']
|
||||
WHEN 'SFP28' THEN ARRAY['SFP28','SFP+','SFP']
|
||||
WHEN 'SFP+' THEN ARRAY['SFP+','SFP']
|
||||
WHEN 'CFP2' THEN ARRAY['CFP2']
|
||||
WHEN 'CFP4' THEN ARRAY['CFP4']
|
||||
WHEN 'CFP' THEN ARRAY['CFP']
|
||||
WHEN 'RJ45' THEN ARRAY['RJ45','Copper']
|
||||
ELSE ARRAY[]::text[]
|
||||
END
|
||||
)
|
||||
-- speed must not exceed the port's speed (slower module in faster cage OK)
|
||||
AND t.speed_gbps <= sp.port_speed
|
||||
)
|
||||
-- AND Flexoptix must be able to code it for THIS switch's platform, so it
|
||||
-- actually runs (not just physically fits). Whitebox/unmapped -> MSA standard.
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(t.fx_compatibilities) c, sw_vendor
|
||||
WHERE (sw_vendor.vpat IS NOT NULL AND c->>'compatible_to_vendor' ILIKE '%' || sw_vendor.vpat || '%')
|
||||
OR (sw_vendor.vpat IS NULL AND c->>'compatible_to_vendor' ILIKE '%MSA Standard%')
|
||||
)
|
||||
ORDER BY t.speed_gbps DESC NULLS LAST, t.reach_meters ASC NULLS LAST`,
|
||||
[switchId]
|
||||
|
||||
@ -28,6 +28,7 @@ import { procurementRouter } from "./routes/procurement";
|
||||
import { changelogRouter } from "./routes/changelog";
|
||||
import { newsRouter } from "./routes/news";
|
||||
import { proxyRouter } from "./routes/proxy";
|
||||
import { researchRobotRouter } from "./routes/research-robot";
|
||||
import { reviewRouter } from "./routes/review";
|
||||
import { stockRouter } from "./routes/stock";
|
||||
import { priceComparisonRouter } from "./routes/price-comparison";
|
||||
@ -37,6 +38,7 @@ import { formFactorsRouter } from "./routes/form-factors";
|
||||
import { tipLlmRouter } from "./routes/tip-llm";
|
||||
import { equivalencesRouter } from "./routes/equivalences";
|
||||
import { priceHistoryRouter } from "./routes/price-history";
|
||||
import { stockCompetitorRouter } from "./routes/stock-competitor";
|
||||
import { kbRouter } from "./routes/kb";
|
||||
import { bulkPriceRouter } from "./routes/bulk-price";
|
||||
import { vendorReliabilityRouter } from "./routes/vendor-reliability";
|
||||
@ -72,6 +74,7 @@ app.use("/api/auth", authRouter);
|
||||
|
||||
// Proxy public endpoints (register + heartbeat + stats + next — no auth)
|
||||
app.use("/api/proxy", proxyRouter);
|
||||
app.use("/api/research-robot", researchRobotRouter);
|
||||
|
||||
// All other API routes require a valid token
|
||||
app.use("/api", (req, res, next) => {
|
||||
@ -120,6 +123,7 @@ app.use("/api/tip-llm", tipLlmRouter);
|
||||
app.use("/api/equivalences", equivalencesRouter);
|
||||
// Price history charts
|
||||
app.use("/api/price-history", priceHistoryRouter);
|
||||
app.use("/api/stock", stockCompetitorRouter);
|
||||
app.use("/api/kb", kbRouter);
|
||||
// Bulk price lookup (G)
|
||||
app.use("/api/bulk-price", bulkPriceRouter);
|
||||
|
||||
@ -14,7 +14,27 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
// -- Ollama endpoint with automatic primary->fallback failover --
|
||||
// Primary = BLOG_OLLAMA_URL (WireGuard tunnel to Mac Studio loopback Ollama),
|
||||
// fallback = BLOG_OLLAMA_URL_FALLBACK (Cloudflare tunnel). Independent transports;
|
||||
// if the primary transport drops, requests move to the backup. Re-probed every 60s.
|
||||
const OLLAMA_PRIMARY = process.env.BLOG_OLLAMA_URL || process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
const OLLAMA_FALLBACK = process.env.BLOG_OLLAMA_URL_FALLBACK || "";
|
||||
let OLLAMA_URL = OLLAMA_PRIMARY;
|
||||
async function pickOllamaUrl(): Promise<void> {
|
||||
for (const u of [OLLAMA_PRIMARY, OLLAMA_FALLBACK].filter(Boolean)) {
|
||||
try {
|
||||
const r = await fetch(`${u}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
||||
if (r.ok) {
|
||||
if (OLLAMA_URL !== u) console.log(`[blog-llm] ollama endpoint -> ${u}`);
|
||||
OLLAMA_URL = u;
|
||||
return;
|
||||
}
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
}
|
||||
void pickOllamaUrl();
|
||||
setInterval(() => { void pickOllamaUrl(); }, 60000).unref();
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
|
||||
const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514";
|
||||
const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:3250";
|
||||
|
||||
@ -2217,7 +2217,7 @@ blogRouter.post("/llm/reset-queue", (_req: Request, res: Response) => {
|
||||
blogRouter.get("/llm/model-info", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = getLlmProvider();
|
||||
const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
const ollamaUrl = process.env.BLOG_OLLAMA_URL || process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
|
||||
// Only meaningful for fo-blog Ollama models
|
||||
const modelName = settings.ollamaModel || "";
|
||||
|
||||
@ -50,6 +50,7 @@ hotTopicsRouter.get("/", async (req, res) => {
|
||||
JOIN vendors v ON pc.vendor_id = v.id
|
||||
JOIN transceivers t ON pc.transceiver_id = t.id
|
||||
WHERE pc.delta_pct < -10 AND pc.detected_at > NOW() - INTERVAL '14 days'
|
||||
AND v.name != 'Flexoptix'
|
||||
ORDER BY pc.delta_pct ASC LIMIT 5
|
||||
`).catch(() => ({ rows: [] }));
|
||||
|
||||
@ -172,7 +173,8 @@ hotTopicsRouter.get("/", async (req, res) => {
|
||||
|
||||
// ═══ SOURCE 3c: NOG Conference Talks — scraped from NOG agendas ═══
|
||||
const nogTalks = await pool.query(`
|
||||
SELECT title, source, source_url, published_at, relevance_score
|
||||
SELECT title, source, source_url, published_at, relevance_score,
|
||||
summary, mentioned_vendors, mentioned_products, mentioned_standards
|
||||
FROM news_articles
|
||||
WHERE source LIKE 'NOG Talks:%'
|
||||
AND relevance_score > 0.4
|
||||
@ -191,11 +193,17 @@ hotTopicsRouter.get("/", async (req, res) => {
|
||||
}
|
||||
for (const [event, talks] of Object.entries(nogByEvent)) {
|
||||
const topTalk = (talks as NogRow[])[0];
|
||||
const talkBullets = (talks as NogRow[]).slice(0, 5).map(t => {
|
||||
const vendors = Array.isArray(t.mentioned_vendors) ? (t.mentioned_vendors as string[]).slice(0, 3).join(", ") : "";
|
||||
const products = Array.isArray(t.mentioned_products) ? (t.mentioned_products as string[]).slice(0, 3).join(", ") : "";
|
||||
const extra = [vendors, products].filter(Boolean).join(" / ");
|
||||
return `• ${t.title}${extra ? ` (${extra})` : ""}${t.summary ? ` — ${String(t.summary).slice(0, 120)}` : ""}`;
|
||||
}).join("\n");
|
||||
topics.push({
|
||||
title: talks.length === 1
|
||||
? `[${event}] ${topTalk.title}`
|
||||
: `${event}: ${talks.length} optics-relevant talks`,
|
||||
description: (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "),
|
||||
description: talkBullets || (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "),
|
||||
blog_type: "technology_deep_dive",
|
||||
urgency: "hot",
|
||||
source: event,
|
||||
@ -209,7 +217,8 @@ hotTopicsRouter.get("/", async (req, res) => {
|
||||
// ═══ SOURCE 4: News Articles — Recent Industry News ═══
|
||||
const recentNews = await pool.query(`
|
||||
SELECT title, source, source_url, category, published_at,
|
||||
COALESCE(relevance_score, 5) AS relevance
|
||||
COALESCE(relevance_score, 5) AS relevance,
|
||||
summary, mentioned_vendors, mentioned_products, mentioned_standards, tags
|
||||
FROM news_articles
|
||||
WHERE source NOT LIKE 'NOG Talks:%'
|
||||
AND published_at > NOW() - INTERVAL '14 days'
|
||||
@ -228,14 +237,22 @@ hotTopicsRouter.get("/", async (req, res) => {
|
||||
|
||||
for (const [theme, articles] of Object.entries(newsThemes)) {
|
||||
if (articles.length >= 1) {
|
||||
// Build rich description with article summaries, vendors, standards mentioned
|
||||
const articleBullets = (articles as NewsRow[]).slice(0, 5).map(a => {
|
||||
const vendors = Array.isArray(a.mentioned_vendors) ? (a.mentioned_vendors as string[]).slice(0, 3).join(", ") : "";
|
||||
const stds = Array.isArray(a.mentioned_standards) ? (a.mentioned_standards as string[]).slice(0, 2).join(", ") : "";
|
||||
const meta = [vendors, stds].filter(Boolean).join(" / ");
|
||||
return `• ${a.title}${meta ? ` [${meta}]` : ""}${a.summary ? ` — ${String(a.summary).slice(0, 150)}` : ""}`;
|
||||
}).join("\n");
|
||||
|
||||
topics.push({
|
||||
title: `${theme}: ${articles.length} recent articles`,
|
||||
description: articles.map(a => a.title).slice(0, 3).join(" | "),
|
||||
description: articleBullets || articles.map(a => a.title).slice(0, 3).join(" | "),
|
||||
blog_type: "technology_deep_dive",
|
||||
urgency: "trending",
|
||||
source: articles.map(a => a.source).filter(Boolean).slice(0, 2).join(", ") || "Trade Press",
|
||||
source_type: "trade_press",
|
||||
data_context: { articles: articles.slice(0, 3) },
|
||||
data_context: { articles: articles.slice(0, 5) },
|
||||
suggested_angle: `${theme}: What the latest announcements actually mean for network operators`,
|
||||
date: articles[0]?.published_at ? new Date(articles[0].published_at).toISOString() : undefined,
|
||||
});
|
||||
@ -407,22 +424,55 @@ function compactDataContext(data: Record<string, unknown> | undefined): string {
|
||||
|
||||
function buildTopicBriefing(topic: HotTopic): string {
|
||||
const lines = [
|
||||
`Topic: ${topic.title}`,
|
||||
`Urgency: ${topic.urgency}`,
|
||||
`Source: ${topic.source_type} / ${topic.source}`,
|
||||
`=== BLOG BRIEFING: ${topic.title} ===`,
|
||||
``,
|
||||
`Urgency: ${topic.urgency.toUpperCase()}`,
|
||||
`Source category: ${topic.source_type} | Source: ${topic.source}`,
|
||||
];
|
||||
|
||||
if (topic.date) lines.push(`Signal date: ${topic.date}`);
|
||||
if (topic.description) lines.push(`Signal summary: ${topic.description}`);
|
||||
if (topic.suggested_angle) lines.push(`Recommended angle: ${topic.suggested_angle}`);
|
||||
if (topic.blog_title_created && topic.last_blog_created_at) {
|
||||
lines.push(`Editorial note: A blog with a very similar title already exists from ${topic.last_blog_created_at}. If used anyway, choose a materially different angle.`);
|
||||
if (topic.date) lines.push(`Signal date: ${new Date(topic.date).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}`);
|
||||
|
||||
// Core signal content — article bullets or summary
|
||||
if (topic.description) {
|
||||
lines.push(``, `--- Market Signals ---`);
|
||||
// Already formatted as bullets for news/nog, or plain summary for market intel
|
||||
lines.push(topic.description.includes("•") ? topic.description : `Signal summary: ${topic.description}`);
|
||||
}
|
||||
|
||||
const dataContext = compactDataContext(topic.data_context);
|
||||
if (dataContext) lines.push(`Structured supporting data:\n${dataContext}`);
|
||||
// Recommended editorial angle
|
||||
if (topic.suggested_angle) {
|
||||
lines.push(``, `--- Recommended Blog Angle ---`);
|
||||
lines.push(topic.suggested_angle);
|
||||
}
|
||||
|
||||
// Structured data from data_context (vendors, tech, buy signal, etc.)
|
||||
const ctx = topic.data_context;
|
||||
if (ctx) {
|
||||
const extraLines: string[] = [];
|
||||
if (ctx.buy_signal && typeof ctx.buy_signal === "string") {
|
||||
const signalMap: Record<string, string> = { bullish: "BUY signal — demand growing, order soon", bearish: "WAIT signal — pricing softening or supply improving", opportunity: "SHORT-TERM OPPORTUNITY — act now", neutral: "Monitor — no immediate action needed" };
|
||||
extraLines.push(`Buy signal: ${signalMap[ctx.buy_signal] ?? ctx.buy_signal}`);
|
||||
}
|
||||
if (ctx.technologies && String(ctx.technologies).length > 2) extraLines.push(`Key technologies: ${ctx.technologies}`);
|
||||
if (ctx.impact_months) extraLines.push(`Expected market impact: within ${ctx.impact_months} months`);
|
||||
if (extraLines.length > 0) {
|
||||
lines.push(``, `--- Market Context ---`);
|
||||
lines.push(...extraLines);
|
||||
}
|
||||
}
|
||||
|
||||
if (topic.blog_title_created && topic.last_blog_created_at) {
|
||||
lines.push(``, `⚠ Editorial note: A similar blog already exists (created ${new Date(topic.last_blog_created_at).toLocaleDateString("de-DE")}). Choose a materially different angle — different structure, timeframe, or use-case focus.`);
|
||||
}
|
||||
|
||||
lines.push(``, `--- Writing Instructions ---`);
|
||||
lines.push(`Write a practical optical networking article (600–900 words) that a network engineer or procurement manager at an ISP, cloud provider, or enterprise can immediately use. Include:`);
|
||||
lines.push(`1. What is actually happening in the market (fact-based, no generic intro)`);
|
||||
lines.push(`2. Specific technical implications (form factors, speeds, reach, protocol implications)`);
|
||||
lines.push(`3. Procurement/planning consequences — what to order, what to delay, what to watch`);
|
||||
lines.push(`4. One concrete recommendation or action item`);
|
||||
lines.push(`Do NOT write generic summaries or restate the title. Be opinionated and specific.`);
|
||||
|
||||
lines.push("Editorial instruction: turn this into a practical optical networking article with procurement/engineering consequences, not a generic news summary.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@ -165,6 +165,56 @@ hypeCycleRouter.get("/regional/:tech", (req: Request, res: Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── MARKET SIGNAL COMPUTATION ───────────────────────────────────────────────
|
||||
/** Technology → form-factor/speed mapping for cross-table signal aggregation */
|
||||
const TECH_SIGNAL_MAP = [
|
||||
{ label: "10G-SFP+", speedGbps: 10, formFactors: ["SFP+"], speedLabel: "10G" },
|
||||
{ label: "100G-QSFP28", speedGbps: 100, formFactors: ["QSFP28"], speedLabel: "100G" },
|
||||
{ label: "400G-QSFP-DD", speedGbps: 400, formFactors: ["QSFP-DD"], speedLabel: "400G" },
|
||||
{ label: "800G-OSFP", speedGbps: 800, formFactors: ["OSFP"], speedLabel: "800G" },
|
||||
{ label: "1.6T-OSFP", speedGbps: 1600, formFactors: ["OSFP"], speedLabel: "1.6T" },
|
||||
{ label: "400G-ZR", speedGbps: 400, formFactors: ["SFP-DD", "CFP2"], speedLabel: "400G" },
|
||||
] as const;
|
||||
|
||||
type PhaseKey =
|
||||
| "plateau_productivity" | "slope_enlightenment" | "trough_disillusionment"
|
||||
| "peak_inflated_expectations" | "innovation_trigger";
|
||||
|
||||
function buildRecommendation(
|
||||
phase: PhaseKey,
|
||||
signalScore: number,
|
||||
capexYoyAvg: number,
|
||||
speedGbps: number,
|
||||
): { label: string; color: string; detail: string } {
|
||||
const fast = speedGbps >= 400;
|
||||
const capexBoom = capexYoyAvg > 50;
|
||||
|
||||
switch (phase) {
|
||||
case "plateau_productivity":
|
||||
if (fast && capexBoom)
|
||||
return { label: "🚀 Buy — AI Wave", color: "#16a34a", detail: "Commodity pricing + AI infrastructure demand surge. Stock up now." };
|
||||
if (signalScore >= 85)
|
||||
return { label: "✅ Hold — Stable", color: "#2563eb", detail: "Mature commodity market, stable long-term demand." };
|
||||
return { label: "📦 Hold", color: "#64748b", detail: "Commodity market. Price-driven. Order on demand." };
|
||||
case "slope_enlightenment":
|
||||
if (capexBoom)
|
||||
return { label: "🟢 Buy Now", color: "#16a34a", detail: "Growing mainstream adoption + hyperscaler capex boom. Window closing." };
|
||||
return { label: "🟡 Buy", color: "#ca8a04", detail: "Adoption curve steepening. Monitor pricing before large orders." };
|
||||
case "trough_disillusionment":
|
||||
if (signalScore > 50)
|
||||
return { label: "🔍 Buy Opportunity", color: "#7c3aed", detail: "Hype trough but demand signals emerging. Strategic buying window." };
|
||||
return { label: "⏳ Watch", color: "#94a3b8", detail: "Wait for demand confirmation before stocking." };
|
||||
case "peak_inflated_expectations":
|
||||
if (fast && capexBoom)
|
||||
return { label: "⚡ Caution / Buy", color: "#f97316", detail: "Hype peak but real hyperscaler demand. Buy selectively, not speculatively." };
|
||||
return { label: "⚠ Caution", color: "#ef4444", detail: "Peak hype. Verify real end-customer demand before building inventory." };
|
||||
case "innovation_trigger":
|
||||
return { label: "👁 Watch", color: "#94a3b8", detail: "Early stage — too early for volume commitments. Monitor for traction." };
|
||||
default:
|
||||
return { label: "📊 Monitor", color: "#64748b", detail: "Monitor signals." };
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table)
|
||||
hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@ -198,6 +248,74 @@ hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// GET /api/hype-cycle/:tech — Specific technology detail (must be last!)
|
||||
// GET /api/hype-cycle/market-signals — data-driven per-technology market signal score + drivers + recommendation
|
||||
hypeCycleRouter.get("/market-signals", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const yearParam = q("year", _req);
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear();
|
||||
const overridesMap = await getDataDrivenOverrides();
|
||||
const speedMetrics = await getSpeedClassMetrics();
|
||||
const metricsBySpeed = new Map(speedMetrics.map((m) => [m.speedGbps, m]));
|
||||
const maxSku = Math.max(1, ...speedMetrics.map((m) => m.skuCount));
|
||||
const allTechs = [...TECH_GENERATIONS, ...SPECIAL_TECHS];
|
||||
|
||||
const technologies = allTechs.map((tech) => {
|
||||
const r = computeHypeCycle(tech, year, overridesMap.get(tech.speedGbps));
|
||||
const sm = metricsBySpeed.get(tech.speedGbps);
|
||||
const adoption = Math.max(0, Math.round(r.adoptionPct));
|
||||
const composite = Math.max(0, Math.round(r.compositeScore));
|
||||
const depth = sm ? Math.round(100 * Math.min(1, sm.skuCount / maxSku)) : 0;
|
||||
const marketSignalScore = Math.max(0, Math.min(100, Math.round(0.4 * adoption + 0.3 * composite + 0.3 * depth)));
|
||||
|
||||
const drivers: string[] = [];
|
||||
drivers.push(adoption + "% modeled adoption");
|
||||
if (sm && sm.skuCount) drivers.push(sm.skuCount + " SKUs / " + sm.vendorCount + " vendors");
|
||||
if (sm && sm.avgPrice) drivers.push("avg $" + Math.round(sm.avgPrice));
|
||||
drivers.push("phase: " + r.phaseLabel);
|
||||
|
||||
const phase = String(r.phaseLabel || "");
|
||||
let recommendation = { label: "Monitor", detail: "Insufficient market momentum — keep watching.", color: "#94a3b8" };
|
||||
if (/Plateau/i.test(phase)) recommendation = { label: "Commodity — negotiate", detail: "Mature, broad supply (" + (sm ? sm.vendorCount : 0) + " vendors). Push for price.", color: "#0ea5e9" };
|
||||
else if (/Slope/i.test(phase)) recommendation = { label: "Mainstream — buy", detail: "Mainstreaming with falling prices and rising supply.", color: "#16a34a" };
|
||||
else if (/Trough/i.test(phase)) recommendation = { label: "Maturing — opportunity", detail: "Past the hype; early mainstream pricing forming.", color: "#ca8a04" };
|
||||
else if (/Peak/i.test(phase)) recommendation = { label: "Hype peak — caution", detail: "Elevated expectations; supply and price still volatile.", color: "#f97316" };
|
||||
else if (/Innovation|Trigger/i.test(phase)) recommendation = { label: "Emerging — pilot", detail: "Early window; limited supply, premium pricing.", color: "#ca8a04" };
|
||||
|
||||
return {
|
||||
technology: tech.name,
|
||||
phase: r.phaseLabel,
|
||||
marketSignalScore,
|
||||
adoptionPct: adoption,
|
||||
compositeScore: composite,
|
||||
skuCount: sm ? sm.skuCount : 0,
|
||||
vendorCount: sm ? sm.vendorCount : 0,
|
||||
avgPrice: sm && sm.avgPrice ? Math.round(sm.avgPrice * 100) / 100 : null,
|
||||
drivers,
|
||||
recommendation,
|
||||
};
|
||||
});
|
||||
|
||||
technologies.sort((a, b) => b.marketSignalScore - a.marketSignalScore);
|
||||
|
||||
const totalSkus = speedMetrics.reduce((acc, m) => acc + m.skuCount, 0);
|
||||
const totalVendors = speedMetrics.length ? Math.max(...speedMetrics.map((m) => m.vendorCount)) : 0;
|
||||
const avgSignal = technologies.length ? Math.round(technologies.reduce((acc, t) => acc + t.marketSignalScore, 0) / technologies.length) : 0;
|
||||
const globalContext = {
|
||||
totalSkus,
|
||||
totalVendors,
|
||||
avgMarketSignal: avgSignal,
|
||||
strongest: technologies.length ? technologies[0].technology : null,
|
||||
strongestScore: technologies.length ? technologies[0].marketSignalScore : null,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json({ success: true, year, technologies, globalContext });
|
||||
} catch (err) {
|
||||
console.error("market-signals error:", err);
|
||||
res.status(500).json({ success: false, error: "Failed to compute market signals" });
|
||||
}
|
||||
});
|
||||
|
||||
hypeCycleRouter.get("/:tech", (req: Request, res: Response) => {
|
||||
const techQuery = String(req.params.tech);
|
||||
const yearParam = q("year", req);
|
||||
|
||||
@ -36,6 +36,7 @@ priceComparisonRouter.get("/summary", async (_req: Request, res: Response) => {
|
||||
po.price,
|
||||
po.currency
|
||||
FROM price_observations po
|
||||
WHERE po.marketplace NOT LIKE 'flexoptix%'
|
||||
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
|
||||
)
|
||||
SELECT
|
||||
@ -55,6 +56,7 @@ priceComparisonRouter.get("/summary", async (_req: Request, res: Response) => {
|
||||
po.price,
|
||||
po.currency
|
||||
FROM price_observations po
|
||||
WHERE po.marketplace NOT LIKE 'flexoptix%'
|
||||
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
|
||||
)
|
||||
SELECT
|
||||
@ -110,6 +112,7 @@ priceComparisonRouter.get("/", async (req: Request, res: Response) => {
|
||||
po.price,
|
||||
po.currency
|
||||
FROM price_observations po
|
||||
WHERE po.marketplace NOT LIKE 'flexoptix%'
|
||||
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
|
||||
)
|
||||
SELECT
|
||||
@ -216,6 +219,7 @@ priceComparisonRouter.get("/:sku", async (req: Request, res: Response) => {
|
||||
po.time
|
||||
FROM price_observations po
|
||||
WHERE po.transceiver_id = $1
|
||||
AND po.marketplace NOT LIKE 'flexoptix%'
|
||||
ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC
|
||||
) po
|
||||
JOIN vendors v ON v.id = po.source_vendor_id
|
||||
|
||||
@ -781,38 +781,198 @@ procurementRouter.get("/dead-stock-revival", async (_req: Request, res: Response
|
||||
});
|
||||
|
||||
// ─── C: GET /api/procurement/supply-squeeze ──────────────────────────────────
|
||||
|
||||
// ─── GET /api/procurement/availability ───────────────────────────────────────
|
||||
// Data-grounded supply-availability per speed tier. Derived from real supplier
|
||||
// diversity (distinct source vendors offering it) + stock coverage. Surfaces the
|
||||
// 400G/800G/1.6T supply tightening that exists in the data but was not shown.
|
||||
procurementRouter.get("/availability", async (_req: Request, res: Response) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Disable parallel workers for this aggregate — Docker /dev/shm (64MB) is too
|
||||
// small for parallel hash and the query is fast enough single-threaded.
|
||||
await client.query('BEGIN');
|
||||
await client.query('SET LOCAL max_parallel_workers_per_gather = 0');
|
||||
const result = await client.query(`
|
||||
WITH sup AS (
|
||||
SELECT t.speed_gbps,
|
||||
COUNT(DISTINCT po.source_vendor_id) FILTER (WHERE po.time > NOW() - INTERVAL '45 days') AS suppliers,
|
||||
COUNT(DISTINCT po.source_vendor_id) FILTER (WHERE po.time <= NOW() - INTERVAL '45 days') AS suppliers_prior
|
||||
FROM price_observations po
|
||||
JOIN transceivers t ON t.id = po.transceiver_id
|
||||
WHERE t.speed_gbps IN (100,200,400,800,1600) AND po.time > NOW() - INTERVAL '90 days'
|
||||
GROUP BY t.speed_gbps
|
||||
),
|
||||
sk AS (
|
||||
SELECT speed_gbps, COUNT(*) AS sku_count
|
||||
FROM transceivers WHERE speed_gbps IN (100,200,400,800,1600)
|
||||
GROUP BY speed_gbps HAVING COUNT(*) >= 3
|
||||
),
|
||||
st AS (
|
||||
SELECT t.speed_gbps,
|
||||
COUNT(*) FILTER (WHERE s.in_stock IS TRUE) AS skus_in_stock,
|
||||
COUNT(*) AS skus_with_stock
|
||||
FROM transceivers t
|
||||
JOIN LATERAL (
|
||||
SELECT in_stock FROM stock_observations so
|
||||
WHERE so.transceiver_id = t.id ORDER BY so.time DESC LIMIT 1
|
||||
) s ON true
|
||||
WHERE t.speed_gbps IN (100,200,400,800,1600)
|
||||
GROUP BY t.speed_gbps
|
||||
),
|
||||
pm AS (
|
||||
-- per-SKU paired price momentum (30d vs prior 30d), bias-free: only SKUs in
|
||||
-- both windows; aggregate the median per-SKU pct delta per speed tier.
|
||||
SELECT speed_gbps,
|
||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY (med_now - med_prior) / NULLIF(med_prior,0) * 100)::numeric, 1) AS price_delta_pct
|
||||
FROM (
|
||||
SELECT t.speed_gbps, t.id,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS med_now,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days') AS med_prior,
|
||||
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS n_now,
|
||||
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days') AS n_prior
|
||||
FROM price_observations po JOIN transceivers t ON t.id = po.transceiver_id
|
||||
WHERE t.speed_gbps IN (100,200,400,800,1600) AND po.price > 0 AND COALESCE(po.is_anomalous,false) = false
|
||||
GROUP BY t.speed_gbps, t.id
|
||||
) per
|
||||
WHERE med_now IS NOT NULL AND med_prior IS NOT NULL AND med_prior > 0 AND n_now >= 2 AND n_prior >= 2
|
||||
GROUP BY speed_gbps
|
||||
)
|
||||
SELECT
|
||||
sk.speed_gbps,
|
||||
sk.sku_count::int,
|
||||
COALESCE(sup.suppliers,0)::int AS suppliers,
|
||||
COALESCE(sup.suppliers_prior,0)::int AS suppliers_prior,
|
||||
COALESCE(st.skus_in_stock,0)::int AS skus_in_stock,
|
||||
COALESCE(st.skus_with_stock,0)::int AS skus_with_stock,
|
||||
CASE WHEN COALESCE(st.skus_with_stock,0) > 0
|
||||
THEN ROUND(100.0 * st.skus_in_stock / st.skus_with_stock)::int ELSE NULL END AS in_stock_pct,
|
||||
pm.price_delta_pct
|
||||
FROM sk
|
||||
LEFT JOIN sup ON sup.speed_gbps = sk.speed_gbps
|
||||
LEFT JOIN st ON st.speed_gbps = sk.speed_gbps
|
||||
LEFT JOIN pm ON pm.speed_gbps = sk.speed_gbps
|
||||
ORDER BY sk.speed_gbps DESC
|
||||
`);
|
||||
|
||||
type Row = { speed_gbps: string; sku_count: number; suppliers: number; suppliers_prior: number; skus_in_stock: number; skus_with_stock: number; in_stock_pct: number | null; price_delta_pct: string | null };
|
||||
const tiers = (result.rows as Row[]).map((r) => {
|
||||
const sup = r.suppliers ?? 0;
|
||||
const inStockPct = r.in_stock_pct ?? null;
|
||||
// availability class from supplier diversity + stock coverage
|
||||
let availability: "scarce" | "constrained" | "moderate" | "abundant";
|
||||
if (sup <= 3 || (inStockPct !== null && inStockPct < 20)) availability = "scarce";
|
||||
else if (sup <= 6 || (inStockPct !== null && inStockPct < 45)) availability = "constrained";
|
||||
else if (sup <= 9) availability = "moderate";
|
||||
else availability = "abundant";
|
||||
// tightening trend: supplier diversity dropped vs prior window
|
||||
const trend = r.suppliers_prior > 0
|
||||
? (sup < r.suppliers_prior ? "tightening" : sup > r.suppliers_prior ? "loosening" : "stable")
|
||||
: "stable";
|
||||
const priceDelta = r.price_delta_pct != null ? parseFloat(r.price_delta_pct) : null;
|
||||
const priceTrend = priceDelta === null ? "unknown" : priceDelta > 3 ? "rising" : priceDelta < -3 ? "falling" : "stable";
|
||||
const drivers: string[] = [`${sup} active suppliers`];
|
||||
if (trend === "tightening") drivers.push(`supplier base shrank ${r.suppliers_prior}→${sup} vs prior 45d`);
|
||||
else if (trend === "loosening") drivers.push(`supplier base grew ${r.suppliers_prior}→${sup} vs prior 45d`);
|
||||
if (inStockPct !== null) drivers.push(`${inStockPct}% of tracked SKUs in stock`);
|
||||
if (priceDelta !== null) drivers.push(`price ${priceDelta >= 0 ? "+" : ""}${priceDelta}% (30d, same-SKU median)`);
|
||||
drivers.push(`${r.sku_count} SKUs catalogued`);
|
||||
// Plain-language WHY, built only from real per-speed signals
|
||||
const reasons: string[] = [];
|
||||
if (availability === "scarce") reasons.push(sup <= 3 ? `only ${sup} supplier(s) offer it` : `near-zero stock coverage`);
|
||||
else if (availability === "constrained") reasons.push(`limited supplier base (${sup})`);
|
||||
if (trend === "tightening") reasons.push(`supplier base is shrinking`);
|
||||
if (priceTrend === "rising") reasons.push(`prices rising ${priceDelta}% on the same parts`);
|
||||
if (priceTrend === "falling") reasons.push(`prices easing ${priceDelta}%`);
|
||||
if (inStockPct !== null && inStockPct < 50) reasons.push(`${inStockPct}% in-stock coverage`);
|
||||
const why = reasons.length ? reasons.join("; ") : `broad supply: ${sup} suppliers, ${inStockPct ?? "n/a"}% in stock, stable pricing`;
|
||||
return {
|
||||
speed_gbps: parseFloat(r.speed_gbps),
|
||||
sku_count: r.sku_count,
|
||||
suppliers: sup,
|
||||
in_stock_pct: inStockPct,
|
||||
price_delta_pct: priceDelta,
|
||||
price_trend: priceTrend,
|
||||
availability,
|
||||
trend,
|
||||
why,
|
||||
drivers,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ success: true, tiers });
|
||||
} catch (err) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
console.error("availability error:", err);
|
||||
res.status(500).json({ success: false, error: String(err) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 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(`
|
||||
-- Per-SKU paired comparison: only transceivers present in BOTH periods.
|
||||
-- This eliminates catalog-composition bias (new expensive SKUs entering a
|
||||
-- speed/form-factor bucket would otherwise fake a huge price jump).
|
||||
WITH per_sku AS (
|
||||
SELECT
|
||||
t.id, t.speed_gbps, t.form_factor,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price)
|
||||
FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS med_now,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price)
|
||||
FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days') AS med_prior,
|
||||
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_now,
|
||||
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days') AS obs_prior
|
||||
FROM price_observations po
|
||||
JOIN transceivers t ON t.id = po.transceiver_id
|
||||
WHERE po.price > 5 AND po.currency = 'USD'
|
||||
AND COALESCE(po.is_anomalous, false) = false
|
||||
AND t.form_factor IN ('SFP','SFP+','SFP28','SFP56','QSFP+','QSFP28','QSFP56','QSFP-DD','QSFP-DD800','OSFP','OSFP-XD','XFP','CFP','CFP2','CFP4','CDFP','DSFP')
|
||||
AND po.price < 15000
|
||||
AND t.part_number NOT ILIKE '%AOC%'
|
||||
AND t.part_number NOT ILIKE '%-DAC-%'
|
||||
AND (t.standard_name IS NULL OR (t.standard_name NOT ILIKE '%Switch%' AND t.standard_name NOT ILIKE '%InfiniBand%'))
|
||||
AND t.speed_gbps > 0
|
||||
GROUP BY t.id, t.speed_gbps, t.form_factor
|
||||
)
|
||||
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
|
||||
speed_gbps, form_factor,
|
||||
-- avg_30d / avg_prior_30d kept as column names for downstream compatibility,
|
||||
-- but they now carry MEDIAN-of-matched-SKU prices (only SKUs in both periods)
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY med_now) AS avg_30d,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY med_prior) AS avg_prior_30d,
|
||||
-- The real signal: median of per-SKU percentage deltas
|
||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY (med_now - med_prior) / NULLIF(med_prior,0) * 100)::numeric, 1) AS sku_median_delta_pct,
|
||||
COUNT(*) AS obs_30d
|
||||
FROM per_sku
|
||||
WHERE med_now IS NOT NULL AND med_prior IS NOT NULL AND med_prior > 0
|
||||
AND obs_now >= 2 AND obs_prior >= 2
|
||||
GROUP BY speed_gbps, form_factor
|
||||
HAVING COUNT(*) >= 3
|
||||
`),
|
||||
// AI cluster demand by speed tier
|
||||
// AI cluster demand by speed tier (speed_tier = 0 excluded — unclassified announcements)
|
||||
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
|
||||
SELECT speed_tier, total_tx, cluster_count FROM (
|
||||
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
|
||||
) t WHERE speed_tier > 0
|
||||
`),
|
||||
// Hype phase per technology
|
||||
pool.query(`
|
||||
@ -835,7 +995,7 @@ procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) =>
|
||||
`).catch(() => ({ rows: [] })),
|
||||
]);
|
||||
|
||||
type PriceRow = { speed_gbps: string; form_factor: string; avg_30d: string; avg_prior_30d: string; obs_30d: string };
|
||||
type PriceRow = { speed_gbps: string; form_factor: string; avg_30d: string; avg_prior_30d: string; obs_30d: string; sku_median_delta_pct: string | null };
|
||||
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 };
|
||||
@ -861,9 +1021,12 @@ procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) =>
|
||||
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;
|
||||
// Prefer the per-SKU median delta (composition-bias-free); fall back to aggregate
|
||||
const priceUp = r.sku_median_delta_pct != null
|
||||
? parseFloat(r.sku_median_delta_pct)
|
||||
: (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}`);
|
||||
@ -887,7 +1050,7 @@ procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) =>
|
||||
activeSignals, severity, reasons,
|
||||
};
|
||||
})
|
||||
.filter((r) => r.activeSignals >= 1)
|
||||
.filter((r) => r.activeSignals >= 1 && parseFloat(String(r.speed_gbps)) > 0)
|
||||
.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 });
|
||||
@ -1083,41 +1246,68 @@ procurementRouter.get("/price-movers", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
WITH cur AS (
|
||||
SELECT transceiver_id, source_vendor_id, currency,
|
||||
AVG(price) AS avg_price,
|
||||
-- Group by part_number+source_vendor+currency to avoid duplicates from multiple
|
||||
-- vendor-OEM transceiver_ids with the same part number.
|
||||
-- Use PERCENTILE_CONT (median) to suppress multi-tier list-price noise
|
||||
-- (e.g. Mouser 1x/10x/100x tiers appearing as price swings).
|
||||
SELECT t.part_number, po.source_vendor_id, po.currency,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price) AS med_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
|
||||
FROM price_observations po
|
||||
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
|
||||
GROUP BY t.part_number, po.source_vendor_id, po.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.part_number, po.source_vendor_id,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY po.price) AS med_price,
|
||||
COUNT(*) AS obs
|
||||
FROM price_observations po
|
||||
JOIN transceivers t ON t.id = po.transceiver_id
|
||||
WHERE po.time >= NOW() - INTERVAL '${days * 2} days'
|
||||
AND po.time < NOW() - INTERVAL '${days} days'
|
||||
AND po.price > 0 AND COALESCE(po.is_anomalous, false) = false
|
||||
GROUP BY t.part_number, po.source_vendor_id
|
||||
),
|
||||
ref_tx AS (
|
||||
-- pick one canonical transceiver_id per part_number for metadata
|
||||
SELECT DISTINCT ON (part_number) id, part_number, form_factor, speed_gbps, standard_name
|
||||
FROM transceivers ORDER BY part_number, id
|
||||
)
|
||||
SELECT
|
||||
t.id, t.part_number, t.form_factor,
|
||||
t.speed_gbps::text AS speed_gbps,
|
||||
t.standard_name,
|
||||
ref.id, ref.part_number, ref.form_factor,
|
||||
ref.speed_gbps::text AS speed_gbps,
|
||||
ref.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,
|
||||
ROUND(c.med_price::numeric, 2) AS current_avg,
|
||||
ROUND(p.med_price::numeric, 2) AS prior_avg,
|
||||
ROUND(((c.med_price - p.med_price) / NULLIF(p.med_price, 0) * 100)::numeric, 1) AS delta_pct,
|
||||
c.currency,
|
||||
c.obs::int AS observations
|
||||
(c.obs + p.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
|
||||
JOIN prior p ON p.part_number = c.part_number
|
||||
AND p.source_vendor_id = c.source_vendor_id
|
||||
JOIN ref_tx ref ON ref.part_number = c.part_number
|
||||
JOIN vendors sv ON sv.id = c.source_vendor_id
|
||||
-- cv_filter: exclude SKUs where the source has high price variance across the full
|
||||
-- 2*days window (e.g. Mouser quantity-tier noise). CV > 0.35 = unreliable source.
|
||||
JOIN (
|
||||
SELECT t2.part_number, po2.source_vendor_id,
|
||||
STDDEV(po2.price) / NULLIF(AVG(po2.price), 0) AS cv
|
||||
FROM price_observations po2
|
||||
JOIN transceivers t2 ON t2.id = po2.transceiver_id
|
||||
WHERE po2.time >= NOW() - INTERVAL '${days * 2} days'
|
||||
AND po2.price > 0 AND COALESCE(po2.is_anomalous, false) = false
|
||||
GROUP BY t2.part_number, po2.source_vendor_id
|
||||
HAVING COUNT(*) >= 2
|
||||
) cv_filter
|
||||
ON cv_filter.part_number = c.part_number
|
||||
AND cv_filter.source_vendor_id = c.source_vendor_id
|
||||
WHERE ABS((c.med_price - p.med_price) / NULLIF(p.med_price, 0) * 100) >= 2
|
||||
AND (c.obs + p.obs) >= 4
|
||||
AND COALESCE(cv_filter.cv, 0) <= 0.35
|
||||
ORDER BY ABS((c.med_price - p.med_price) / NULLIF(p.med_price, 0) * 100) DESC
|
||||
LIMIT ${limit * 2}
|
||||
`);
|
||||
|
||||
|
||||
210
packages/api/src/routes/research-robot.ts
Normal file
210
packages/api/src/routes/research-robot.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { pool } from "../db/client";
|
||||
|
||||
export const researchRobotRouter = Router();
|
||||
|
||||
// Known job queues are validated against pgboss.queue before any action.
|
||||
async function isKnownQueue(name: string): Promise<boolean> {
|
||||
const r = await pool.query("SELECT 1 FROM pgboss.queue WHERE name = $1", [name]);
|
||||
return (r.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
type Recommendation = {
|
||||
job: string;
|
||||
severity: "critical" | "warning" | "info";
|
||||
title: string;
|
||||
detail: string;
|
||||
cause: string;
|
||||
// Which actions the UI should offer for this job
|
||||
actions: Array<"dispatch" | "pause" | "resume" | "token_help">;
|
||||
failed_count?: number;
|
||||
last_error?: string;
|
||||
};
|
||||
|
||||
// Classify a job error message into a human cause + recommended actions.
|
||||
function classifyError(jobName: string, errorText: string): Omit<Recommendation, "job" | "failed_count" | "last_error"> {
|
||||
const e = (errorText || "").toLowerCase();
|
||||
|
||||
if (/http 401|http 403|unauthorized|forbidden|invalid.*token|api.*key/.test(e)) {
|
||||
return {
|
||||
severity: "critical",
|
||||
title: `${jobName}: Authentifizierung fehlgeschlagen`,
|
||||
detail: "Der Job erreicht die Quelle, wird aber abgewiesen (401/403). Meist ein fehlender oder abgelaufener API-Token. Ohne gültigen Token reiht der Robot den Job endlos neu ein und er schlägt jedes Mal fehl.",
|
||||
cause: "auth",
|
||||
actions: ["token_help", "pause"],
|
||||
};
|
||||
}
|
||||
if (/timeout|etimedout|econnrefused|enotfound|network|socket hang up|fetch failed/.test(e)) {
|
||||
return {
|
||||
severity: "warning",
|
||||
title: `${jobName}: Quelle nicht erreichbar`,
|
||||
detail: "Netzwerk-/Verbindungsfehler beim Abruf. Oft temporär (Ziel-Server kurz down, Rate-Limit). Manuell erneut auslösen oder pausieren, falls die Quelle dauerhaft weg ist.",
|
||||
cause: "network",
|
||||
actions: ["dispatch", "pause"],
|
||||
};
|
||||
}
|
||||
if (/no handler|not registered|unknown job|kein handler/.test(e)) {
|
||||
return {
|
||||
severity: "critical",
|
||||
title: `${jobName}: Kein Handler registriert`,
|
||||
detail: "Der Job ist im Schedule, aber es gibt keinen Code-Handler dafür. Er kann nie laufen — entweder Handler nachrüsten oder den Job aus dem Schedule entfernen.",
|
||||
cause: "no_handler",
|
||||
actions: ["pause"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: "warning",
|
||||
title: `${jobName}: Wiederholt fehlgeschlagen`,
|
||||
detail: "Der Job schlägt regelmäßig fehl. Letzte Fehlermeldung siehe unten. Manuell auslösen zum erneuten Versuch, oder pausieren falls überflüssig.",
|
||||
cause: "other",
|
||||
actions: ["dispatch", "pause"],
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/research-robot — letzte Robot-Laeufe + abgeleitete Handlungsempfehlungen
|
||||
researchRobotRouter.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
"SELECT run_at, freshness, decision, dispatched, model FROM research_robot_runs ORDER BY run_at DESC LIMIT 20"
|
||||
);
|
||||
|
||||
// Build recommendations from LIVE pgboss state (more accurate than the stored summary).
|
||||
const recommendations: Recommendation[] = [];
|
||||
try {
|
||||
const failing = await pool.query<{ name: string; failed: string; last_error: string | null }>(`
|
||||
SELECT j.name,
|
||||
COUNT(*) FILTER (WHERE j.state = 'failed') AS failed,
|
||||
(SELECT jj.output::text FROM pgboss.job jj
|
||||
WHERE jj.name = j.name AND jj.state = 'failed' AND jj.output IS NOT NULL
|
||||
ORDER BY jj.created_on DESC LIMIT 1) AS last_error
|
||||
FROM pgboss.job j
|
||||
WHERE j.created_on > NOW() - INTERVAL '3 days'
|
||||
GROUP BY j.name
|
||||
HAVING COUNT(*) FILTER (WHERE j.state = 'failed') >= 2
|
||||
AND COUNT(*) FILTER (WHERE j.state = 'completed') = 0
|
||||
ORDER BY failed DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
// Which jobs are currently scheduled (so we can offer pause vs resume correctly)
|
||||
const scheduled = await pool.query<{ name: string }>("SELECT name FROM pgboss.schedule");
|
||||
const scheduledSet = new Set(scheduled.rows.map((s) => s.name));
|
||||
|
||||
for (const row of failing.rows) {
|
||||
let errMsg = "";
|
||||
try {
|
||||
const parsed = JSON.parse(row.last_error || "{}") as { stack?: string; message?: string };
|
||||
errMsg = (parsed.stack || parsed.message || row.last_error || "").slice(0, 300);
|
||||
} catch {
|
||||
errMsg = (row.last_error || "").slice(0, 300);
|
||||
}
|
||||
const cls = classifyError(row.name, errMsg);
|
||||
// If the job isn't scheduled, swap pause→resume in the offered actions
|
||||
const actions = cls.actions.map((a) =>
|
||||
a === "pause" && !scheduledSet.has(row.name) ? "resume" : a
|
||||
) as Recommendation["actions"];
|
||||
recommendations.push({
|
||||
job: row.name,
|
||||
severity: cls.severity,
|
||||
title: cls.title,
|
||||
detail: cls.detail,
|
||||
cause: cls.cause,
|
||||
actions: [...new Set(actions)],
|
||||
failed_count: parseInt(row.failed, 10),
|
||||
last_error: errMsg.split("\n")[0].slice(0, 160),
|
||||
});
|
||||
}
|
||||
} catch { /* pgboss introspection best-effort */ }
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push({
|
||||
job: "",
|
||||
severity: "info",
|
||||
title: "Alle Scraper-Jobs gesund",
|
||||
detail: "Keine dauerhaft fehlschlagenden Jobs erkannt. Keine Aktion nötig.",
|
||||
cause: "healthy",
|
||||
actions: [],
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, runs: r.rows, recommendations });
|
||||
} catch (e) {
|
||||
res.json({ success: true, runs: [], recommendations: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/research-robot/action — { action, job }
|
||||
// dispatch → Job sofort in die Queue stellen; pause → aus Schedule entfernen (Backup);
|
||||
// resume → aus Backup wiederherstellen.
|
||||
researchRobotRouter.post("/action", async (req: Request, res: Response) => {
|
||||
const action = String((req.body?.action ?? "")).trim();
|
||||
const job = String((req.body?.job ?? "")).trim();
|
||||
|
||||
if (!["dispatch", "pause", "resume"].includes(action)) {
|
||||
res.status(400).json({ success: false, error: "Unbekannte Aktion." });
|
||||
return;
|
||||
}
|
||||
if (!job || !(await isKnownQueue(job))) {
|
||||
res.status(400).json({ success: false, error: "Unbekannter oder ungültiger Job." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "dispatch") {
|
||||
// Enqueue one job run immediately (pg-boss v10 standard policy, mirrors a scheduled send)
|
||||
await pool.query(
|
||||
`INSERT INTO pgboss.job (id, name, data, priority, retry_limit, retry_delay, expire_in, keep_until, policy, start_after)
|
||||
VALUES (gen_random_uuid(), $1, '{}'::jsonb, 0, 2, 0, INTERVAL '15 minutes', NOW() + INTERVAL '14 days', 'standard', NOW())`,
|
||||
[job]
|
||||
);
|
||||
res.json({ success: true, action, job, message: `Job '${job}' wurde in die Queue gestellt und läuft beim nächsten Worker-Tick.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure backup table for paused schedules exists
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS research_robot_paused_schedules (
|
||||
name TEXT PRIMARY KEY,
|
||||
cron TEXT, timezone TEXT, data JSONB, options JSONB,
|
||||
paused_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`);
|
||||
|
||||
if (action === "pause") {
|
||||
const sched = await pool.query("SELECT name, cron, timezone, data, options FROM pgboss.schedule WHERE name = $1", [job]);
|
||||
if (sched.rowCount === 0) {
|
||||
res.json({ success: true, action, job, message: `Job '${job}' war nicht im Schedule — bereits pausiert.` });
|
||||
return;
|
||||
}
|
||||
const s = sched.rows[0];
|
||||
await pool.query(
|
||||
`INSERT INTO research_robot_paused_schedules (name, cron, timezone, data, options, paused_at)
|
||||
VALUES ($1,$2,$3,$4,$5,NOW())
|
||||
ON CONFLICT (name) DO UPDATE SET cron=$2, timezone=$3, data=$4, options=$5, paused_at=NOW()`,
|
||||
[s.name, s.cron, s.timezone, s.data, s.options]
|
||||
);
|
||||
await pool.query("DELETE FROM pgboss.schedule WHERE name = $1", [job]);
|
||||
res.json({ success: true, action, job, message: `Job '${job}' pausiert (aus Schedule entfernt). Der Robot reiht ihn nicht mehr neu ein. Reversibel über 'Fortsetzen'.` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "resume") {
|
||||
const bak = await pool.query("SELECT name, cron, timezone, data, options FROM research_robot_paused_schedules WHERE name = $1", [job]);
|
||||
if (bak.rowCount === 0) {
|
||||
res.status(404).json({ success: false, error: `Kein pausierter Schedule-Eintrag für '${job}' gefunden.` });
|
||||
return;
|
||||
}
|
||||
const s = bak.rows[0];
|
||||
await pool.query(
|
||||
`INSERT INTO pgboss.schedule (name, cron, timezone, data, options, created_on, updated_on)
|
||||
VALUES ($1,$2,$3,$4,$5,NOW(),NOW())
|
||||
ON CONFLICT (name) DO UPDATE SET cron=$2, timezone=$3, data=$4, options=$5, updated_on=NOW()`,
|
||||
[s.name, s.cron, s.timezone, s.data, s.options]
|
||||
);
|
||||
await pool.query("DELETE FROM research_robot_paused_schedules WHERE name = $1", [job]);
|
||||
res.json({ success: true, action, job, message: `Job '${job}' wieder aktiviert (zurück im Schedule).` });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
@ -238,3 +238,85 @@ scraperRouter.get("/llm-insights", async (_req: Request, res: Response) => {
|
||||
res.status(503).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/scrapers/data-quality — Verification evidence coverage + quality metrics
|
||||
scraperRouter.get("/data-quality", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [coverageRows, evidenceTypes, robotActivity, dailyActivity] = await Promise.all([
|
||||
// Coverage: how many transceivers have each evidence type
|
||||
pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT t.id)::int AS total_transceivers,
|
||||
COUNT(DISTINCT CASE WHEN e.verification_type = 'price' THEN t.id END)::int AS have_price,
|
||||
COUNT(DISTINCT CASE WHEN e.verification_type = 'image' THEN t.id END)::int AS have_image,
|
||||
COUNT(DISTINCT CASE WHEN e.verification_type = 'details' THEN t.id END)::int AS have_details,
|
||||
COUNT(DISTINCT CASE WHEN e.verification_type = 'competitor_match' THEN t.id END)::int AS have_competitor,
|
||||
COUNT(DISTINCT CASE WHEN e.verification_type = 'artifact_quarantine' THEN t.id END)::int AS quarantined
|
||||
FROM transceivers t
|
||||
LEFT JOIN transceiver_verification_evidence e ON e.transceiver_id = t.id
|
||||
`),
|
||||
// Evidence type breakdown
|
||||
pool.query(`
|
||||
SELECT
|
||||
verification_type,
|
||||
COUNT(*)::int AS cnt,
|
||||
ROUND(AVG(confidence)::numeric, 3) AS avg_confidence,
|
||||
COUNT(DISTINCT transceiver_id)::int AS distinct_tx,
|
||||
COUNT(DISTINCT robot_name) AS robot_count,
|
||||
MAX(created_at) AS last_seen
|
||||
FROM transceiver_verification_evidence
|
||||
GROUP BY verification_type
|
||||
ORDER BY cnt DESC
|
||||
`),
|
||||
// Robot / scraper activity
|
||||
pool.query(`
|
||||
SELECT
|
||||
robot_name,
|
||||
COUNT(*)::int AS total_evidence,
|
||||
COUNT(DISTINCT transceiver_id)::int AS transceivers_covered,
|
||||
COUNT(DISTINCT verification_type) AS types_covered,
|
||||
MIN(created_at)::date AS first_run,
|
||||
MAX(created_at)::date AS last_run
|
||||
FROM transceiver_verification_evidence
|
||||
GROUP BY robot_name
|
||||
ORDER BY total_evidence DESC
|
||||
LIMIT 20
|
||||
`),
|
||||
// Daily activity last 14 days
|
||||
pool.query(`
|
||||
SELECT
|
||||
created_at::date AS day,
|
||||
COUNT(*)::int AS evidence_added,
|
||||
COUNT(DISTINCT transceiver_id)::int AS transceivers_processed
|
||||
FROM transceiver_verification_evidence
|
||||
WHERE created_at >= NOW() - INTERVAL '14 days'
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
`),
|
||||
]);
|
||||
|
||||
const cov = coverageRows.rows[0];
|
||||
const total = cov.total_transceivers || 1;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
coverage: {
|
||||
total: cov.total_transceivers,
|
||||
price: cov.have_price,
|
||||
image: cov.have_image,
|
||||
details: cov.have_details,
|
||||
competitor: cov.have_competitor,
|
||||
quarantined: cov.quarantined,
|
||||
pricePct: Math.round((cov.have_price / total) * 100),
|
||||
imagePct: Math.round((cov.have_image / total) * 100),
|
||||
detailsPct: Math.round((cov.have_details / total) * 100),
|
||||
competitorPct: Math.round((cov.have_competitor / total) * 100),
|
||||
},
|
||||
evidenceTypes: evidenceTypes.rows,
|
||||
robotActivity: robotActivity.rows,
|
||||
dailyActivity: dailyActivity.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
58
packages/api/src/routes/stock-competitor.ts
Normal file
58
packages/api/src/routes/stock-competitor.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { pool } from "../db/client";
|
||||
|
||||
export const stockCompetitorRouter = Router();
|
||||
|
||||
// GET /api/stock/competitor-by-tech
|
||||
// Returns publicly-available competitor stock levels aggregated by technology (form_factor+speed_gbps).
|
||||
// Used by Warehouse Stock Intelligence to benchmark Flexoptix demand against competitor availability.
|
||||
stockCompetitorRouter.get("/competitor-by-tech", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
t.form_factor,
|
||||
t.speed_gbps::numeric AS speed_gbps,
|
||||
sv.name AS vendor_name,
|
||||
COUNT(DISTINCT so.transceiver_id)::int AS skus,
|
||||
COALESCE(SUM(so.warehouse_de_qty),0)::int AS de_stock,
|
||||
COALESCE(SUM(so.warehouse_global_qty),0)::int AS global_stock,
|
||||
COALESCE(SUM(so.quantity_available),0)::int AS qty_available,
|
||||
MAX(so.time)::date AS last_seen
|
||||
FROM stock_observations so
|
||||
JOIN transceivers t ON t.id = so.transceiver_id
|
||||
JOIN vendors sv ON sv.id = so.source_vendor_id
|
||||
WHERE so.time > NOW() - INTERVAL '14 days'
|
||||
AND sv.name IN ('FS.COM','ATGBICS','FiberMall','NADDOD','Prolabs','Blue Optics','Skylane Optics')
|
||||
GROUP BY t.form_factor, t.speed_gbps, sv.name
|
||||
ORDER BY sv.name, global_stock DESC NULLS LAST
|
||||
`);
|
||||
|
||||
// Pivot: { "1G SFP": { "FS.COM": { de:..., global:... }, ... }, ... }
|
||||
const byTech: Record<string, Record<string, { de: number; global: number; skus: number; last_seen: string }>> = {};
|
||||
const vendors = new Set<string>();
|
||||
|
||||
for (const row of result.rows) {
|
||||
const n = parseFloat(row.speed_gbps);
|
||||
const spd = n >= 1000
|
||||
? ((n / 1000 * 10) % 10 === 0 ? Math.round(n / 1000).toString() : (n / 1000).toFixed(1)) + "T"
|
||||
: ((n * 10) % 10 === 0 ? Math.round(n).toString() : String(n)) + "G";
|
||||
const key = spd + " " + (row.form_factor || "?");
|
||||
if (!byTech[key]) byTech[key] = {};
|
||||
byTech[key][row.vendor_name] = {
|
||||
de: parseInt(row.de_stock) || 0,
|
||||
global: parseInt(row.global_stock) || parseInt(row.qty_available) || 0,
|
||||
skus: parseInt(row.skus) || 0,
|
||||
last_seen: row.last_seen,
|
||||
};
|
||||
vendors.add(row.vendor_name);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
vendors: [...vendors].sort(),
|
||||
by_tech: byTech,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
@ -518,6 +518,54 @@ stockRouter.get("/velocity/:id", async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── GET /api/stock/competitor-by-tech ───────────────────────────────────────
|
||||
// Competitor stock levels from publicly-scraped data, aggregated by technology.
|
||||
stockRouter.get("/competitor-by-tech", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
t.form_factor,
|
||||
t.speed_gbps::numeric AS speed_gbps,
|
||||
sv.name AS vendor_name,
|
||||
COUNT(DISTINCT so.transceiver_id)::int AS skus,
|
||||
COALESCE(SUM(so.warehouse_de_qty),0)::int AS de_stock,
|
||||
COALESCE(SUM(so.warehouse_global_qty),0)::int AS global_stock,
|
||||
COALESCE(SUM(so.quantity_available),0)::int AS qty_available,
|
||||
MAX(so.time)::date AS last_seen
|
||||
FROM stock_observations so
|
||||
JOIN transceivers t ON t.id = so.transceiver_id
|
||||
JOIN vendors sv ON sv.id = so.source_vendor_id
|
||||
WHERE so.time > NOW() - INTERVAL '14 days'
|
||||
AND sv.name IN ('FS.COM','ATGBICS','FiberMall','NADDOD','Prolabs','Blue Optics','Skylane Optics')
|
||||
GROUP BY t.form_factor, t.speed_gbps, sv.name
|
||||
ORDER BY sv.name, global_stock DESC NULLS LAST
|
||||
`);
|
||||
|
||||
const byTech: Record<string, Record<string, { de: number; global: number; skus: number; last_seen: string }>> = {};
|
||||
const vendors = new Set<string>();
|
||||
for (const row of result.rows) {
|
||||
const n = parseFloat(row.speed_gbps);
|
||||
const spd = n >= 1000
|
||||
? ((n / 1000 * 10) % 10 === 0 ? String(Math.round(n / 1000)) : (n / 1000).toFixed(1)) + "T"
|
||||
: ((n * 10) % 10 === 0 ? String(Math.round(n)) : String(n)) + "G";
|
||||
const key = spd + " " + (row.form_factor || "?");
|
||||
if (!byTech[key]) byTech[key] = {};
|
||||
byTech[key][row.vendor_name] = {
|
||||
de: parseInt(row.de_stock) || 0,
|
||||
global: parseInt(row.global_stock) || parseInt(row.qty_available) || 0,
|
||||
skus: parseInt(row.skus) || 0,
|
||||
last_seen: row.last_seen,
|
||||
};
|
||||
vendors.add(row.vendor_name);
|
||||
}
|
||||
res.json({ success: true, vendors: [...vendors].sort(), by_tech: byTech });
|
||||
} catch (err) {
|
||||
console.error("stock/competitor-by-tech error:", err);
|
||||
res.status(500).json({ success: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/stock/:id ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* Full observation history for one transceiver.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { pool } from "../db/client";
|
||||
import { listVendors } from "../db/queries";
|
||||
|
||||
@ -114,7 +114,41 @@ vendorRouter.post("/", async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// GET /api/vendors/:id — Get single vendor with full stats
|
||||
vendorRouter.get("/:id", async (req: Request, res: Response) => {
|
||||
// GET /api/vendors/reliability — per-vendor data-reliability scores (freshness/frequency/coverage)
|
||||
vendorRouter.get("/reliability", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT v.id::text AS vendor_id, v.name AS vendor_name,
|
||||
COUNT(DISTINCT t.id)::int AS sku_count,
|
||||
COUNT(po.transceiver_id)::int AS obs_count,
|
||||
MAX(po.time) AS last_obs
|
||||
FROM vendors v
|
||||
JOIN transceivers t ON t.vendor_id = v.id
|
||||
LEFT JOIN price_observations po ON po.transceiver_id = t.id
|
||||
GROUP BY v.id, v.name
|
||||
HAVING COUNT(DISTINCT t.id) > 0`);
|
||||
const rows = result.rows as Array<{ vendor_id: string; vendor_name: string; sku_count: number; obs_count: number; last_obs: string | null }>;
|
||||
const maxSku = Math.max(1, ...rows.map((r) => r.sku_count));
|
||||
const maxObs = Math.max(1, ...rows.map((r) => r.obs_count));
|
||||
const now = Date.now();
|
||||
const vendors = rows.map((r) => {
|
||||
const daysStale = r.last_obs ? Math.max(0, (now - new Date(r.last_obs).getTime()) / 86400000) : 999;
|
||||
const freshness_score = Math.max(0, Math.min(100, Math.round(100 - daysStale * 3)));
|
||||
const frequency_score = Math.round(100 * Math.min(1, Math.log10(r.obs_count + 1) / Math.log10(maxObs + 1)));
|
||||
const coverage_score = Math.round(100 * Math.min(1, r.sku_count / maxSku));
|
||||
const reliability_score = Math.round(0.4 * freshness_score + 0.3 * frequency_score + 0.3 * coverage_score);
|
||||
return { vendor_id: r.vendor_id, vendor_name: r.vendor_name, sku_count: r.sku_count, obs_count: r.obs_count, last_obs: r.last_obs, freshness_score, frequency_score, coverage_score, reliability_score };
|
||||
});
|
||||
vendors.sort((a, b) => b.reliability_score - a.reliability_score);
|
||||
res.json({ success: true, count: vendors.length, vendors });
|
||||
} catch (err) {
|
||||
console.error("vendor reliability error:", err);
|
||||
res.status(500).json({ success: false, error: "Failed to compute vendor reliability" });
|
||||
}
|
||||
});
|
||||
|
||||
vendorRouter.get("/:id", async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.params.id === "market-share" || req.params.id === "intelligence") return next();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
@ -181,8 +215,8 @@ vendorRouter.get("/market-share", async (req: Request, res: Response) => {
|
||||
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,
|
||||
ROUND((COUNT(DISTINCT po.transceiver_id)::numeric / NULLIF(t.total,0)::numeric) * 100, 1) AS market_share_pct,
|
||||
COUNT(*)::int AS total_obs,
|
||||
MAX(po.time) AS last_seen
|
||||
FROM price_observations po
|
||||
JOIN vendors v ON v.id = po.source_vendor_id
|
||||
@ -228,7 +262,7 @@ vendorRouter.get("/market-share", async (req: Request, res: Response) => {
|
||||
(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)
|
||||
ELSE ROUND(((c.sku_count - p.sku_count)::numeric / p.sku_count::numeric) * 100, 1)
|
||||
END AS delta_pct
|
||||
FROM cur c
|
||||
JOIN vendors v ON v.id = c.source_vendor_id
|
||||
@ -273,7 +307,7 @@ vendorRouter.get("/intelligence", async (_req: Request, res: Response) => {
|
||||
v.type,
|
||||
v.website,
|
||||
COUNT(DISTINCT po.transceiver_id)::int AS sku_count,
|
||||
COUNT(po.id)::int AS price_obs,
|
||||
COUNT(*)::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,
|
||||
|
||||
@ -722,6 +722,18 @@
|
||||
|
||||
<!-- Auth guard — redirect to login if no valid token -->
|
||||
<script>
|
||||
// fmtSpd: clean speed display — 1.00->1G, 400.00->400G, 1600->1.6T, 2.5->2.5G
|
||||
function fmtSpd(gbps) {
|
||||
if (gbps == null || gbps === '') return '?';
|
||||
var n = parseFloat(gbps);
|
||||
if (isNaN(n) || n === 0) return '?';
|
||||
if (n >= 1000) {
|
||||
var t = n / 1000;
|
||||
return ((t * 10) % 10 === 0 ? String(Math.round(t)) : t.toFixed(1)) + 'T';
|
||||
}
|
||||
return ((n * 10) % 10 === 0 ? String(Math.round(n)) : String(n)) + 'G';
|
||||
}
|
||||
|
||||
// ── Token storage helpers — never store plaintext ──────────────────────────
|
||||
(function() {
|
||||
var _K = 'tip_v3_tk';
|
||||
@ -901,6 +913,8 @@
|
||||
</div>
|
||||
<div id="ov-movers-inner" class="mt" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"></div>
|
||||
</div>
|
||||
<!-- RESEARCH ROBOT (overview) -->
|
||||
<div class="card mb" id="research-robot-card" style="display:none"></div>
|
||||
<!-- RESEARCH STATUS -->
|
||||
<div class="card mb" id="verification-card">
|
||||
<div class="card-label">Data Research Status</div>
|
||||
@ -1756,6 +1770,16 @@
|
||||
<!-- PROCUREMENT INTEL TAB -->
|
||||
<div id="tab-procurement" class="hidden">
|
||||
|
||||
<!-- Transceiver-Suche -->
|
||||
<div style="margin-bottom:1.25rem;border:1px solid var(--border);border-radius:10px;padding:0.9rem 1rem;background:var(--surface2)">
|
||||
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright);margin-bottom:0.5rem">🔎 Transceiver suchen — Preise, Anbieter, Verfügbarkeit & Signale</div>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
|
||||
<input id="proc-tx-search" type="text" onkeydown="if(event.key==='Enter')procTxSearch()" placeholder="Part-Number oder Name, z.B. SFP-10G-LR, QSFP28-100G…" style="flex:1;min-width:240px;padding:0.5rem 0.7rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--text);font-size:0.85rem">
|
||||
<button onclick="procTxSearch()" style="padding:0.5rem 1.1rem;border:none;border-radius:7px;background:var(--accent);color:#fff;font-weight:600;font-size:0.85rem;cursor:pointer">Suchen</button>
|
||||
</div>
|
||||
<div id="proc-tx-results" style="margin-top:0.6rem"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-nav -->
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="showProcSection('signals')" id="proc-btn-signals" class="proc-btn proc-btn-active">Reorder Signals</button>
|
||||
@ -2331,10 +2355,12 @@
|
||||
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Momentum</th>
|
||||
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Trend</th>
|
||||
<th style="padding:6px 8px;text-align:center;color:var(--text-dim);font-weight:500">Fast Movers</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:#06b6d4;font-weight:500;font-size:0.7rem;white-space:nowrap" title="FS.COM DE-Lager (14-Tage-Schnitt)">FS.COM DE</th>
|
||||
<th style="padding:6px 8px;text-align:right;color:#0ea5e9;font-weight:500;font-size:0.7rem;white-space:nowrap" title="FS.COM Global-Lager (14-Tage-Schnitt)">FS.COM Global</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="foxd-by-speed-body">
|
||||
<tr><td colspan="7" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Flexoptix Demand-Daten…</td></tr>
|
||||
<tr><td colspan="9" style="text-align:center;padding:2rem;color:var(--text-dim)">Lade Flexoptix Demand-Daten…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -3519,6 +3545,7 @@ async function loadOverview() {
|
||||
|
||||
// Async fire — don't block overview render
|
||||
loadProcurementPulse();
|
||||
loadResearchRobot();
|
||||
}
|
||||
|
||||
// SEARCH
|
||||
@ -4607,11 +4634,14 @@ async function openTxDetail(id) {
|
||||
h += '<div style="font-size:0.72rem;color:#888;margin-bottom:0.5rem">Gleiche Spezifikationsklasse — andere Part Number</div>';
|
||||
comparPrices.forEach(function(p) {
|
||||
// Calculate price delta (EUR-normalized)
|
||||
var myEur = null;
|
||||
var refPrice = directPrices.length > 0 ? directPrices[0] : null;
|
||||
if (refPrice) {
|
||||
var ra = parseFloat(refPrice.price), rc = (refPrice.currency||'USD').toUpperCase();
|
||||
myEur = rc === 'EUR' ? ra : rc === 'USD' ? ra * 0.92 : ra;
|
||||
// Use Flexoptix own verified EUR price as reference for comparison
|
||||
var myEur = t.price_verified_eur ? parseFloat(t.price_verified_eur) : null;
|
||||
if (!myEur) {
|
||||
var refPrice = directPrices.length > 0 ? directPrices[0] : null;
|
||||
if (refPrice) {
|
||||
var ra = parseFloat(refPrice.price), rc = (refPrice.currency||'USD').toUpperCase();
|
||||
myEur = rc === 'EUR' ? ra : rc === 'USD' ? ra * 0.92 : ra;
|
||||
}
|
||||
}
|
||||
var compEur = null;
|
||||
var ca = parseFloat(p.price), cc = (p.currency||'USD').toUpperCase();
|
||||
@ -4639,8 +4669,8 @@ async function openTxDetail(id) {
|
||||
+ '<td style="color:' + compColor + ';font-size:0.7rem;padding:2px 0">' + esc(compVal || '—') + '</td></tr>';
|
||||
}
|
||||
|
||||
var mySpeed = t.speed_gbps >= 1000 ? (t.speed_gbps / 1000).toFixed(1).replace('.0','') + 'T' : t.speed_gbps + 'G';
|
||||
var compSpeed = p.comp_speed_gbps ? (p.comp_speed_gbps >= 1000 ? (p.comp_speed_gbps/1000).toFixed(1).replace('.0','')+'T' : p.comp_speed_gbps+'G') : null;
|
||||
var mySpeed = fmtSpd(t.speed_gbps);
|
||||
var compSpeed = p.comp_speed_gbps ? fmtSpd(p.comp_speed_gbps) : null;
|
||||
|
||||
h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:0.6rem;overflow:hidden">';
|
||||
|
||||
@ -4661,7 +4691,8 @@ async function openTxDetail(id) {
|
||||
if (savBadge) {
|
||||
h += '<div style="padding:0.3rem 0.75rem;background:rgba(255,255,255,0.02);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:0.5rem">';
|
||||
h += savBadge;
|
||||
h += '<span style="font-size:0.68rem;color:#666">vs. Flexoptix Listenpreis</span>';
|
||||
var fxPriceLabel = myEur ? 'EUR ' + myEur.toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2}) : null;
|
||||
h += '<span style="font-size:0.68rem;color:#666">vs. Flexoptix Listenpreis' + (fxPriceLabel ? ' (' + fxPriceLabel + ')' : '') + '</span>';
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
@ -5181,7 +5212,7 @@ function searchSwitches() {
|
||||
buildDOM(el('sw-table'), items.map(function(s) {
|
||||
var catColors = { DataCenter: 'b-blue', Campus: 'b-green', SP: 'b-purple', Core: 'b-orange', Edge: 'b-cyan', Industrial: 'b-yellow' };
|
||||
var statusColors = { Active: 'b-green', 'EoS_Announced': 'b-yellow', EoL: 'b-red', Legacy: 'b-neutral' };
|
||||
var maxSpd = s.max_speed_gbps >= 1000 ? (s.max_speed_gbps/1000) + 'T' : s.max_speed_gbps + 'G';
|
||||
var maxSpd = fmtSpd(s.max_speed_gbps);
|
||||
var cap = s.switching_capacity_tbps ? s.switching_capacity_tbps + ' Tbps' : '—';
|
||||
// Thumbnail — show image if available, otherwise a switch icon
|
||||
var thumb = s.image_url
|
||||
@ -5435,12 +5466,7 @@ async function openSwitchDetail(id) {
|
||||
fch += '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.6rem">Passend für diesen Switch — FlexBox-Codierung möglich</div>';
|
||||
|
||||
// Format speed_gbps → "1.6T", "400G", "100G" etc.
|
||||
function fmtSpeed(gbps) {
|
||||
if (!gbps) return '?';
|
||||
var n = parseFloat(gbps);
|
||||
if (n >= 1000) return (n / 1000) + 'T';
|
||||
return Math.round(n) + 'G';
|
||||
}
|
||||
var fmtSpeed = fmtSpd; // use global
|
||||
|
||||
// Group by speed class
|
||||
var foGroups = {};
|
||||
@ -5930,7 +5956,7 @@ function renderFormFactors(items) {
|
||||
var sCl = statusColors[f.status] || '#888';
|
||||
var fCl = familyColors[f.family] || '#888';
|
||||
var sLbl = statusLabels[f.status] || f.status || '';
|
||||
var maxSpd = f.max_speed_gbps >= 1000 ? (f.max_speed_gbps/1000) + 'T' : (f.max_speed_gbps || '?') + 'G';
|
||||
var maxSpd = fmtSpd(f.max_speed_gbps);
|
||||
// Description: show first part (DE) if bilingual
|
||||
var descFull = f.description || '';
|
||||
var descDE = descFull.split('//')[0].trim();
|
||||
@ -6911,7 +6937,7 @@ async function runFinder() {
|
||||
'<div style="font-size:1.1rem;font-weight:700">' + sw.vendor + ' ' + sw.model + '</div>' +
|
||||
'<div style="color:var(--text-dim);font-size:0.8rem">' +
|
||||
(sw.series ? sw.series + ' · ' : '') +
|
||||
'Max speed: ' + (sw.max_speed_gbps || '?') + 'G' +
|
||||
'Max speed: ' + fmtSpd(sw.max_speed_gbps) +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="text-align:right;font-size:0.8rem;color:var(--text-dim)">' +
|
||||
@ -6927,7 +6953,7 @@ async function runFinder() {
|
||||
// Group by speed class
|
||||
var bySpeed = {};
|
||||
for (var t of transceivers) {
|
||||
var key = t.speed_gbps + 'G ' + t.form_factor;
|
||||
var key = fmtSpd(t.speed_gbps) + ' ' + t.form_factor;
|
||||
if (!bySpeed[key]) bySpeed[key] = [];
|
||||
bySpeed[key].push(t);
|
||||
}
|
||||
@ -7964,7 +7990,7 @@ async function loadReorderTop() {
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;color:var(--text-bright);font-size:0.78rem">'+esc(r.vendor_name||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.72rem;color:var(--text-dim)">'+esc(r.part_number||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px;font-size:0.7rem">'+esc(r.form_factor||'')+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.78rem">'+esc(String(r.speed_gbps||''))+'G</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.78rem">'+fmtSpd(r.speed_gbps)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center;font-weight:700;color:'+strColor+';font-family:monospace">'+str+'%</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem">'+pt+' Preis · '+st+' Stock</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'+esc(reasons)+'">'+esc(reasons.substring(0,80))+'</td>'
|
||||
@ -8080,7 +8106,7 @@ async function loadSwitchCompat() {
|
||||
+ '<td style="padding:0.4rem 0.5rem;font-weight:600;font-size:0.75rem;color:var(--text-bright)">'+esc(t.vendor_name)+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;font-family:monospace;font-size:0.7rem;color:var(--text-dim)">'+esc(t.part_number||'')+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:1px 5px;font-size:0.68rem">'+esc(t.form_factor)+'</span></td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+esc(String(t.speed_gbps))+'G</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+fmtSpd(t.speed_gbps)+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:right;color:var(--green);font-size:0.72rem">'+esc(priceStr)+'</td>'
|
||||
+ '<td style="padding:0.4rem 0.5rem;text-align:center;font-size:0.68rem;color:var(--text-dim)">'+esc(t.verification_method||'')+'</td>'
|
||||
+ '</tr>';
|
||||
@ -8103,12 +8129,51 @@ async function loadSwitchCompat() {
|
||||
}
|
||||
|
||||
/* ── C: Supply Squeeze Detector ────────────────────────────────────────── */
|
||||
|
||||
/* ── Verfügbarkeit pro Speed-Tier (mit Warum-Begründung) ────────────────── */
|
||||
async function renderAvailabilityPanel() {
|
||||
var host = el('proc-squeeze-summary');
|
||||
if (!host) return;
|
||||
try {
|
||||
var d = await api('/api/procurement/availability');
|
||||
var tiers = (d && d.tiers) || [];
|
||||
if (!tiers.length) return;
|
||||
var meta = {
|
||||
scarce: { c:'#ef4444', bg:'rgba(239,68,68,0.10)', lbl:'KNAPP' },
|
||||
constrained: { c:'#f97316', bg:'rgba(249,115,22,0.10)', lbl:'eng' },
|
||||
moderate: { c:'#f59e0b', bg:'rgba(245,158,11,0.10)', lbl:'moderat' },
|
||||
abundant: { c:'#22c55e', bg:'rgba(34,197,94,0.10)', lbl:'breit' }
|
||||
};
|
||||
function spd(g){ return g>=1000 ? (g/1000)+'T' : g+'G'; }
|
||||
var html = '<div style="margin-bottom:1rem">'
|
||||
+ '<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright);margin-bottom:0.5rem">📦 Verfügbarkeit nach Geschwindigkeit <span style="font-weight:400;color:var(--text-dim);font-size:0.7rem">— aus Anbieter-Diversität, Stock-Coverage & Preis-Momentum (echte Daten)</span></div>'
|
||||
+ '<div style="display:flex;gap:0.5rem;flex-wrap:wrap">';
|
||||
tiers.forEach(function(t) {
|
||||
var m = meta[t.availability] || meta.moderate;
|
||||
var pt = t.price_trend, pd = t.price_delta_pct;
|
||||
var priceTag = pt==='rising' ? '<span style="color:#ef4444">▲ +'+pd+'%</span>' : pt==='falling' ? '<span style="color:#22c55e">▼ '+pd+'%</span>' : '<span style="color:var(--text-dim)">→ stabil</span>';
|
||||
html += '<div title="' + esc(t.why || '') + '" style="flex:1;min-width:150px;border:1px solid '+m.c+'44;background:'+m.bg+';border-radius:9px;padding:0.6rem 0.7rem">'
|
||||
+ '<div style="display:flex;align-items:baseline;justify-content:space-between">'
|
||||
+ '<span style="font-size:1.05rem;font-weight:800;color:var(--text-bright)">'+spd(t.speed_gbps)+'</span>'
|
||||
+ '<span style="font-size:0.62rem;font-weight:800;color:'+m.c+';text-transform:uppercase;letter-spacing:0.03em">'+m.lbl+'</span>'
|
||||
+ '</div>'
|
||||
+ '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">'+t.suppliers+' Anbieter · '+(t.in_stock_pct!=null?t.in_stock_pct+'% stock':'k.A.')+' · '+priceTag+'</div>'
|
||||
+ '<div style="font-size:0.66rem;color:var(--text-dim);margin-top:5px;line-height:1.35">'+esc((t.why||'').charAt(0).toUpperCase()+(t.why||'').slice(1))+'</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
// prepend to summary (before the squeeze severity badges, which get set after)
|
||||
host.insertAdjacentHTML('afterbegin', html);
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadSupplySqueeze() {
|
||||
var token = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var summEl = el('proc-squeeze-summary');
|
||||
var listEl = el('proc-squeeze-list');
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--text-dim)">Analysiere Preis- & Nachfragesignale…</div>';
|
||||
try {
|
||||
renderAvailabilityPanel();
|
||||
var r = await fetch('/api/procurement/supply-squeeze', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
var d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
@ -8129,7 +8194,7 @@ async function loadSupplySqueeze() {
|
||||
var mom = s.price_momentum_pct !== 0 ? (s.price_momentum_pct > 0 ? '+' : '') + s.price_momentum_pct + '%' : '—';
|
||||
var momColor = s.price_momentum_pct > 10 ? '#ef4444' : s.price_momentum_pct > 0 ? '#f59e0b' : '#22c55e';
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.8rem;color:'+col+'">'+icon+' '+esc(String(s.speed_gbps))+'G</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.8rem;color:'+col+'">'+icon+' '+fmtSpd(s.speed_gbps)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 6px;font-size:0.7rem">'+esc(s.form_factor||'—')+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:700;color:'+momColor+';font-family:monospace">'+esc(mom)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.7rem;color:var(--text-dim)">'+esc((s.hype_phase||'—').replace(/_/g,' '))+'</td>'
|
||||
@ -8181,7 +8246,7 @@ async function loadDeadStockRevival() {
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-weight:700;font-size:0.75rem;color:var(--text-bright)">'+esc(r.vendor_name||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-family:monospace;font-size:0.68rem;color:var(--text-dim)">'+esc(r.part_number||'')+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:center"><span style="background:rgba(99,102,241,0.15);color:#818cf8;border-radius:4px;padding:2px 5px;font-size:0.68rem">'+esc(r.form_factor)+'</span></td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+esc(String(r.speed_gbps))+'G</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;color:var(--blue);font-weight:700;font-size:0.72rem">'+fmtSpd(r.speed_gbps)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;font-size:0.72rem">'+icon+' '+esc((r.hype_phase||'').replace(/_/g,' '))+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-weight:700;color:'+scoreColor+';font-family:monospace">'+Math.round(r.hype_score)+'</td>'
|
||||
+ '<td style="padding:0.45rem 0.5rem;text-align:right;font-size:0.72rem;color:'+(parseFloat(trend)>0?'#22c55e':'#64748b')+'">'+esc(trend)+'</td>'
|
||||
@ -8251,12 +8316,12 @@ function renderSignals(filterSig) {
|
||||
if (r.image_r2_key) {
|
||||
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
return '<div class="signal-card ' + sigClass + '">'
|
||||
return '<div class="signal-card ' + sigClass + '" data-txid="' + esc(String(r.transceiver_id||r.id||'')) + '" data-part="' + esc(String(r.standard_name||r.part_number||'')) + '" style="cursor:pointer" onclick="openSignalPriceChart(this.dataset.txid,this.dataset.part)">'
|
||||
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
|
||||
+ imgHtml
|
||||
+ '<div style="flex:1;min-width:0">'
|
||||
+ '<div style="font-weight:700;font-size:0.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(productName) + (r.is_demo_data || r.is_demo ? demoBadgeHtml : '') + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(r.form_factor || '') + (r.speed_gbps ? ' · ' + r.speed_gbps + 'G' : '') + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(r.form_factor || '') + (r.speed_gbps ? ' · ' + fmtSpd(r.speed_gbps) : '') + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div style="display:flex;gap:0.4rem;align-items:center;margin-bottom:0.6rem;flex-wrap:wrap">'
|
||||
@ -9838,10 +9903,14 @@ async function loadStock() {
|
||||
|
||||
// ── Flexoptix Internal Demand (real data) ────────────────────────────────
|
||||
try {
|
||||
var [demandBySpeed, demandVelocity] = await Promise.all([
|
||||
var [demandBySpeed, demandVelocity, compStockResp] = await Promise.all([
|
||||
api('/api/internal/demand/by-speed').catch(function() { return null; }),
|
||||
api('/api/internal/demand/velocity').catch(function() { return null; })
|
||||
api('/api/internal/demand/velocity').catch(function() { return null; }),
|
||||
api('/api/stock/competitor-by-tech').catch(function() { return null; })
|
||||
]);
|
||||
if (compStockResp && compStockResp.success) {
|
||||
window._compStockByTech = compStockResp.by_tech || {};
|
||||
}
|
||||
|
||||
if (demandBySpeed && demandBySpeed.success && demandBySpeed.data) {
|
||||
var rows = demandBySpeed.data;
|
||||
@ -9872,7 +9941,7 @@ async function loadStock() {
|
||||
var momColor = momentum >= 1.05 ? '#22c55e' : momentum >= 0.95 ? '#f59e0b' : '#ef4444';
|
||||
var trendArrow = momentum >= 1.1 ? '▲▲' : momentum >= 1.02 ? '▲' : momentum >= 0.98 ? '→' : momentum >= 0.9 ? '▼' : '▼▼';
|
||||
var trendColor = momentum >= 1.05 ? '#22c55e' : momentum >= 0.95 ? '#f59e0b' : '#ef4444';
|
||||
var tech = (r.speed_gbps || '?') + 'G ' + (r.form_factor || '');
|
||||
var tech = fmtSpd(r.speed_gbps) + ' ' + (r.form_factor || '');
|
||||
var fastBadge = Number(r.fast_movers || 0) > 0
|
||||
? '<span style="background:#6366f122;color:#818cf8;border-radius:10px;padding:1px 7px;font-size:0.65rem;font-weight:700">' + r.fast_movers + '</span>'
|
||||
: '<span style="color:var(--text-dim)">—</span>';
|
||||
@ -9886,6 +9955,17 @@ async function loadStock() {
|
||||
+ '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:center;font-size:0.85rem;color:' + trendColor + ';font-weight:700">' + trendArrow + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:center">' + fastBadge + '</td>'
|
||||
+ (function() {
|
||||
if (!window._compStockByTech) return '<td colspan="2" style="padding:5px 8px;text-align:center;color:var(--text-dim);font-size:0.68rem">—</td>';
|
||||
var cs = window._compStockByTech[tech] || {};
|
||||
var fs = cs['FS.COM'] || null;
|
||||
if (!fs) return '<td style="padding:5px 8px;text-align:right;color:var(--text-dim)">—</td><td style="padding:5px 8px;text-align:right;color:var(--text-dim)">—</td>';
|
||||
var demandMonthly = Number(r.total_demand_12m || 0) / 12;
|
||||
var deColor = fs.de > demandMonthly * 3 ? '#22c55e' : fs.de > demandMonthly ? '#f59e0b' : '#ef4444';
|
||||
var glColor = fs.global > demandMonthly * 6 ? '#22c55e' : fs.global > demandMonthly * 2 ? '#f59e0b' : '#ef4444';
|
||||
return '<td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:0.72rem;color:' + deColor + '">' + Number(fs.de).toLocaleString() + '</td>'
|
||||
+ '<td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:0.72rem;color:' + glColor + '">' + Number(fs.global).toLocaleString() + '</td>';
|
||||
}())
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
@ -10397,6 +10477,144 @@ function exportMoversCSV() {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// B – PROCUREMENT PULSE (overview cards)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
async function loadResearchRobot() {
|
||||
try {
|
||||
var d = await api("/api/research-robot");
|
||||
if (!d.runs || !d.runs.length) return;
|
||||
var r = d.runs[0], f = r.freshness || {}, dec = r.decision || {};
|
||||
var card = el("research-robot-card"); if (!card) return;
|
||||
var total = f.jobsTotal || 0, stale = f.stale || 0;
|
||||
var disp = r.dispatched || [];
|
||||
var recs = d.recommendations || [];
|
||||
|
||||
var h = "<div class=\"card-label\">🤖 Research Robot <span style=\"font-weight:400;color:var(--text-dim);font-size:0.72rem\">letzter Lauf " + esc(new Date(r.run_at).toLocaleString()) + " · " + esc(r.model||"") + "</span></div>";
|
||||
h += "<div class=\"mt\" style=\"display:flex;gap:1.6rem;flex-wrap:wrap;align-items:baseline;margin-bottom:0.6rem\">";
|
||||
h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:var(--green)\">" + (total-stale) + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">/" + total + " Jobs frisch</span></div>";
|
||||
h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:" + (stale>0?"#f59e0b":"var(--green)") + "\">" + stale + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">ueberfaellig</span></div>";
|
||||
var critCount = recs.filter(function(x){return x.severity==="critical";}).length;
|
||||
if (critCount) h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:#ef4444\">" + critCount + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">Handlungsbedarf</span></div>";
|
||||
h += "</div>";
|
||||
|
||||
if (dec.assessment) h += "<div style=\"font-size:0.8rem;margin-bottom:0.6rem;color:var(--text-dim)\"><b style=\"color:var(--text)\">KI-Urteil (lokales LLM):</b> " + esc(dec.assessment) + "</div>";
|
||||
if (disp.length) h += "<div style=\"font-size:0.74rem;color:var(--text-dim);margin-bottom:0.5rem\">Automatisch neu eingereiht: " + disp.map(esc).join(", ") + "</div>";
|
||||
|
||||
// ── Recommendations with action buttons ─────────────────────────────────
|
||||
var sevMeta = {
|
||||
critical: { icon: "🔴", color: "#ef4444", bg: "rgba(239,68,68,0.07)", bd: "rgba(239,68,68,0.3)" },
|
||||
warning: { icon: "⚠️", color: "#f59e0b", bg: "rgba(245,158,11,0.07)", bd: "rgba(245,158,11,0.3)" },
|
||||
info: { icon: "✅", color: "#22c55e", bg: "rgba(34,197,94,0.07)", bd: "rgba(34,197,94,0.3)" }
|
||||
};
|
||||
var actionMeta = {
|
||||
dispatch: { label: "▶ Jetzt auslösen", color: "#6366f1", title: "Job sofort in die Queue stellen (einmaliger Lauf)" },
|
||||
pause: { label: "❚❚ Pausieren", color: "#f59e0b", title: "Aus dem Schedule nehmen — Robot reiht ihn nicht mehr neu ein (reversibel)" },
|
||||
resume: { label: "▶ Fortsetzen", color: "#22c55e", title: "Wieder in den Schedule aufnehmen" },
|
||||
token_help: { label: "🔑 Token-Anleitung", color: "#0ea5e9", title: "Wie man den fehlenden API-Token setzt" }
|
||||
};
|
||||
|
||||
h += "<div style=\"display:flex;flex-direction:column;gap:0.5rem;margin-top:0.4rem\">";
|
||||
recs.forEach(function(rec) {
|
||||
var m = sevMeta[rec.severity] || sevMeta.info;
|
||||
h += "<div style=\"border:1px solid " + m.bd + ";background:" + m.bg + ";border-radius:8px;padding:0.6rem 0.75rem\">";
|
||||
h += "<div style=\"display:flex;align-items:flex-start;gap:0.5rem;justify-content:space-between;flex-wrap:wrap\">";
|
||||
h += "<div style=\"flex:1;min-width:200px\">";
|
||||
h += "<div style=\"font-weight:700;font-size:0.84rem;color:" + m.color + "\">" + m.icon + " " + esc(rec.title) + "</div>";
|
||||
h += "<div style=\"font-size:0.76rem;color:var(--text-dim);margin-top:0.25rem;line-height:1.45\">" + esc(rec.detail) + "</div>";
|
||||
if (rec.last_error) h += "<div style=\"font-size:0.68rem;color:var(--text-dim);margin-top:0.3rem;font-family:var(--mono);opacity:0.8\">Letzter Fehler: " + esc(rec.last_error) + (rec.failed_count?" (" + rec.failed_count + "x fehlgeschlagen)":"") + "</div>";
|
||||
h += "</div>";
|
||||
// Action buttons
|
||||
if (rec.actions && rec.actions.length) {
|
||||
h += "<div style=\"display:flex;gap:0.4rem;flex-wrap:wrap;align-items:flex-start\">";
|
||||
rec.actions.forEach(function(act) {
|
||||
var am = actionMeta[act]; if (!am) return;
|
||||
if (act === "token_help") {
|
||||
h += "<button onclick=\"showTokenHelp('" + esc(rec.job) + "')\" title=\"" + am.title + "\" style=\"cursor:pointer;background:transparent;border:1px solid " + am.color + ";color:" + am.color + ";border-radius:6px;padding:4px 10px;font-size:0.72rem;font-weight:600;white-space:nowrap\">" + am.label + "</button>";
|
||||
} else {
|
||||
h += "<button onclick=\"researchRobotAction('" + act + "','" + esc(rec.job) + "',this)\" title=\"" + am.title + "\" style=\"cursor:pointer;background:transparent;border:1px solid " + am.color + ";color:" + am.color + ";border-radius:6px;padding:4px 10px;font-size:0.72rem;font-weight:600;white-space:nowrap\">" + am.label + "</button>";
|
||||
}
|
||||
});
|
||||
h += "</div>";
|
||||
}
|
||||
h += "</div></div>";
|
||||
});
|
||||
h += "</div>";
|
||||
|
||||
card.innerHTML = h; card.style.display = "";
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// Execute a research-robot action (dispatch/pause/resume) and refresh the panel
|
||||
async function researchRobotAction(action, job, btn) {
|
||||
if (btn) { btn.disabled = true; btn.style.opacity = "0.5"; }
|
||||
try {
|
||||
var token = (window.loadToken ? window.loadToken() : "") || "";
|
||||
var resp = await fetch("/api/research-robot/action", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer " + token, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: action, job: job })
|
||||
});
|
||||
var d = await resp.json();
|
||||
if (d.success) {
|
||||
if (typeof showToast === "function") showToast("Aktion ausgeführt", d.message || (action + " für " + job));
|
||||
} else {
|
||||
if (typeof showToast === "function") showToast("Fehler", d.error || "Aktion fehlgeschlagen", true);
|
||||
}
|
||||
} catch(e) {
|
||||
if (typeof showToast === "function") showToast("Fehler", e.message, true);
|
||||
}
|
||||
setTimeout(loadResearchRobot, 600);
|
||||
}
|
||||
|
||||
// Show how to set a missing API token (e.g. FLEXOPTIX_API_TOKEN)
|
||||
function showTokenHelp(job) {
|
||||
var isFlexoptix = /flexoptix/i.test(job);
|
||||
var msg;
|
||||
if (isFlexoptix) {
|
||||
msg = "Der Job '" + job + "' ruft die Flexoptix REST-API auf und bekommt HTTP 401 (kein gültiger Token).\n\n"
|
||||
+ "So setzt du den Token:\n"
|
||||
+ "1. Im Flexoptix-Portal einen REST-API-Token erzeugen (Account → API).\n"
|
||||
+ "2. Auf Erik in /opt/tip/.env setzen: FLEXOPTIX_API_TOKEN=<dein-token>\n"
|
||||
+ "3. API neu starten: pm2 restart tip-api\n\n"
|
||||
+ "Bis der Token gesetzt ist, kannst du den Job pausieren, damit er nicht alle 15 Min erneut fehlschlägt.";
|
||||
} else {
|
||||
msg = "Der Job '" + job + "' wird von der Quelle mit 401/403 abgewiesen — meist fehlt ein API-Token oder er ist abgelaufen.\n\n"
|
||||
+ "Token in /opt/tip/.env setzen und 'pm2 restart tip-api'. Bis dahin Job pausieren.";
|
||||
}
|
||||
if (typeof showModal === "function") { showModal("Token-Anleitung", "<pre style=\"white-space:pre-wrap;font-size:0.8rem;line-height:1.5\">" + esc(msg) + "</pre>"); }
|
||||
else { alert(msg); }
|
||||
}
|
||||
|
||||
|
||||
/* ── Procurement: Transceiver-Suche ────────────────────────────────────── */
|
||||
async function procTxSearch() {
|
||||
var q = (el('proc-tx-search').value || '').trim();
|
||||
var box = el('proc-tx-results');
|
||||
if (!q) { box.innerHTML = '<div style="color:var(--text-dim);font-size:0.78rem">Bitte Part-Number oder Name eingeben.</div>'; return; }
|
||||
box.innerHTML = '<div style="color:var(--text-dim);font-size:0.78rem">Suche…</div>';
|
||||
try {
|
||||
var d = await api('/api/transceivers?q=' + encodeURIComponent(q) + '&limit=25');
|
||||
var rows = d.data || [];
|
||||
if (!rows.length) { box.innerHTML = '<div style="color:var(--text-dim);font-size:0.78rem">Kein Transceiver gefunden für „' + esc(q) + '".</div>'; return; }
|
||||
var seen = {};
|
||||
var html = '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.35rem">' + (d.total || rows.length) + ' Treffer — klicken für Details (Preise, Anbieter, Verfügbarkeit, Preisverlauf):</div>';
|
||||
html += '<div style="display:flex;flex-direction:column;gap:2px;max-height:340px;overflow-y:auto">';
|
||||
rows.forEach(function(r) {
|
||||
// dedupe by part_number (mehrere OEM-Zeilen)
|
||||
var key = (r.part_number || r.id);
|
||||
if (seen[key]) return; seen[key] = 1;
|
||||
var spd = (typeof fmtSpd === 'function') ? fmtSpd(r.speed_gbps) : (r.speed_gbps + 'G');
|
||||
html += '<div onclick="openTxDetail(\'' + esc(String(r.id)) + '\')" style="cursor:pointer;display:flex;align-items:center;gap:0.6rem;padding:6px 8px;border-radius:6px;border:1px solid var(--border)" onmouseover="this.style.background=\'var(--surface2)\'" onmouseout="this.style.background=\'\'">'
|
||||
+ '<span style="font-family:var(--mono);font-size:0.78rem;font-weight:600;color:var(--text-bright);min-width:140px">' + esc(r.part_number || r.standard_name || '—') + '</span>'
|
||||
+ '<span style="font-size:0.7rem;color:var(--text-dim)">' + esc(r.form_factor || '') + ' · ' + spd + (r.reach_label ? ' · ' + esc(r.reach_label) : '') + '</span>'
|
||||
+ '<span style="font-size:0.68rem;color:var(--text-dim);margin-left:auto">' + esc(r.vendor_name || '') + '</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
box.innerHTML = html;
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div style="color:var(--text-dim);font-size:0.78rem">Suche fehlgeschlagen: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProcurementPulse() {
|
||||
var pulse = el('ov-proc-pulse');
|
||||
var moversCard = el('ov-movers-card');
|
||||
@ -10405,14 +10623,14 @@ async function loadProcurementPulse() {
|
||||
try {
|
||||
// Fire all in parallel
|
||||
var [reorder, arb, squeeze, movers] = await Promise.all([
|
||||
api('/api/procurement/reorder?limit=1').catch(function() { return null; }),
|
||||
api('/api/procurement/reorder-top?limit=1').catch(function() { return null; }),
|
||||
api('/api/procurement/arbitrage?limit=1').catch(function() { return null; }),
|
||||
api('/api/procurement/supply-squeeze?limit=1').catch(function() { return null; }),
|
||||
api('/api/procurement/price-movers?days=7&limit=10').catch(function() { return null; }),
|
||||
api('/api/equivalences?limit=1').catch(function() { return null; }),
|
||||
]);
|
||||
|
||||
var buySignals = reorder ? (reorder.total || (reorder.reorder_signals || []).length || 0) : 0;
|
||||
var buySignals = reorder && reorder.summary ? (reorder.summary.buy_now || 0) : 0;
|
||||
var arbOps = arb ? (arb.total || (arb.opportunities || []).length || 0) : 0;
|
||||
var supplyAlert = squeeze ? (squeeze.total || (squeeze.items || []).length || 0) : 0;
|
||||
var gainers = movers ? ((movers.gainers || []).length) : 0;
|
||||
@ -10636,7 +10854,7 @@ async function runBulkPrice() {
|
||||
+ '</div>'
|
||||
+ '<div style="display:flex;gap:0.4rem">'
|
||||
+ (r.form_factor ? '<span class="b b-blue">' + esc(r.form_factor) + '</span>' : '')
|
||||
+ (r.speed_gbps ? '<span class="b b-neutral">' + r.speed_gbps + 'G</span>' : '')
|
||||
+ (r.speed_gbps ? '<span class="b b-neutral">' + fmtSpd(r.speed_gbps) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
if (!r.prices || !r.prices.length) {
|
||||
@ -12224,6 +12442,89 @@ function roiCard(title, val, sub, color) {
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
|
||||
/* ── PRICE HISTORY CHART (signal-card click) ─────────────────────────── */
|
||||
async function openSignalPriceChart(txId, partNumber) {
|
||||
var modal = document.createElement('div');
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9000;display:flex;align-items:center;justify-content:center;padding:1rem';
|
||||
modal.innerHTML = '<div style="background:var(--surface);border-radius:12px;padding:1.5rem;max-width:680px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">'
|
||||
+ '<button onclick="this.closest(\'[style*=fixed]\').remove()" style="position:absolute;top:0.75rem;right:0.9rem;background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-dim)">x</button>'
|
||||
+ '<div style="font-weight:700;font-size:1rem;margin-bottom:1rem;color:var(--text-bright)" id="ph-title">Price History</div>'
|
||||
+ '<div id="ph-body" style="min-height:220px;display:flex;align-items:center;justify-content:center;color:var(--text-dim)">Loading...</div>'
|
||||
+ '</div>';
|
||||
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var tok = (window.loadToken ? window.loadToken() : '') || '';
|
||||
var d = await (await fetch('/api/price-history/' + txId + '?days=180', { headers: { 'Authorization': 'Bearer ' + tok } })).json();
|
||||
var tx = d.transceiver || {};
|
||||
document.getElementById('ph-title').textContent = 'Price History — ' + (tx.part_number || partNumber || txId)
|
||||
+ (tx.form_factor ? ' (' + tx.form_factor + (tx.speed_gbps ? ' · ' + fmtSpd(tx.speed_gbps) : '') + ')' : '');
|
||||
var series = d.series || [];
|
||||
if (!series.length) {
|
||||
document.getElementById('ph-body').innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:2rem">No price data available.</div>';
|
||||
return;
|
||||
}
|
||||
var vendors = {}, cur = d.current_prices || [];
|
||||
series.forEach(function(r) { if (r.source_vendor) vendors[r.source_vendor] = r.currency; });
|
||||
var vendorList = Object.keys(vendors).slice(0, 6);
|
||||
var COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#0ea5e9','#a855f7'];
|
||||
var dates = [], seen = {};
|
||||
series.forEach(function(r) { var d2 = r.day ? r.day.slice(0,10) : ''; if (d2 && !seen[d2]) { seen[d2]=1; dates.push(d2); } });
|
||||
dates.sort();
|
||||
var vData = {};
|
||||
vendorList.forEach(function(v) { vData[v] = {}; });
|
||||
series.forEach(function(r) {
|
||||
if (r.source_vendor && vData[r.source_vendor] !== undefined && r.day)
|
||||
vData[r.source_vendor][r.day.slice(0,10)] = parseFloat(r.price_avg || r.price_min || 0);
|
||||
});
|
||||
var allP = [];
|
||||
vendorList.forEach(function(v) { Object.values(vData[v]).forEach(function(p) { allP.push(p); }); });
|
||||
var pMin = Math.min.apply(null,allP), pMax = Math.max.apply(null,allP), pRange = pMax-pMin||1;
|
||||
var W=620,H=200,PAD={l:52,r:12,t:10,b:36};
|
||||
var iW=W-PAD.l-PAD.r, iH=H-PAD.t-PAD.b;
|
||||
function xOf(i){ return PAD.l+(dates.length<2?iW/2:i/(dates.length-1)*iW); }
|
||||
function yOf(p){ return PAD.t+iH-((p-pMin)/pRange*iH); }
|
||||
var currency = series[0] ? (series[0].currency||'') : '';
|
||||
var gridSvg='';
|
||||
for(var gi=0;gi<=4;gi++){
|
||||
var gy=PAD.t+gi*iH/4, gv=pMax-gi*pRange/4;
|
||||
gridSvg+='<line x1="'+PAD.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-PAD.r)+'" y2="'+gy.toFixed(1)+'" stroke="var(--border)" stroke-width="1"/>';
|
||||
gridSvg+='<text x="'+(PAD.l-5)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="9" fill="var(--text-dim)">'+(gv>=1000?(gv/1000).toFixed(1)+'k':gv.toFixed(0))+'</text>';
|
||||
}
|
||||
var xLabels='';
|
||||
var nXL=Math.min(6,dates.length);
|
||||
for(var xi=0;xi<nXL;xi++){
|
||||
var didx=Math.round(xi*(dates.length-1)/Math.max(nXL-1,1));
|
||||
xLabels+='<text x="'+xOf(didx).toFixed(1)+'" y="'+(H-4)+'" text-anchor="middle" font-size="9" fill="var(--text-dim)">'+dates[didx].slice(5)+'</text>';
|
||||
}
|
||||
var linesSvg='';
|
||||
vendorList.forEach(function(v,vi){
|
||||
var pts=dates.map(function(d3,di){ var p=vData[v][d3]; return p!==undefined?(xOf(di).toFixed(1)+','+yOf(p).toFixed(1)):null; }).filter(Boolean);
|
||||
if(!pts.length) return;
|
||||
linesSvg+='<polyline points="'+pts.join(' ')+'" fill="none" stroke="'+COLORS[vi%COLORS.length]+'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||
var lp=pts[pts.length-1].split(',');
|
||||
linesSvg+='<circle cx="'+lp[0]+'" cy="'+lp[1]+'" r="3" fill="'+COLORS[vi%COLORS.length]+'"/>';
|
||||
});
|
||||
var legend=vendorList.map(function(v,vi){
|
||||
var lp=Object.values(vData[v]).slice(-1)[0];
|
||||
return '<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.68rem;margin-right:0.6rem">'
|
||||
+'<span style="display:inline-block;width:12px;height:3px;background:'+COLORS[vi%COLORS.length]+';border-radius:2px"></span>'
|
||||
+esc(v)+(lp?' '+lp.toFixed(0)+' '+currency:'')+'</span>';
|
||||
}).join('');
|
||||
var bestHtml='';
|
||||
if(cur.length) bestHtml='<div style="margin-top:0.6rem;font-size:0.72rem;color:var(--text-dim)">Best now: '
|
||||
+cur.slice(0,3).map(function(c){ return '<b style="color:var(--text)">'+c.best_price+' '+c.currency+'</b> @ '+esc(c.source_vendor); }).join(' · ')+'</div>';
|
||||
document.getElementById('ph-body').innerHTML=
|
||||
'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;display:block">'+gridSvg+linesSvg+xLabels
|
||||
+'<text x="'+(W/2)+'" y="'+(H-2)+'" text-anchor="middle" font-size="9" fill="var(--text-dim)">'+currency+'</text></svg>'
|
||||
+'<div style="margin-top:0.4rem;line-height:1.8">'+legend+'</div>'+bestHtml;
|
||||
} catch(e) {
|
||||
var b=document.getElementById('ph-body');
|
||||
if(b) b.innerHTML='<div style="color:var(--text-dim);padding:1rem">Error: '+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
/* ── END PRICE HISTORY CHART ─────────────────────────────────────────── */
|
||||
</script>
|
||||
<script src="/dashboard/hot-topics.js"></script>
|
||||
</body>
|
||||
|
||||
@ -25,6 +25,7 @@ import { registerContentTools } from "./tools/content.js";
|
||||
import { registerMarketTools } from "./tools/market.js";
|
||||
import { registerSwitchDocTools } from "./tools/switch-docs.js";
|
||||
import { finderTools, handleFinderTool } from "./tools/finder.js";
|
||||
import { registerEquivalencesTools } from "./tools/equivalences.js";
|
||||
|
||||
async function main() {
|
||||
const server = new McpServer({
|
||||
@ -350,6 +351,7 @@ async function main() {
|
||||
await registerContentTools(server);
|
||||
await registerMarketTools(server);
|
||||
await registerSwitchDocTools(server);
|
||||
await registerEquivalencesTools(server);
|
||||
|
||||
// --- Register finder.ts tools (find_flexoptix_for_switch, get_competitor_alerts) ---
|
||||
for (const [toolName, toolDef] of Object.entries(finderTools)) {
|
||||
@ -374,7 +376,7 @@ async function main() {
|
||||
// --- Ollama-compatible LLM tools: market analysis (TIP_LLM) + blog generation (FO_BlogLLM) ---
|
||||
const OLLAMA_BASE = process.env["OLLAMA_BASE_URL"] ?? "https://ollama.fichtmueller.org";
|
||||
const TIP_LLM_MODEL = process.env["TIP_LLM_MODEL"] ?? "tip-llm-v1";
|
||||
const BLOG_LLM_MODEL = process.env["BLOG_LLM_MODEL"] ?? "fo-blog-v7";
|
||||
const BLOG_LLM_MODEL = process.env["BLOG_LLM_MODEL"] ?? "fo-blog-v10";
|
||||
const BLOG_LLM_FALLBACK = process.env["BLOG_LLM_FALLBACK_MODEL"] ?? "qwen2.5:14b";
|
||||
|
||||
server.tool(
|
||||
|
||||
217
packages/mcp-server/src/tools/equivalences.ts
Normal file
217
packages/mcp-server/src/tools/equivalences.ts
Normal file
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Equivalences & price-history tools: find_equivalences, get_price_history
|
||||
*/
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { pool } from "../db.js";
|
||||
|
||||
export async function registerEquivalencesTools(server: McpServer): Promise<void> {
|
||||
// --- Tool: find_equivalences ---
|
||||
server.tool(
|
||||
"find_equivalences",
|
||||
`Find Flexoptix equivalent transceivers for a competitor product (or vice-versa).
|
||||
Uses the TIP equivalences database (63k+ verified cross-brand mappings, 93.9% avg confidence).
|
||||
Example: "What Flexoptix alternative exists for Cisco GLC-LH-SMD?" → Returns Flexoptix part numbers, pricing, specs, and confidence.`,
|
||||
{
|
||||
part_number: z.string().describe("Competitor or Flexoptix part number, e.g. 'GLC-LH-SMD', 'SFP-10G-LR', 'QSFP-100G-PSM4'"),
|
||||
vendor: z.string().optional().describe("Vendor filter, e.g. 'Cisco', 'Juniper', 'FS.COM'. Leave empty to search all vendors."),
|
||||
min_confidence: z.number().min(0).max(1).default(0.8).describe("Minimum confidence threshold (0–1). Default 0.8."),
|
||||
max_results: z.number().default(10).describe("Maximum results to return"),
|
||||
},
|
||||
async ({ part_number, vendor, min_confidence, max_results }) => {
|
||||
const conditions = [
|
||||
"e.status IN ('approved', 'auto_approved')",
|
||||
`e.confidence >= $1`,
|
||||
`(cx.part_number ILIKE $2 OR cx.standard_name ILIKE $2 OR fx.part_number ILIKE $2 OR fx.standard_name ILIKE $2)`,
|
||||
];
|
||||
const values: unknown[] = [min_confidence, `%${part_number}%`];
|
||||
let idx = 3;
|
||||
|
||||
if (vendor) {
|
||||
conditions.push(`cv.name ILIKE $${idx}`);
|
||||
values.push(`%${vendor}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
e.confidence,
|
||||
e.match_basis,
|
||||
e.status,
|
||||
-- Flexoptix product
|
||||
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.fiber_type AS flexoptix_fiber,
|
||||
fx.market_status AS flexoptix_market_status,
|
||||
fx.price_verified_eur AS flexoptix_price_eur,
|
||||
fx.product_page_url AS flexoptix_url,
|
||||
-- Competitor product
|
||||
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.price_verified_eur AS competitor_price_eur,
|
||||
cv.name AS competitor_vendor
|
||||
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 ${conditions.join(" AND ")}
|
||||
ORDER BY e.confidence DESC
|
||||
LIMIT $${idx}`,
|
||||
[...values, max_results]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `No equivalences found for "${part_number}"${vendor ? ` from ${vendor}` : ""} with confidence ≥ ${min_confidence}.\n\nTry a broader search term or lower confidence threshold.`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const lines = result.rows.map((r, i) => {
|
||||
const conf = `${(parseFloat(r.confidence) * 100).toFixed(0)}%`;
|
||||
const basis = Array.isArray(r.match_basis) ? r.match_basis.join(", ") : r.match_basis;
|
||||
const fxPrice = r.flexoptix_price_eur ? `€${r.flexoptix_price_eur}` : "—";
|
||||
const compPrice = r.competitor_price_eur ? `€${r.competitor_price_eur}` : "—";
|
||||
return [
|
||||
`${i + 1}. Competitor: ${r.competitor_vendor} **${r.competitor_pn}** (${r.competitor_std || r.competitor_form_factor}, ${r.competitor_speed}, ${r.competitor_reach || "—"}) @ ${compPrice}`,
|
||||
` → Flexoptix: **${r.flexoptix_pn}** (${r.flexoptix_std || r.flexoptix_form_factor}, ${r.flexoptix_speed}, ${r.flexoptix_reach || "—"}) @ ${fxPrice} | Status: ${r.flexoptix_market_status || "—"}`,
|
||||
` Confidence: ${conf} | Match basis: ${basis}`,
|
||||
r.flexoptix_url ? ` Product page: ${r.flexoptix_url}` : "",
|
||||
].filter(Boolean).join("\n");
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `## Equivalences for "${part_number}"\n\nFound ${result.rows.length} match(es):\n\n${lines.join("\n\n")}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Tool: get_price_history ---
|
||||
server.tool(
|
||||
"get_price_history",
|
||||
`Get price history for a transceiver over time (from 392k+ price observations across 60+ competitors).
|
||||
Returns daily min/max/avg prices per source vendor for charting and trend analysis.
|
||||
Useful for: price trend analysis, sourcing decisions, identifying cheapest vendor window.`,
|
||||
{
|
||||
part_number: z.string().describe("Part number, slug, or standard name, e.g. 'QSFP-40G-SR4', '100GBASE-LR4'"),
|
||||
days: z.number().default(30).describe("Number of days of history to return (max 365)"),
|
||||
vendor: z.string().optional().describe("Filter to specific source vendor, e.g. 'FS.COM', 'Mouser'. Leave empty for all."),
|
||||
},
|
||||
async ({ part_number, days, vendor }) => {
|
||||
const daysLimited = Math.min(days, 365);
|
||||
|
||||
// Resolve transceiver
|
||||
const tx = await pool.query(
|
||||
`SELECT t.id, t.part_number, t.standard_name, t.form_factor, t.speed, v.name as vendor_name
|
||||
FROM transceivers t LEFT JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE t.slug ILIKE $1 OR t.part_number ILIKE $1 OR t.standard_name ILIKE $1
|
||||
LIMIT 1`,
|
||||
[`%${part_number}%`]
|
||||
);
|
||||
|
||||
if (tx.rows.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Transceiver not found: "${part_number}"` }],
|
||||
};
|
||||
}
|
||||
|
||||
const txRow = tx.rows[0];
|
||||
|
||||
const conditions = [
|
||||
`po.transceiver_id = $1`,
|
||||
`po.time >= NOW() - INTERVAL '${daysLimited} days'`,
|
||||
`po.price > 0`,
|
||||
`po.is_anomalous IS NOT TRUE`,
|
||||
];
|
||||
const values: unknown[] = [txRow.id];
|
||||
let idx = 2;
|
||||
|
||||
if (vendor) {
|
||||
conditions.push(`sv.name ILIKE $${idx}`);
|
||||
values.push(`%${vendor}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const series = await pool.query(
|
||||
`SELECT
|
||||
DATE_TRUNC('day', po.time) AS day,
|
||||
sv.name AS source_vendor,
|
||||
MIN(po.price)::numeric(12,2) AS price_min,
|
||||
MAX(po.price)::numeric(12,2) 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 ${conditions.join(" AND ")}
|
||||
GROUP BY DATE_TRUNC('day', po.time), sv.name, po.currency
|
||||
ORDER BY day ASC, source_vendor`,
|
||||
values
|
||||
);
|
||||
|
||||
if (series.rows.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `No price history found for "${txRow.part_number}" in the last ${daysLimited} days.`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Summarize by vendor
|
||||
const byVendor = new Map<string, { min: number; max: number; latest: number; currency: string; points: number }>();
|
||||
for (const row of series.rows) {
|
||||
const key = row.source_vendor || "Unknown";
|
||||
const cur = byVendor.get(key);
|
||||
if (!cur) {
|
||||
byVendor.set(key, { min: parseFloat(row.price_min), max: parseFloat(row.price_max), latest: parseFloat(row.price_avg), currency: row.currency, points: parseInt(row.observations) });
|
||||
} else {
|
||||
cur.min = Math.min(cur.min, parseFloat(row.price_min));
|
||||
cur.max = Math.max(cur.max, parseFloat(row.price_max));
|
||||
cur.latest = parseFloat(row.price_avg); // last in order = latest
|
||||
cur.points += parseInt(row.observations);
|
||||
}
|
||||
}
|
||||
|
||||
const vendorLines = [...byVendor.entries()]
|
||||
.sort((a, b) => a[1].min - b[1].min)
|
||||
.map(([v, d]) =>
|
||||
`- **${v}**: ${d.currency} ${d.min}–${d.max} (latest avg: ${d.latest}, ${d.points} observations)`
|
||||
);
|
||||
|
||||
const allMins = [...byVendor.values()].map(d => d.min);
|
||||
const overallMin = Math.min(...allMins);
|
||||
const overallMax = Math.max(...[...byVendor.values()].map(d => d.max));
|
||||
const cheapestVendor = [...byVendor.entries()].sort((a, b) => a[1].min - b[1].min)[0];
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: [
|
||||
`## Price History: ${txRow.part_number} (${txRow.vendor_name || "—"})`,
|
||||
`**Standard:** ${txRow.standard_name || "—"} | **Form factor:** ${txRow.form_factor} | **Speed:** ${txRow.speed}`,
|
||||
`**Period:** Last ${daysLimited} days | **Total observations:** ${series.rows.reduce((s, r) => s + parseInt(r.observations), 0)}`,
|
||||
``,
|
||||
`### Price Range (all vendors)`,
|
||||
`- Overall min: **${overallMin}** | max: **${overallMax}**`,
|
||||
`- Cheapest source: **${cheapestVendor[0]}** @ ${cheapestVendor[1].min}`,
|
||||
``,
|
||||
`### By Vendor`,
|
||||
...vendorLines,
|
||||
].join("\n"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -33,7 +33,10 @@
|
||||
"scrape:optcore": "tsx src/scrapers/optcore.ts",
|
||||
"scrape:news": "tsx src/scrapers/news.ts",
|
||||
"scrape:all": "tsx src/index.ts --all",
|
||||
"robots:verification": "tsx src/robots/verification-robots.ts"
|
||||
"robots:verification": "tsx src/robots/verification-robots.ts",
|
||||
"dqo": "tsx src/orchestrator/index.ts",
|
||||
"dqo:once": "tsx src/orchestrator/index.ts --once",
|
||||
"dqo:test": "node --import tsx --test src/orchestrator/self-heal-guards.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"crawlee": "^3.12.0",
|
||||
|
||||
@ -69,7 +69,7 @@ export interface CrawlExtraction {
|
||||
}
|
||||
|
||||
export interface RobotExperience {
|
||||
event: "status" | "tipllm_plan" | "queue_plan" | "queue_enqueued" | "queue_rejected" | "crawler_result";
|
||||
event: "status" | "tipllm_plan" | "queue_plan" | "queue_enqueued" | "queue_rejected" | "crawler_result" | "dqo_dispatch" | "dqo_verify" | "dqo_escalation";
|
||||
observed_at: string;
|
||||
actor: string;
|
||||
profile?: string;
|
||||
|
||||
394
packages/scraper/src/orchestrator/control-loop.ts
Normal file
394
packages/scraper/src/orchestrator/control-loop.ts
Normal file
@ -0,0 +1,394 @@
|
||||
/**
|
||||
* tip-dqo control loop — the closed loop TIP was missing.
|
||||
*
|
||||
* DETECT read v_dqo_targets (v_scraper_health joined onto source_registry) — DATA
|
||||
* freshness of the observation TABLES, never pg-boss job cadence.
|
||||
* PRIORITIZE the view is pre-ranked (dead>stale, weighted); split staleness vs
|
||||
* code_fix/blocked. Only 'staleness' is dispatch-eligible.
|
||||
* DISPATCH guardrails (self-heal-guards) + plan-mode + profile cap, then the shared
|
||||
* verification-robots dispatchQueues() — exactly one boss.send path.
|
||||
* VERIFY re-read the source's freshness for pending dispatch rows: did fresh rows
|
||||
* land? healed / ineffective(+strike). Catches completed-but-wrote-0 jobs.
|
||||
* ESCALATE fresh=silent, stale/1-2 strikes=digest, breaker/dead=page (delta-gated by
|
||||
* acknowledged_exceptions and once-per-24h so the day-1 backlog never spams).
|
||||
*
|
||||
* INVARIANT: VERIFY + DETECT key off observation-table freshness (v_scraper_health), NOT
|
||||
* pgboss.job='completed' — that is the exact blind spot that lets a green-queue/zero-row
|
||||
* scraper look healthy. Do not "optimize" this to read pgboss.job.
|
||||
*/
|
||||
import { Pool } from "pg";
|
||||
import { loadavg } from "os";
|
||||
import { requireDbPassword } from "../utils/db-connection";
|
||||
import {
|
||||
dispatchQueues,
|
||||
queuesForProfile,
|
||||
defaultMaxQueues,
|
||||
type RobotProfile,
|
||||
} from "../robots/verification-robots";
|
||||
import { canDispatch, type GuardConfig, type LedgerFacts } from "./self-heal-guards";
|
||||
import { notify } from "./escalation-sink";
|
||||
import { writeRobotExperience } from "../crawler-llm/training-data-writer";
|
||||
|
||||
const PLAN_ONLY = process.env.SELF_HEAL_PLAN_ONLY !== "false"; // default true — dispatches NOTHING
|
||||
const MAX_LOAD = Number.parseFloat(process.env.DQO_MAX_LOAD || "2.5");
|
||||
const SETTLE_MINUTES = Number.parseInt(process.env.DQO_VERIFY_SETTLE_MINUTES || "20", 10);
|
||||
const ESCALATE_REPAGE_HOURS = 24;
|
||||
|
||||
let poolSingleton: Pool | null = null;
|
||||
function getPool(): Pool {
|
||||
if (!poolSingleton) {
|
||||
poolSingleton = new Pool({
|
||||
host: process.env.POSTGRES_HOST || "localhost",
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5433"),
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
password: requireDbPassword(),
|
||||
max: 2, // read-mostly; kept small so the orchestrator never contends the daemon pool
|
||||
idleTimeoutMillis: 10_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
});
|
||||
}
|
||||
return poolSingleton;
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
if (poolSingleton) {
|
||||
await poolSingleton.end();
|
||||
poolSingleton = null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Target {
|
||||
source_key: string;
|
||||
vendor: string;
|
||||
source_table: string;
|
||||
status: string;
|
||||
days_since_last: number;
|
||||
rows_30d: number;
|
||||
last_observation: string | null;
|
||||
root_cause_class: "staleness" | "code_fix" | "blocked";
|
||||
queue_names: string[];
|
||||
profile: RobotProfile;
|
||||
dead_days: number;
|
||||
cooldown_minutes: number;
|
||||
budget_per_day: number;
|
||||
max_backoff_hours: number;
|
||||
circuit_breaker_strikes: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function guardConfig(t: Target): GuardConfig {
|
||||
return {
|
||||
cooldown_minutes: t.cooldown_minutes,
|
||||
budget_per_day: t.budget_per_day,
|
||||
max_backoff_hours: t.max_backoff_hours,
|
||||
circuit_breaker_strikes: t.circuit_breaker_strikes,
|
||||
max_load: MAX_LOAD,
|
||||
};
|
||||
}
|
||||
|
||||
// ── kill-switch + global daily ceiling ────────────────────────────────────────
|
||||
async function globalGate(pool: Pool): Promise<{ armed: boolean; reason: string; globalBudget: number }> {
|
||||
const { rows } = await pool.query(
|
||||
`select enabled, budget_per_day from source_registry where source_key = '__global__'`,
|
||||
);
|
||||
if (rows.length === 0) return { armed: true, reason: "no __global__ row (default armed)", globalBudget: 9999 };
|
||||
if (!rows[0].enabled) return { armed: false, reason: "kill-switch: __global__.enabled = false", globalBudget: 0 };
|
||||
return { armed: true, reason: "armed", globalBudget: rows[0].budget_per_day };
|
||||
}
|
||||
|
||||
async function globalDispatchesToday(pool: Pool): Promise<number> {
|
||||
const { rows } = await pool.query(
|
||||
`select count(*)::int n from self_heal_dispatch
|
||||
where mode = 'dispatch' and dispatched_at >= date_trunc('day', now())`,
|
||||
);
|
||||
return rows[0]?.n ?? 0;
|
||||
}
|
||||
|
||||
// ── DETECT ────────────────────────────────────────────────────────────────────
|
||||
export async function detect(pool: Pool): Promise<Target[]> {
|
||||
const { rows } = await pool.query(`select * from v_dqo_targets`);
|
||||
return rows as Target[];
|
||||
}
|
||||
|
||||
async function ledgerFacts(pool: Pool, sourceKey: string): Promise<LedgerFacts> {
|
||||
const { rows } = await pool.query(
|
||||
`select
|
||||
(select max(dispatched_at) from self_heal_dispatch where source_key = $1 and mode = 'dispatch') as last_dispatch_at,
|
||||
(select coalesce(strikes, 0) from self_heal_dispatch where source_key = $1 and mode = 'dispatch' order by dispatched_at desc limit 1) as last_strikes,
|
||||
(select count(*)::int from self_heal_dispatch where source_key = $1 and mode = 'dispatch' and dispatched_at >= date_trunc('day', now())) as dispatches_today`,
|
||||
[sourceKey],
|
||||
);
|
||||
const r = rows[0] ?? {};
|
||||
return {
|
||||
last_dispatch_at: r.last_dispatch_at ? new Date(r.last_dispatch_at) : null,
|
||||
last_strikes: Number(r.last_strikes ?? 0),
|
||||
dispatches_today: Number(r.dispatches_today ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function insertLedger(
|
||||
pool: Pool,
|
||||
row: {
|
||||
source_key: string;
|
||||
queues: string[];
|
||||
reason: string;
|
||||
mode: "plan" | "dispatch";
|
||||
rows_before: number | null;
|
||||
last_obs_before: string | null;
|
||||
outcome: "pending" | "skipped" | "escalated";
|
||||
strikes: number;
|
||||
run_id: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`insert into self_heal_dispatch
|
||||
(source_key, queues, reason, mode, rows_before, last_obs_before, outcome, strikes, run_id)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[row.source_key, row.queues, row.reason, row.mode, row.rows_before, row.last_obs_before, row.outcome, row.strikes, row.run_id],
|
||||
);
|
||||
}
|
||||
|
||||
// ── DISPATCH (one self-heal tick) ─────────────────────────────────────────────
|
||||
export interface TickResult {
|
||||
planOnly: boolean;
|
||||
detected: number;
|
||||
planned: number;
|
||||
dispatched: number;
|
||||
skipped: number;
|
||||
escalated: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export async function runTick(): Promise<TickResult> {
|
||||
const pool = getPool();
|
||||
const runId = `dqo-self-heal-${new Date().toISOString()}`;
|
||||
const load1 = loadavg()[0];
|
||||
const res: TickResult = { planOnly: PLAN_ONLY, detected: 0, planned: 0, dispatched: 0, skipped: 0, escalated: 0, lines: [] };
|
||||
|
||||
const gate = await globalGate(pool);
|
||||
if (!gate.armed) {
|
||||
res.lines.push(`[HALT] ${gate.reason}`);
|
||||
return res;
|
||||
}
|
||||
const globalToday = await globalDispatchesToday(pool);
|
||||
const globalRemaining = gate.globalBudget - globalToday;
|
||||
|
||||
const targets = await detect(pool);
|
||||
res.detected = targets.length;
|
||||
if (targets.length === 0) {
|
||||
res.lines.push("no stale/dead sources — nothing to do");
|
||||
return res;
|
||||
}
|
||||
|
||||
for (const t of targets) {
|
||||
// Root-cause routing: only staleness is auto-dispatched. code_fix/blocked escalate.
|
||||
if (t.root_cause_class !== "staleness") {
|
||||
await escalateSource(pool, t, `root_cause=${t.root_cause_class} (${t.status} ${t.days_since_last}d) — a re-scrape cannot fix this; human required`, runId);
|
||||
res.escalated++;
|
||||
res.lines.push(` ESCALATE ${t.source_key} — ${t.root_cause_class} (${t.status} ${t.days_since_last}d)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A dead staleness source past dead_days also escalates (retrying won't help a truly dead source).
|
||||
if (t.status === "dead" && t.days_since_last > t.dead_days) {
|
||||
await escalateSource(pool, t, `dead ${t.days_since_last}d > dead_days ${t.dead_days} — escalating`, runId);
|
||||
res.escalated++;
|
||||
res.lines.push(` ESCALATE ${t.source_key} — dead ${t.days_since_last}d`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const facts = await ledgerFacts(pool, t.source_key);
|
||||
const verdict = canDispatch(facts, guardConfig(t), new Date(), load1);
|
||||
|
||||
// Profile filter + cap on the queues.
|
||||
const eligible = queuesForProfile(t.queue_names, t.profile).slice(0, defaultMaxQueues(t.profile));
|
||||
|
||||
if (eligible.length === 0) {
|
||||
await insertLedger(pool, {
|
||||
source_key: t.source_key, queues: [], reason: `no queue eligible under profile '${t.profile}'`,
|
||||
mode: "plan", rows_before: t.rows_30d, last_obs_before: t.last_observation, outcome: "skipped", strikes: facts.last_strikes, run_id: runId,
|
||||
});
|
||||
res.skipped++;
|
||||
res.lines.push(` SKIP ${t.source_key} — no queue eligible under '${t.profile}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!verdict.allow) {
|
||||
await insertLedger(pool, {
|
||||
source_key: t.source_key, queues: eligible, reason: `guard: ${verdict.reason}`,
|
||||
mode: "plan", rows_before: t.rows_30d, last_obs_before: t.last_observation, outcome: "skipped", strikes: facts.last_strikes, run_id: runId,
|
||||
});
|
||||
res.skipped++;
|
||||
res.lines.push(` SKIP ${t.source_key} — ${verdict.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PLAN_ONLY || globalRemaining <= 0) {
|
||||
const why = PLAN_ONLY ? "plan-mode (SELF_HEAL_PLAN_ONLY)" : `global budget exhausted (${globalToday}/${gate.globalBudget})`;
|
||||
await insertLedger(pool, {
|
||||
source_key: t.source_key, queues: eligible, reason: `WOULD DISPATCH → ${eligible.join(", ")} [${why}]`,
|
||||
mode: "plan", rows_before: t.rows_30d, last_obs_before: t.last_observation, outcome: "pending", strikes: facts.last_strikes, run_id: runId,
|
||||
});
|
||||
res.planned++;
|
||||
res.lines.push(` PLAN ${t.source_key} → ${eligible.join(", ")} [${why}]`);
|
||||
writeRobotExperience({
|
||||
event: "dqo_dispatch", observed_at: new Date().toISOString(), actor: "tip-dqo",
|
||||
summary: `Plan: would dispatch ${t.source_key} (${t.status} ${t.days_since_last}d)`,
|
||||
input: { source_key: t.source_key, status: t.status, days_since_last: t.days_since_last, eligible, why },
|
||||
decision: { mode: "plan", dispatched: false }, safety_notes: ["Plan mode — no jobs enqueued."],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// ARMED: real dispatch.
|
||||
await dispatchQueues(eligible, { source: "dqo:self-heal", source_key: t.source_key, run_id: runId, reason: `${t.status} ${t.days_since_last}d`, enqueued_at: new Date().toISOString() });
|
||||
await insertLedger(pool, {
|
||||
source_key: t.source_key, queues: eligible, reason: `dispatched (${t.status} ${t.days_since_last}d)`,
|
||||
mode: "dispatch", rows_before: t.rows_30d, last_obs_before: t.last_observation, outcome: "pending", strikes: facts.last_strikes, run_id: runId,
|
||||
});
|
||||
res.dispatched++;
|
||||
res.lines.push(` DISPATCH ${t.source_key} → ${eligible.join(", ")}`);
|
||||
writeRobotExperience({
|
||||
event: "dqo_dispatch", observed_at: new Date().toISOString(), actor: "tip-dqo",
|
||||
summary: `Dispatched ${t.source_key} (${t.status} ${t.days_since_last}d)`,
|
||||
input: { source_key: t.source_key, eligible }, decision: { mode: "dispatch", dispatched: true },
|
||||
safety_notes: ["Armed dispatch within guardrails."],
|
||||
}, { flush: true });
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── VERIFY (one verify tick) ──────────────────────────────────────────────────
|
||||
export interface VerifyResult {
|
||||
checked: number;
|
||||
healed: number;
|
||||
ineffective: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export async function runVerifyTick(): Promise<VerifyResult> {
|
||||
const pool = getPool();
|
||||
const res: VerifyResult = { checked: 0, healed: 0, ineffective: 0, lines: [] };
|
||||
|
||||
// pending REAL dispatches past the settle window
|
||||
const { rows: pending } = await pool.query(
|
||||
`select d.id, d.source_key, d.rows_before, d.last_obs_before, d.strikes, d.run_id,
|
||||
sr.vendor, sr.source_table, sr.dead_days, sr.circuit_breaker_strikes
|
||||
from self_heal_dispatch d
|
||||
join source_registry sr on sr.source_key = d.source_key
|
||||
where d.mode = 'dispatch' and d.outcome = 'pending'
|
||||
and d.dispatched_at < now() - ($1 || ' minutes')::interval`,
|
||||
[String(SETTLE_MINUTES)],
|
||||
);
|
||||
// No pending dispatches → skip the expensive v_scraper_health scan entirely.
|
||||
if (pending.length === 0) return res;
|
||||
|
||||
// Compute the (expensive) freshness view ONCE and index it, instead of re-running the
|
||||
// whole aggregate per pending source. Keyed off observation TABLES, never pgboss.job.
|
||||
const { rows: healthRows } = await pool.query(
|
||||
`select vendor, source_table, rows_30d, last_observation from v_scraper_health`,
|
||||
);
|
||||
const health = new Map<string, { rows_30d: number | null; last_observation: string | null }>();
|
||||
for (const hr of healthRows) {
|
||||
health.set(`${hr.vendor}|${hr.source_table}`, { rows_30d: hr.rows_30d, last_observation: hr.last_observation });
|
||||
}
|
||||
|
||||
for (const p of pending) {
|
||||
res.checked++;
|
||||
const h = health.get(`${p.vendor}|${p.source_table}`);
|
||||
const rowsAfter: number | null = h?.rows_30d ?? null;
|
||||
const lastObsAfter: string | null = h?.last_observation ?? null;
|
||||
const advanced = lastObsAfter && p.last_obs_before && new Date(lastObsAfter) > new Date(p.last_obs_before);
|
||||
const grew = rowsAfter != null && p.rows_before != null && rowsAfter > p.rows_before;
|
||||
|
||||
if (advanced || grew) {
|
||||
await pool.query(
|
||||
`update self_heal_dispatch set outcome='healed', checked_at=now(), rows_after=$2, last_obs_after=$3, strikes=0 where id=$1`,
|
||||
[p.id, rowsAfter, lastObsAfter],
|
||||
);
|
||||
res.healed++;
|
||||
res.lines.push(` HEALED ${p.source_key} (rows ${p.rows_before}→${rowsAfter})`);
|
||||
writeRobotExperience({
|
||||
event: "dqo_verify", observed_at: new Date().toISOString(), actor: "tip-dqo",
|
||||
summary: `Healed ${p.source_key}`, input: { source_key: p.source_key, rows_before: p.rows_before, rows_after: rowsAfter },
|
||||
decision: { outcome: "healed" },
|
||||
});
|
||||
} else {
|
||||
const newStrikes = Number(p.strikes ?? 0) + 1;
|
||||
await pool.query(
|
||||
`update self_heal_dispatch set outcome='ineffective', checked_at=now(), rows_after=$2, last_obs_after=$3, strikes=$4 where id=$1`,
|
||||
[p.id, rowsAfter, lastObsAfter, newStrikes],
|
||||
);
|
||||
res.ineffective++;
|
||||
res.lines.push(` INEFFECTIVE ${p.source_key} (no new rows; strikes→${newStrikes})`);
|
||||
writeRobotExperience({
|
||||
event: "dqo_verify", observed_at: new Date().toISOString(), actor: "tip-dqo",
|
||||
summary: `Ineffective dispatch ${p.source_key} — no fresh rows landed`,
|
||||
input: { source_key: p.source_key, rows_before: p.rows_before, rows_after: rowsAfter, strikes: newStrikes },
|
||||
decision: { outcome: "ineffective" },
|
||||
});
|
||||
if (newStrikes >= Number(p.circuit_breaker_strikes ?? 3)) {
|
||||
await escalateByKey(pool, p.source_key, `circuit-breaker open: ${newStrikes} ineffective dispatches — retrying wastes resources, human required`, p.run_id);
|
||||
res.lines.push(` ESCALATE ${p.source_key} — breaker open (${newStrikes} strikes)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── ESCALATE (delta-gated, once-per-24h per source) ───────────────────────────
|
||||
async function alreadyEscalatedRecently(pool: Pool, sourceKey: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`select 1 from self_heal_dispatch
|
||||
where source_key = $1 and outcome = 'escalated'
|
||||
and dispatched_at > now() - ($2 || ' hours')::interval limit 1`,
|
||||
[sourceKey, String(ESCALATE_REPAGE_HOURS)],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function isAcknowledged(pool: Pool, sourceKey: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`select 1 from acknowledged_exceptions
|
||||
where (source_key = $1 or source_key is null)
|
||||
and (ack_until is null or ack_until > now()) limit 1`,
|
||||
[sourceKey],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function escalateSource(pool: Pool, t: Target, reason: string, runId: string): Promise<void> {
|
||||
await escalateByKey(pool, t.source_key, reason, runId, { rows: t.rows_30d, lastObs: t.last_observation, status: t.status });
|
||||
}
|
||||
|
||||
async function escalateByKey(
|
||||
pool: Pool,
|
||||
sourceKey: string,
|
||||
reason: string,
|
||||
runId: string,
|
||||
ctx?: { rows?: number; lastObs?: string | null; status?: string },
|
||||
): Promise<void> {
|
||||
// delta-gate: suppress if acknowledged, and never re-page the same source within 24h
|
||||
if (await isAcknowledged(pool, sourceKey)) return;
|
||||
if (await alreadyEscalatedRecently(pool, sourceKey)) return;
|
||||
|
||||
await insertLedger(pool, {
|
||||
source_key: sourceKey, queues: [], reason, mode: "plan",
|
||||
rows_before: ctx?.rows ?? null, last_obs_before: ctx?.lastObs ?? null, outcome: "escalated", strikes: 0, run_id: runId,
|
||||
});
|
||||
await notify("page", `TIP data-quality: ${sourceKey}`, reason);
|
||||
writeRobotExperience({
|
||||
event: "dqo_escalation", observed_at: new Date().toISOString(), actor: "tip-dqo",
|
||||
summary: `Escalated ${sourceKey}: ${reason}`, input: { source_key: sourceKey, ...ctx },
|
||||
decision: { escalated: true }, safety_notes: ["Paged once; suppressed for 24h + honors acknowledged_exceptions."],
|
||||
}, { flush: true });
|
||||
}
|
||||
|
||||
export async function runOnce(): Promise<{ tick: TickResult; verify: VerifyResult }> {
|
||||
const tick = await runTick();
|
||||
const verify = await runVerifyTick();
|
||||
return { tick, verify };
|
||||
}
|
||||
53
packages/scraper/src/orchestrator/escalation-sink.ts
Normal file
53
packages/scraper/src/orchestrator/escalation-sink.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Notification sink for the tip-dqo escalation ladder.
|
||||
*
|
||||
* Default channel: self-hosted ntfy on Erik (no external secret, fits "Gitea = EVERYTHING").
|
||||
* Set NTFY_URL (e.g. https://ntfy.fichtmueller.org) and optionally NTFY_TOPIC (default
|
||||
* "tip-dqo"). If NTFY_URL is unset, notify() is a logged no-op so the loop still runs and
|
||||
* the escalation is recorded in the ledger — the channel can be wired later without a
|
||||
* code change.
|
||||
*/
|
||||
|
||||
export type EscalationLevel = "digest" | "page";
|
||||
|
||||
export interface NotifyResult {
|
||||
sent: boolean;
|
||||
channel: "ntfy" | "noop" | "error";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export async function notify(
|
||||
level: EscalationLevel,
|
||||
title: string,
|
||||
body: string,
|
||||
): Promise<NotifyResult> {
|
||||
const base = process.env.NTFY_URL?.replace(/\/+$/, "");
|
||||
const topic = process.env.NTFY_TOPIC || "tip-dqo";
|
||||
|
||||
if (!base) {
|
||||
console.warn(`[dqo:escalation:noop] (${level}) ${title} — ${body} [set NTFY_URL to enable]`);
|
||||
return { sent: false, channel: "noop" };
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${base}/${topic}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Title: title,
|
||||
Priority: level === "page" ? "high" : "default",
|
||||
Tags: level === "page" ? "rotating_light" : "warning",
|
||||
...(process.env.NTFY_TOKEN ? { Authorization: `Bearer ${process.env.NTFY_TOKEN}` } : {}),
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return { sent: false, channel: "error", detail: `HTTP ${resp.status}` };
|
||||
}
|
||||
return { sent: true, channel: "ntfy" };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[dqo:escalation] ntfy failed: ${detail}`);
|
||||
return { sent: false, channel: "error", detail };
|
||||
}
|
||||
}
|
||||
85
packages/scraper/src/orchestrator/index.ts
Normal file
85
packages/scraper/src/orchestrator/index.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* tip-dqo — TIP Autonomous Data-Quality Orchestrator (entrypoint).
|
||||
*
|
||||
* A SEPARATE always-on process (PM2 app `tip-dqo`), deliberately NOT grafted into the
|
||||
* tip-scraper-daemon event loop, so its blast radius is isolated and it never contends
|
||||
* the daemon's pg Pool. Read-mostly: its only writes are to its own ledger/registry tables.
|
||||
*
|
||||
* Default (no args) : self-heal tick every DQO_SELF_HEAL_MINUTES (30), verify tick every
|
||||
* DQO_VERIFY_MINUTES (15). Runs until SIGINT/SIGTERM.
|
||||
* --once : run one self-heal + verify tick, print the plan, exit. Used to
|
||||
* PROVE the loop against the live DB without scheduling anything.
|
||||
*
|
||||
* SELF_HEAL_PLAN_ONLY defaults to "true" (dispatches NOTHING; writes plan rows a human
|
||||
* reviews). Set SELF_HEAL_PLAN_ONLY=false only to arm live dispatch.
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
import { join } from "path";
|
||||
import { runOnce, runTick, runVerifyTick, closePool } from "./control-loop";
|
||||
|
||||
config({ path: join(__dirname, "..", "..", "..", "..", ".env") });
|
||||
|
||||
const SELF_HEAL_MINUTES = Number.parseInt(process.env.DQO_SELF_HEAL_MINUTES || "30", 10);
|
||||
const VERIFY_MINUTES = Number.parseInt(process.env.DQO_VERIFY_MINUTES || "15", 10);
|
||||
const PLAN_ONLY = process.env.SELF_HEAL_PLAN_ONLY !== "false";
|
||||
|
||||
function stamp(): string {
|
||||
return new Date().toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
|
||||
async function once(): Promise<void> {
|
||||
console.log(`\n=== tip-dqo one-shot (${PLAN_ONLY ? "PLAN-ONLY" : "ARMED"}) ${stamp()} ===`);
|
||||
const { tick, verify } = await runOnce();
|
||||
console.log(`\n[self-heal] detected=${tick.detected} planned=${tick.planned} dispatched=${tick.dispatched} skipped=${tick.skipped} escalated=${tick.escalated}`);
|
||||
for (const l of tick.lines) console.log(l);
|
||||
console.log(`\n[verify] checked=${verify.checked} healed=${verify.healed} ineffective=${verify.ineffective}`);
|
||||
for (const l of verify.lines) console.log(l);
|
||||
console.log("");
|
||||
await closePool();
|
||||
}
|
||||
|
||||
async function loop(): Promise<void> {
|
||||
console.log(`=== tip-dqo starting (${PLAN_ONLY ? "PLAN-ONLY" : "ARMED"}) — self-heal ${SELF_HEAL_MINUTES}m / verify ${VERIFY_MINUTES}m ===`);
|
||||
|
||||
const selfHeal = async () => {
|
||||
try {
|
||||
const r = await runTick();
|
||||
console.log(`[${stamp()}] self-heal: detected=${r.detected} planned=${r.planned} dispatched=${r.dispatched} skipped=${r.skipped} escalated=${r.escalated}`);
|
||||
for (const l of r.lines) console.log(l);
|
||||
} catch (err) {
|
||||
console.error(`[${stamp()}] self-heal error:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
};
|
||||
const verify = async () => {
|
||||
try {
|
||||
const r = await runVerifyTick();
|
||||
if (r.checked > 0) {
|
||||
console.log(`[${stamp()}] verify: checked=${r.checked} healed=${r.healed} ineffective=${r.ineffective}`);
|
||||
for (const l of r.lines) console.log(l);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[${stamp()}] verify error:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
};
|
||||
|
||||
await selfHeal(); // run once on boot
|
||||
const t1 = setInterval(selfHeal, SELF_HEAL_MINUTES * 60_000);
|
||||
const t2 = setInterval(verify, VERIFY_MINUTES * 60_000);
|
||||
|
||||
const shutdown = async (sig: string) => {
|
||||
console.log(`\n[${stamp()}] ${sig} — shutting down tip-dqo`);
|
||||
clearInterval(t1);
|
||||
clearInterval(t2);
|
||||
await closePool().catch(() => {});
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
const isOnce = process.argv.includes("--once");
|
||||
(isOnce ? once() : loop()).catch(async (err) => {
|
||||
console.error("tip-dqo fatal:", err);
|
||||
await closePool().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
78
packages/scraper/src/orchestrator/self-heal-guards.test.ts
Normal file
78
packages/scraper/src/orchestrator/self-heal-guards.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLoadAcceptable,
|
||||
minIntervalOk,
|
||||
budgetOk,
|
||||
backoffElapsed,
|
||||
breakerTripped,
|
||||
canDispatch,
|
||||
type GuardConfig,
|
||||
type LedgerFacts,
|
||||
} from "./self-heal-guards";
|
||||
|
||||
const CFG: GuardConfig = {
|
||||
cooldown_minutes: 180,
|
||||
budget_per_day: 4,
|
||||
max_backoff_hours: 24,
|
||||
circuit_breaker_strikes: 3,
|
||||
};
|
||||
const NOW = new Date("2026-07-06T12:00:00Z");
|
||||
const fresh: LedgerFacts = { last_dispatch_at: null, last_strikes: 0, dispatches_today: 0 };
|
||||
|
||||
test("load guard", () => {
|
||||
assert.equal(isLoadAcceptable(1.2).allow, true);
|
||||
assert.equal(isLoadAcceptable(3.0).allow, false);
|
||||
assert.equal(isLoadAcceptable(2.5).allow, true); // boundary: not greater than
|
||||
});
|
||||
|
||||
test("min-interval blocks within cooldown, allows after", () => {
|
||||
const recent = { ...fresh, last_dispatch_at: new Date("2026-07-06T11:00:00Z") }; // 60min ago
|
||||
assert.equal(minIntervalOk(recent, CFG, NOW).allow, false);
|
||||
const old = { ...fresh, last_dispatch_at: new Date("2026-07-06T08:00:00Z") }; // 240min ago
|
||||
assert.equal(minIntervalOk(old, CFG, NOW).allow, true);
|
||||
assert.equal(minIntervalOk(fresh, CFG, NOW).allow, true); // never dispatched
|
||||
});
|
||||
|
||||
test("daily budget", () => {
|
||||
assert.equal(budgetOk({ ...fresh, dispatches_today: 3 }, CFG).allow, true);
|
||||
assert.equal(budgetOk({ ...fresh, dispatches_today: 4 }, CFG).allow, false);
|
||||
assert.equal(budgetOk({ ...fresh, dispatches_today: 9 }, CFG).allow, false);
|
||||
});
|
||||
|
||||
test("exponential backoff scales with strikes and caps", () => {
|
||||
// 2 strikes → 4h backoff. Dispatched 1h ago → still backing off.
|
||||
const s2recent = { last_dispatch_at: new Date("2026-07-06T11:00:00Z"), last_strikes: 2, dispatches_today: 0 };
|
||||
assert.equal(backoffElapsed(s2recent, CFG, NOW).allow, false);
|
||||
// 2 strikes → 4h backoff. Dispatched 5h ago → elapsed.
|
||||
const s2old = { last_dispatch_at: new Date("2026-07-06T07:00:00Z"), last_strikes: 2, dispatches_today: 0 };
|
||||
assert.equal(backoffElapsed(s2old, CFG, NOW).allow, true);
|
||||
// 0 strikes → no backoff
|
||||
assert.equal(backoffElapsed(fresh, CFG, NOW).allow, true);
|
||||
// huge strikes → capped at max_backoff_hours (24h), dispatched 25h ago → elapsed
|
||||
const capped = { last_dispatch_at: new Date("2026-07-05T11:00:00Z"), last_strikes: 10, dispatches_today: 0 };
|
||||
assert.equal(backoffElapsed(capped, CFG, NOW).allow, true);
|
||||
});
|
||||
|
||||
test("circuit breaker opens at threshold", () => {
|
||||
assert.equal(breakerTripped({ ...fresh, last_strikes: 2 }, CFG).allow, true);
|
||||
assert.equal(breakerTripped({ ...fresh, last_strikes: 3 }, CFG).allow, false);
|
||||
assert.equal(breakerTripped({ ...fresh, last_strikes: 5 }, CFG).allow, false);
|
||||
});
|
||||
|
||||
test("canDispatch: clean source under low load passes", () => {
|
||||
assert.equal(canDispatch(fresh, CFG, NOW, 1.0).allow, true);
|
||||
});
|
||||
|
||||
test("canDispatch: breaker is reported before other brakes", () => {
|
||||
const tripped = { last_dispatch_at: new Date("2026-07-06T11:59:00Z"), last_strikes: 3, dispatches_today: 9 };
|
||||
const v = canDispatch(tripped, CFG, NOW, 5.0);
|
||||
assert.equal(v.allow, false);
|
||||
assert.match(v.reason, /breaker OPEN/);
|
||||
});
|
||||
|
||||
test("canDispatch: high load blocks an otherwise-clean source", () => {
|
||||
const v = canDispatch(fresh, CFG, NOW, 9.0);
|
||||
assert.equal(v.allow, false);
|
||||
assert.match(v.reason, /load/);
|
||||
});
|
||||
126
packages/scraper/src/orchestrator/self-heal-guards.ts
Normal file
126
packages/scraper/src/orchestrator/self-heal-guards.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Cost-safety guardrails for the tip-dqo self-heal loop — PURE, network-free, unit-testable.
|
||||
*
|
||||
* The controller must NEVER hammer a source. These functions turn ledger facts +
|
||||
* the source's guardrail config into a typed {allow, reason} verdict, so every skip is
|
||||
* explainable and logged (never a silent no-op). The DB reads live in control-loop.ts;
|
||||
* the DECISIONS live here and are tested with node:test against fixture facts.
|
||||
*
|
||||
* Nine compounding brakes (this module implements 5 of them; the rest — kill-switch,
|
||||
* plan-mode, root-cause routing, profile cap — live in control-loop.ts):
|
||||
* min-interval · daily budget · exponential backoff · circuit-breaker · load-guard
|
||||
*/
|
||||
|
||||
export interface GuardVerdict {
|
||||
allow: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** The per-source guardrail contract, straight from source_registry. */
|
||||
export interface GuardConfig {
|
||||
cooldown_minutes: number;
|
||||
budget_per_day: number;
|
||||
max_backoff_hours: number;
|
||||
circuit_breaker_strikes: number;
|
||||
max_load?: number;
|
||||
}
|
||||
|
||||
/** Facts read from the self_heal_dispatch ledger for one source. */
|
||||
export interface LedgerFacts {
|
||||
/** dispatched_at of the most recent mode='dispatch' row, or null if never dispatched. */
|
||||
last_dispatch_at: Date | null;
|
||||
/** strikes as of the most recent verified dispatch (running no-progress count). */
|
||||
last_strikes: number;
|
||||
/** count of mode='dispatch' rows for this source since local midnight. */
|
||||
dispatches_today: number;
|
||||
}
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
const MIN_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Load guard — do not dispatch when the box is already busy. Pure: caller passes the
|
||||
* 1-minute load average (os.loadavg()[0]) so this stays testable. Mirrors the existing
|
||||
* scheduler.ts isLoadAcceptable so self-heal never fights the load guard.
|
||||
*/
|
||||
export function isLoadAcceptable(load1: number, maxLoad = 2.5): GuardVerdict {
|
||||
if (load1 > maxLoad) {
|
||||
return { allow: false, reason: `load ${load1.toFixed(2)} > ${maxLoad}` };
|
||||
}
|
||||
return { allow: true, reason: `load ${load1.toFixed(2)} <= ${maxLoad}` };
|
||||
}
|
||||
|
||||
/** Per-source minimum interval — a source cannot be re-fired within its cooldown window. */
|
||||
export function minIntervalOk(facts: LedgerFacts, cfg: GuardConfig, now: Date): GuardVerdict {
|
||||
if (!facts.last_dispatch_at) return { allow: true, reason: "no prior dispatch" };
|
||||
const elapsedMin = (now.getTime() - facts.last_dispatch_at.getTime()) / MIN_MS;
|
||||
if (elapsedMin < cfg.cooldown_minutes) {
|
||||
return {
|
||||
allow: false,
|
||||
reason: `cooldown: ${Math.round(elapsedMin)}min < ${cfg.cooldown_minutes}min since last dispatch`,
|
||||
};
|
||||
}
|
||||
return { allow: true, reason: `cooldown elapsed (${Math.round(elapsedMin)}min)` };
|
||||
}
|
||||
|
||||
/** Daily budget — no more than budget_per_day dispatches for this source since midnight. */
|
||||
export function budgetOk(facts: LedgerFacts, cfg: GuardConfig): GuardVerdict {
|
||||
if (facts.dispatches_today >= cfg.budget_per_day) {
|
||||
return {
|
||||
allow: false,
|
||||
reason: `budget: ${facts.dispatches_today}/${cfg.budget_per_day} dispatches used today`,
|
||||
};
|
||||
}
|
||||
return { allow: true, reason: `budget ${facts.dispatches_today}/${cfg.budget_per_day}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff — a repeatedly-ineffective source backs off 2^strikes hours
|
||||
* (capped at max_backoff_hours) instead of retrying every tick.
|
||||
*/
|
||||
export function backoffElapsed(facts: LedgerFacts, cfg: GuardConfig, now: Date): GuardVerdict {
|
||||
if (facts.last_strikes <= 0 || !facts.last_dispatch_at) {
|
||||
return { allow: true, reason: "no backoff (0 strikes)" };
|
||||
}
|
||||
const backoffHours = Math.min(Math.pow(2, facts.last_strikes), cfg.max_backoff_hours);
|
||||
const readyAt = new Date(facts.last_dispatch_at.getTime() + backoffHours * HOUR_MS);
|
||||
if (now < readyAt) {
|
||||
return {
|
||||
allow: false,
|
||||
reason: `backoff: ${facts.last_strikes} strikes → wait ${backoffHours}h (ready ${readyAt.toISOString()})`,
|
||||
};
|
||||
}
|
||||
return { allow: true, reason: `backoff elapsed (${backoffHours}h after ${facts.last_strikes} strikes)` };
|
||||
}
|
||||
|
||||
/** Circuit-breaker — at N strikes the source is removed from the retry pool (escalated instead). */
|
||||
export function breakerTripped(facts: LedgerFacts, cfg: GuardConfig): GuardVerdict {
|
||||
if (facts.last_strikes >= cfg.circuit_breaker_strikes) {
|
||||
return {
|
||||
allow: false,
|
||||
reason: `breaker OPEN: ${facts.last_strikes} >= ${cfg.circuit_breaker_strikes} strikes`,
|
||||
};
|
||||
}
|
||||
return { allow: true, reason: `breaker closed (${facts.last_strikes}/${cfg.circuit_breaker_strikes})` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine all guardrails into one verdict. Returns the FIRST failing brake so the skip
|
||||
* reason is specific. load1 is the current 1-min load average.
|
||||
*/
|
||||
export function canDispatch(
|
||||
facts: LedgerFacts,
|
||||
cfg: GuardConfig,
|
||||
now: Date,
|
||||
load1: number,
|
||||
): GuardVerdict {
|
||||
const checks = [
|
||||
breakerTripped(facts, cfg), // breaker first: an open breaker means "escalate, don't retry"
|
||||
budgetOk(facts, cfg),
|
||||
minIntervalOk(facts, cfg, now),
|
||||
backoffElapsed(facts, cfg, now),
|
||||
isLoadAcceptable(load1, cfg.max_load),
|
||||
];
|
||||
const failed = checks.find((c) => !c.allow);
|
||||
return failed ?? { allow: true, reason: "all guardrails passed" };
|
||||
}
|
||||
@ -53,23 +53,41 @@ function extractFirstNm(wavelengths: string | null): number | null {
|
||||
// ── Haupt-Matching-Logik (identisch mit Nightly-Matcher) ────────────────────
|
||||
|
||||
function calcConfidence(
|
||||
fx: { standard_name: string | null; fiber_type: string | null; reach_meters: number | null; wavelengths: string | null },
|
||||
cand: { standard_name: string | null; fiber_type: string | null; reach_meters: number | null; wavelengths: string | null }
|
||||
): { confidence: number; basis: string[] } {
|
||||
fx: {
|
||||
standard_name: string | null; fiber_type: string | null; reach_meters: number | null; wavelengths: string | null;
|
||||
cdr_type: string | null; temp_range: string | null; protocol_family: string | null; wdm_type: string | null;
|
||||
},
|
||||
cand: {
|
||||
standard_name: string | null; fiber_type: string | null; reach_meters: number | null; wavelengths: string | null;
|
||||
cdr_type: string | null; temp_range: string | null; protocol_family: string | null; wdm_type: string | null;
|
||||
}
|
||||
): { confidence: number; basis: string[]; hardReject: boolean } {
|
||||
// Hard rejects: incompatible entity types — no spec similarity overrides these
|
||||
if (fx.protocol_family && cand.protocol_family && fx.protocol_family !== cand.protocol_family) {
|
||||
return { confidence: 0, basis: [`protocol_family:${fx.protocol_family}≠${cand.protocol_family}`], hardReject: true };
|
||||
}
|
||||
if (fx.temp_range && cand.temp_range && fx.temp_range !== cand.temp_range) {
|
||||
return { confidence: 0, basis: [`temp_range:${fx.temp_range}≠${cand.temp_range}`], hardReject: true };
|
||||
}
|
||||
if (fx.wdm_type && cand.wdm_type && fx.wdm_type !== cand.wdm_type) {
|
||||
return { confidence: 0, basis: [`wdm_type:${fx.wdm_type}≠${cand.wdm_type}`], hardReject: true };
|
||||
}
|
||||
|
||||
// Max-Score: form_factor(25) + speed_gbps(20) + standard_name(30) +
|
||||
// wavelength_nm(20) + fiber_type(10) + reach(10) = 115
|
||||
// Beide form_factor und speed_gbps sind bereits durch den SQL-Filter gesichert.
|
||||
// form_factor and speed_gbps are pre-filtered by SQL.
|
||||
let score = 0;
|
||||
const basis: string[] = [];
|
||||
|
||||
score += 25; basis.push("form_factor");
|
||||
score += 20; basis.push("speed_gbps");
|
||||
|
||||
if (
|
||||
fx.standard_name && cand.standard_name &&
|
||||
fx.standard_name.trim().toUpperCase() === cand.standard_name.trim().toUpperCase()
|
||||
) {
|
||||
score += 30; basis.push("standard_name");
|
||||
if (fx.standard_name && cand.standard_name) {
|
||||
if (fx.standard_name.trim().toUpperCase() === cand.standard_name.trim().toUpperCase()) {
|
||||
score += 30; basis.push("standard_name");
|
||||
} else {
|
||||
score -= 25; // known mismatch: strong negative signal
|
||||
}
|
||||
}
|
||||
|
||||
const fxNm = extractFirstNm(fx.wavelengths);
|
||||
@ -102,8 +120,17 @@ function calcConfidence(
|
||||
score += 5; basis.push("reach_null");
|
||||
}
|
||||
|
||||
// CDR type mismatch at >=400G is a strong penalty (different product class)
|
||||
if (fx.cdr_type && cand.cdr_type) {
|
||||
if (fx.cdr_type !== cand.cdr_type) {
|
||||
score -= 20;
|
||||
} else {
|
||||
score += 5; basis.push(`cdr_${fx.cdr_type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const confidence = Math.max(0, Math.min(1, score / 115));
|
||||
return { confidence, basis };
|
||||
return { confidence, basis, hardReject: false };
|
||||
}
|
||||
|
||||
// ── Haupt-Funktion ───────────────────────────────────────────────────────────
|
||||
@ -135,9 +162,14 @@ export async function runCatalogReconcile(): Promise<ReconcileResult> {
|
||||
fiber_type: string | null;
|
||||
reach_meters: number | null;
|
||||
wavelengths: string | null;
|
||||
cdr_type: string | null;
|
||||
temp_range: string | null;
|
||||
protocol_family: string | null;
|
||||
wdm_type: string | null;
|
||||
}>(`
|
||||
SELECT t.id, t.part_number, t.standard_name, t.form_factor,
|
||||
t.speed_gbps, t.fiber_type, t.reach_meters, t.wavelengths
|
||||
t.speed_gbps, t.fiber_type, t.reach_meters, t.wavelengths,
|
||||
t.cdr_type, t.temp_range, t.protocol_family, t.wdm_type
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%'
|
||||
@ -169,12 +201,17 @@ export async function runCatalogReconcile(): Promise<ReconcileResult> {
|
||||
reach_meters: number | null;
|
||||
wavelengths: string | null;
|
||||
vendor_name: string;
|
||||
cdr_type: string | null;
|
||||
temp_range: string | null;
|
||||
protocol_family: string | null;
|
||||
wdm_type: string | null;
|
||||
last_price: Date | null;
|
||||
price_count: string;
|
||||
}>(`
|
||||
SELECT t.id AS competitor_id, t.part_number, t.standard_name,
|
||||
t.form_factor, t.speed_gbps, t.fiber_type, t.reach_meters,
|
||||
t.wavelengths, v.name AS vendor_name,
|
||||
t.cdr_type, t.temp_range, t.protocol_family, t.wdm_type,
|
||||
MAX(po.time) AS last_price, COUNT(*) AS price_count
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
@ -186,19 +223,25 @@ export async function runCatalogReconcile(): Promise<ReconcileResult> {
|
||||
AND ROUND(t.speed_gbps::NUMERIC, 2) = ROUND($2::NUMERIC, 2)
|
||||
AND t.id != $3
|
||||
GROUP BY t.id, t.part_number, t.standard_name, t.form_factor,
|
||||
t.speed_gbps, t.fiber_type, t.reach_meters, t.wavelengths, v.name
|
||||
t.speed_gbps, t.fiber_type, t.reach_meters, t.wavelengths, v.name,
|
||||
t.cdr_type, t.temp_range, t.protocol_family, t.wdm_type
|
||||
HAVING COUNT(*) >= 1
|
||||
`, [fx.form_factor, fx.speed_gbps, fx.id]);
|
||||
|
||||
for (const cand of candidates) {
|
||||
const { confidence, basis } = calcConfidence(fx, cand);
|
||||
const { confidence, basis, hardReject } = calcConfidence(fx, cand);
|
||||
|
||||
if (confidence < CONFIDENCE_MIN) {
|
||||
if (hardReject || confidence < CONFIDENCE_MIN) {
|
||||
result.skippedLowConfidence++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const status = confidence >= CONFIDENCE_AUTO_APPROVE ? "auto_approved" : "pending";
|
||||
// Require FX standard_name for auto_approve; if competitor also has one it must match
|
||||
const candStdOk = !cand.standard_name || !fx.standard_name ||
|
||||
cand.standard_name.trim().toUpperCase() === fx.standard_name.trim().toUpperCase();
|
||||
const canAutoApprove = fx.standard_name !== null && candStdOk;
|
||||
|
||||
const status = (confidence >= CONFIDENCE_AUTO_APPROVE && canAutoApprove) ? "auto_approved" : "pending";
|
||||
const notes =
|
||||
`${fx.part_number} ↔ ${cand.part_number} (${cand.vendor_name}) | ` +
|
||||
`basis: ${basis.join(", ")} | reach: ${fx.reach_meters}m vs ${cand.reach_meters}m | ` +
|
||||
@ -206,18 +249,25 @@ export async function runCatalogReconcile(): Promise<ReconcileResult> {
|
||||
`last_price: ${cand.last_price?.toISOString() ?? "never"} | ` +
|
||||
`source: catalog-reconcile`;
|
||||
|
||||
// Upsert — bereits approved/rejected Einträge nicht überschreiben
|
||||
// Upsert — bereits approved/rejected Einträge nicht überschreiben. Pending
|
||||
// rows get re_research_due_at = NOW() so the daily
|
||||
// maintenance:re-research-equivalences job (scheduler.ts) actually drains
|
||||
// them — that job only evaluates rows where re_research_due_at IS NOT NULL,
|
||||
// and without this the backlog only grows (found live 2026-07-13: 289,681
|
||||
// pending rows had accumulated with re_research_due_at always NULL).
|
||||
const reResearchDueAt = status === "pending" ? new Date() : null;
|
||||
const { rowCount } = await pool.query(`
|
||||
INSERT INTO transceiver_equivalences
|
||||
(flexoptix_id, competitor_id, confidence, match_basis, match_notes, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
(flexoptix_id, competitor_id, confidence, match_basis, match_notes, status, re_research_due_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (flexoptix_id, competitor_id) DO UPDATE SET
|
||||
confidence = EXCLUDED.confidence,
|
||||
match_basis = EXCLUDED.match_basis,
|
||||
match_notes = EXCLUDED.match_notes,
|
||||
re_research_due_at = COALESCE(transceiver_equivalences.re_research_due_at, EXCLUDED.re_research_due_at),
|
||||
updated_at = NOW()
|
||||
WHERE transceiver_equivalences.status NOT IN ('approved', 'rejected', 'auto_approved')
|
||||
`, [fx.id, cand.competitor_id, confidence, basis, notes, status]);
|
||||
`, [fx.id, cand.competitor_id, confidence, basis, notes, status, reResearchDueAt]);
|
||||
|
||||
const wasInsertOrUpdate = (rowCount ?? 0) > 0;
|
||||
if (!wasInsertOrUpdate) {
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
import {
|
||||
ensureVendor,
|
||||
findOrCreateScrapedTransceiver,
|
||||
pool,
|
||||
upsertPriceObservation,
|
||||
upsertStockObservation,
|
||||
} from "../utils/db";
|
||||
@ -38,8 +39,12 @@ interface CatalogProduct {
|
||||
sku: string;
|
||||
title: string;
|
||||
url: string | null;
|
||||
imageUrl: string | null;
|
||||
productType: string | null;
|
||||
price: {
|
||||
amount: number | null;
|
||||
listAmount: number | null;
|
||||
selfConfigureAmount: number | null;
|
||||
currency: string | null;
|
||||
source: "api" | "missing";
|
||||
fetchedAt: string;
|
||||
@ -61,6 +66,8 @@ interface CatalogProduct {
|
||||
bidi: boolean | null;
|
||||
dwdm: boolean | null;
|
||||
cwdm: boolean | null;
|
||||
cdrType: string | null;
|
||||
protocolFamily: string | null;
|
||||
};
|
||||
compatibility: Array<{
|
||||
vendor: string;
|
||||
@ -76,6 +83,7 @@ export interface FlexoptixSyncResult {
|
||||
skipped: number;
|
||||
priceWrites: number;
|
||||
stockWrites: number;
|
||||
mapWrites: number;
|
||||
}
|
||||
|
||||
// ── Generic helpers ────────────────────────────────────────────────────────
|
||||
@ -179,7 +187,8 @@ function parseReachMeters(value: unknown): number | null {
|
||||
|
||||
function parseSpeedGbps(value: unknown): number | null {
|
||||
const numeric = asNumber(value);
|
||||
if (numeric !== null) return numeric;
|
||||
// API returns -10 / -1 as sentinel for N/A (e.g. SFP trays, accessories)
|
||||
if (numeric !== null) return numeric < 0 ? null : numeric;
|
||||
const text = asString(value)?.toLowerCase();
|
||||
if (!text) return null;
|
||||
const gbps = text.match(/([\d.,]+)\s*(g|gb|gbps|gbit)/);
|
||||
@ -229,12 +238,42 @@ function inferWavelengthNm(...values: Array<string | null>): number | null {
|
||||
|
||||
// ── Normalization ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function parseStandardName(title: string | null): string | null {
|
||||
if (!title) return null;
|
||||
// Extract from first segment (before first " | ")
|
||||
const seg = title.split(" | ")[0] ?? title;
|
||||
// Try full IEEE form first: 10GBASE-SR, 100GBASE-DR4, etc.
|
||||
const ieee = seg.match(/((?:\d+(?:\.\d+)?[TGM])?BASE[-/]\w+)/i);
|
||||
if (ieee) return ieee[1].toUpperCase();
|
||||
// Short-form standards — ordered longest-first to avoid partial matches
|
||||
const m = seg.match(
|
||||
/(OpenZR\+|ZR\+|ZX\+|DR4\+|LR4\+|SR10|SR8|LR8|ER8|DR8|VR8|SR4|LR4|ER4|FR4|DR4|CLR4|PLR4|PSM4|CWDM4|LRM|BX\w*|DR|LR|ER|SR|ZR|ZX|SX|LX|LH|EX|T)/
|
||||
);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function parseCdrType(title: string | null): string | null {
|
||||
if (!title) return null;
|
||||
if (/dual[\s-]cdr/i.test(title)) return "dual_cdr";
|
||||
if (/with[\s-]?cdr/i.test(title)) return "cdr";
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseProtocolFamily(title: string | null): string | null {
|
||||
if (!title) return null;
|
||||
if (/fibre[\s-]?channel|fc\s*\d+g/i.test(title)) return "fibre_channel";
|
||||
if (/\bsonet\b|\bsdh\b|\boc-\d+\b/i.test(title)) return "sonet";
|
||||
if (/\binfiniband\b/i.test(title)) return "infiniband";
|
||||
return "ethernet";
|
||||
}
|
||||
|
||||
function extractCompatibility(row: JsonRecord): CatalogProduct["compatibility"] {
|
||||
const rawCompat = row.compatibility ?? row.compatibilities ?? row.vendorCompatibility;
|
||||
const rows = Array.isArray(rawCompat) ? rawCompat.filter(isRecord) : [];
|
||||
return rows.flatMap(entry => {
|
||||
const flat = flatLookup(entry);
|
||||
const vendor = asString(pick(flat, ["vendor", "manufacturer", "brand", "systemVendor"]));
|
||||
const vendor = asString(pick(flat, ["vendor", "manufacturer", "brand", "systemVendor", "compatibleToVendor"]));
|
||||
if (!vendor) return [];
|
||||
return [{
|
||||
vendor,
|
||||
@ -252,7 +291,11 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct |
|
||||
if (!sku || !title) return null;
|
||||
|
||||
const url = asString(pick(flat, ["url", "productUrl", "canonicalUrl", "link"]));
|
||||
const imageUrl = asString(pick(flat, ["image", "imageUrl", "productImage", "thumbnail"]));
|
||||
const productType = asString(pick(flat, ["type", "producttype", "moduletype"]));
|
||||
const amount = asNumber(pick(flat, ["price", "priceNet", "netPrice", "grossPrice", "amount"]));
|
||||
const listAmount = asNumber(pick(flat, ["listprice", "msrp", "rrp", "recommendedprice"]));
|
||||
const selfConfigureAmount = asNumber(pick(flat, ["selfconfigureprice", "selfconfigprice"]));
|
||||
const currency = asString(pick(flat, ["currency", "priceCurrency", "currencyCode"]))
|
||||
?? (amount === null ? null : process.env["FLEXOPTIX_API_CURRENCY"]?.trim() ?? "EUR");
|
||||
const quantity = asNumber(pick(flat, ["stock", "stockQuantity", "quantity", "availableQuantity"]));
|
||||
@ -274,9 +317,13 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct |
|
||||
fetchedAt,
|
||||
sku,
|
||||
title,
|
||||
productType,
|
||||
url,
|
||||
imageUrl,
|
||||
price: {
|
||||
amount,
|
||||
listAmount: (listAmount !== null && listAmount !== amount) ? listAmount : null,
|
||||
selfConfigureAmount,
|
||||
currency,
|
||||
source: amount === null ? "missing" : "api",
|
||||
fetchedAt,
|
||||
@ -298,6 +345,8 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct |
|
||||
bidi: asBoolean(pick(flat, ["bidi", "bidirectional"])) ?? flags.bidi,
|
||||
dwdm: asBoolean(pick(flat, ["dwdm"])) ?? flags.dwdm,
|
||||
cwdm: asBoolean(pick(flat, ["cwdm"])) ?? flags.cwdm,
|
||||
cdrType: parseCdrType(title),
|
||||
protocolFamily: parseProtocolFamily(title),
|
||||
},
|
||||
compatibility: extractCompatibility(row),
|
||||
};
|
||||
@ -305,14 +354,33 @@ function normalizeProduct(row: JsonRecord, fetchedAt: string): CatalogProduct |
|
||||
|
||||
// ── Import helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function isMerchandise(product: CatalogProduct): boolean {
|
||||
// Flexoptix merchandise SKU prefixes: jackets (MJA), mugs (MMU), stickers (MST),
|
||||
// t-shirts (MSH), trolley (MTR), buff (MBU). FO-EXTRA-* and FO-PC-* are non-product.
|
||||
if (/^FO\.M(JA|MU|ST|SH|TR|BU)\d/i.test(product.sku)) return true;
|
||||
if (/^FO-(EXTRA-COMMUNITY-KIT|PC-\d)/i.test(product.sku)) return true;
|
||||
const t = product.title.toLowerCase();
|
||||
return /\b(jacket|coffee mug|t-shirt|sticker|travel trolley|buff)\b/.test(t);
|
||||
}
|
||||
|
||||
function canImportProduct(product: CatalogProduct): boolean {
|
||||
return Boolean(
|
||||
product.sku
|
||||
&& product.title
|
||||
&& product.optics.formFactor
|
||||
&& product.optics.speedGbps !== null
|
||||
&& product.optics.reachM !== null,
|
||||
);
|
||||
// Minimal gate: sku + title required. formFactor/speed/reach are optional
|
||||
// — findOrCreateScrapedTransceiver provides sensible defaults for accessories.
|
||||
// Skip country-of-origin variants (SKUs like "S2A.CL.10000:S2") — they are
|
||||
// duplicates of the base SKU with a different sourcing region, same price/stock.
|
||||
if (product.sku.includes(":")) return false;
|
||||
if (isMerchandise(product)) return false;
|
||||
return Boolean(product.sku && product.title);
|
||||
}
|
||||
|
||||
function formFactorFallback(product: CatalogProduct): string | undefined {
|
||||
const t = product.productType?.toLowerCase() ?? "";
|
||||
if (/accessories|adapter/i.test(t)) return "Accessory";
|
||||
if (/hardware/i.test(t)) return "Hardware";
|
||||
// Passive MTP/MPO breakout cables have no form-factor in the API response.
|
||||
const title = product.title.toLowerCase();
|
||||
if (/\bmtp\b|\bmpo\b/.test(title) && /cable|breakout/.test(title)) return "MTP";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reachLabel(reachM: number | null): string | undefined {
|
||||
@ -328,8 +396,14 @@ function speedLabel(speedGbps: number | null): string | undefined {
|
||||
}
|
||||
|
||||
function categoryFor(product: CatalogProduct): string {
|
||||
const apiType = product.productType?.toLowerCase() ?? "";
|
||||
if (/accessories|adapter/i.test(apiType)) return "Accessory";
|
||||
if (/hardware/i.test(apiType)) return "Hardware";
|
||||
if (apiType.includes("cable")) return "Cable";
|
||||
const text = `${product.title} ${product.optics.protocol ?? ""}`.toLowerCase();
|
||||
if (/\bdac\b|direct attach|copper/.test(text)) return "DAC";
|
||||
// Passive MTP/MPO fan-out cables (e.g. M3.T10L.* "MTP female to 10x LC ... Breakout cable")
|
||||
if (/\bmtp\b.*cable|cable.*\bmtp\b|\bmpo\b.*cable|\bbreakout cable\b/.test(text)) return "Cable";
|
||||
if (/\bdac\b|direct attach|passive copper/.test(text)) return "DAC";
|
||||
if (/\baoc\b|active optical/.test(text)) return "AOC";
|
||||
if (/coherent|zr|dco/.test(text)) return "Coherent";
|
||||
return "DataCenter";
|
||||
@ -343,16 +417,43 @@ async function importProduct(
|
||||
partNumber: product.sku,
|
||||
vendorId,
|
||||
productUrl: product.url ?? undefined,
|
||||
formFactor: product.optics.formFactor ?? undefined,
|
||||
formFactor: product.optics.formFactor ?? formFactorFallback(product),
|
||||
speedGbps: product.optics.speedGbps ?? undefined,
|
||||
speed: speedLabel(product.optics.speedGbps),
|
||||
reachMeters: product.optics.reachM ?? undefined,
|
||||
reachLabel: reachLabel(product.optics.reachM),
|
||||
reachLabel: reachLabel(product.optics.reachM) ?? undefined,
|
||||
fiberType: product.optics.fiberType ?? undefined,
|
||||
wavelengths: product.optics.wavelengthNm === null ? undefined : `${product.optics.wavelengthNm}nm`,
|
||||
category: categoryFor(product),
|
||||
});
|
||||
|
||||
// Write image_url, product_page_url, and notes (product title) from API
|
||||
if (product.imageUrl || product.url || product.title) {
|
||||
const wdmType = product.optics.dwdm ? "DWDM" : product.optics.cwdm ? "CWDM" : null;
|
||||
await pool.query(`
|
||||
UPDATE transceivers SET
|
||||
image_url = COALESCE(NULLIF(image_url, ''), $1::text),
|
||||
product_page_url = COALESCE(NULLIF(product_page_url, ''), $2::text),
|
||||
notes = $3::text,
|
||||
standard_name = COALESCE(NULLIF(standard_name, ''), $5::text),
|
||||
wdm_type = COALESCE(NULLIF(wdm_type, ''), $6::text),
|
||||
cdr_type = COALESCE(NULLIF(cdr_type, ''), $7::text),
|
||||
cdr_support = CASE WHEN $7::text IS NOT NULL THEN true ELSE cdr_support END,
|
||||
protocol_family = COALESCE(NULLIF(protocol_family, ''), $8::text),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, [
|
||||
product.imageUrl ?? null,
|
||||
product.url ?? null,
|
||||
product.title,
|
||||
transceiverId,
|
||||
parseStandardName(product.title) ?? product.optics.protocol ?? null,
|
||||
wdmType,
|
||||
product.optics.cdrType ?? null,
|
||||
product.optics.protocolFamily ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
let priceWritten = false;
|
||||
if (product.price.amount !== null && product.price.currency) {
|
||||
priceWritten = await upsertPriceObservation({
|
||||
@ -373,11 +474,61 @@ async function importProduct(
|
||||
});
|
||||
}
|
||||
|
||||
if (product.price.selfConfigureAmount !== null && product.price.currency) {
|
||||
await upsertPriceObservation({
|
||||
transceiverId,
|
||||
sourceVendorId: vendorId,
|
||||
price: product.price.selfConfigureAmount,
|
||||
currency: product.price.currency,
|
||||
marketplace: "flexoptix-selfconfigure",
|
||||
stockLevel: product.stock.status ?? "unknown",
|
||||
quantityAvailable: product.stock.quantity ?? undefined,
|
||||
url: product.url ?? undefined,
|
||||
contentHash: contentHash({
|
||||
source: product.source,
|
||||
sku: product.sku,
|
||||
price: product.price.selfConfigureAmount,
|
||||
currency: product.price.currency,
|
||||
marketplace: "flexoptix-selfconfigure",
|
||||
fetchedAt: product.price.fetchedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (product.price.listAmount !== null && product.price.currency) {
|
||||
await upsertPriceObservation({
|
||||
transceiverId,
|
||||
sourceVendorId: vendorId,
|
||||
price: product.price.listAmount,
|
||||
currency: product.price.currency,
|
||||
marketplace: "flexoptix-msrp",
|
||||
stockLevel: product.stock.status ?? "unknown",
|
||||
quantityAvailable: product.stock.quantity ?? undefined,
|
||||
url: product.url ?? undefined,
|
||||
contentHash: contentHash({
|
||||
source: product.source,
|
||||
sku: product.sku,
|
||||
price: product.price.listAmount,
|
||||
currency: product.price.currency,
|
||||
marketplace: "flexoptix-msrp",
|
||||
fetchedAt: product.price.fetchedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// When status is known but no numeric quantity, synthesize a value so
|
||||
// upsertStockObservation's early-exit guard doesn't skip the write.
|
||||
const syntheticQty =
|
||||
product.stock.quantity !== null ? product.stock.quantity
|
||||
: product.stock.status === "in_stock" ? 1
|
||||
: product.stock.status === "out_of_stock" ? 0
|
||||
: undefined;
|
||||
|
||||
const stockWritten = await upsertStockObservation({
|
||||
transceiverId,
|
||||
sourceVendorId: vendorId,
|
||||
stockLevel: product.stock.status ?? "unknown",
|
||||
quantityAvailable: product.stock.quantity ?? undefined,
|
||||
quantityAvailable: syntheticQty,
|
||||
priceNet: product.price.amount ?? undefined,
|
||||
productUrl: product.url ?? undefined,
|
||||
priceCurrency: product.price.currency ?? undefined,
|
||||
@ -387,6 +538,22 @@ async function importProduct(
|
||||
return { priceWritten, stockWritten };
|
||||
}
|
||||
|
||||
async function writeProductMapEntries(product: CatalogProduct): Promise<number> {
|
||||
const entries = product.compatibility
|
||||
.filter(c => c.coding !== null && c.vendor !== null)
|
||||
.map(c => ({
|
||||
oemPartNumber: c.coding!,
|
||||
oemVendor: c.vendor,
|
||||
flexoptixSku: product.sku,
|
||||
priceEur: product.price.amount,
|
||||
formFactor: product.optics.formFactor,
|
||||
speedGbps: product.optics.speedGbps,
|
||||
reachLabel: reachLabel(product.optics.reachM) ?? null,
|
||||
fiberType: product.optics.fiberType,
|
||||
}));
|
||||
return batchUpsertProductMap(entries);
|
||||
}
|
||||
|
||||
// ── API client ─────────────────────────────────────────────────────────────
|
||||
|
||||
function validateEnv(): { baseUrl: string; username: string | null; password: string | null; token: string | null } {
|
||||
@ -394,7 +561,7 @@ function validateEnv(): { baseUrl: string; username: string | null; password: st
|
||||
if (!baseUrl) {
|
||||
throw new Error("FLEXOPTIX_API_BASE_URL is required for Flexoptix API sync");
|
||||
}
|
||||
const token = process.env["FLEXOPTIX_API_TOKEN"]?.trim() ?? null;
|
||||
const token = process.env["FLEXOPTIX_API_TOKEN"]?.trim() || null; // empty string -> null so bearer-login fallback fires
|
||||
const username = process.env["FLEXOPTIX_API_USERNAME"]?.trim() ?? null;
|
||||
const password = process.env["FLEXOPTIX_API_PASSWORD"]?.trim() ?? null;
|
||||
if (!token && (!username || !password)) {
|
||||
@ -457,8 +624,6 @@ async function fetchAllProducts(baseUrl: string, headers: Record<string, string>
|
||||
if (rows.length === 0) break;
|
||||
|
||||
allRows.push(...rows);
|
||||
|
||||
if (rows.length < (Number.isFinite(limit) ? limit : 500)) break;
|
||||
}
|
||||
|
||||
return allRows;
|
||||
@ -476,6 +641,94 @@ function extractRows(payload: unknown): JsonRecord[] {
|
||||
|
||||
// ── Main export ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ── Self-configure price enrichment ───────────────────────────────────────
|
||||
// The bulk paginated endpoint omits self_configure_price.
|
||||
// A targeted sku[] fetch returns it. Batch 50 SKUs per call (~10 calls total).
|
||||
|
||||
async function fetchSelfConfigurePrices(
|
||||
skus: string[],
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
currency: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Map<string, number>> {
|
||||
const BATCH_SIZE = 50;
|
||||
const priceMap = new Map<string, number>();
|
||||
const productPath = process.env["FLEXOPTIX_API_PRODUCTS_PATH"]?.trim() ?? "/rest/V2/flexoptix/products";
|
||||
|
||||
for (let i = 0; i < skus.length; i += BATCH_SIZE) {
|
||||
const batch = skus.slice(i, i + BATCH_SIZE);
|
||||
const url = buildUrl(baseUrl, productPath);
|
||||
url.searchParams.set("currency", currency);
|
||||
batch.forEach(sku => url.searchParams.append("sku[]", sku));
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(url, { headers }, timeoutMs);
|
||||
for (const row of extractRows(payload)) {
|
||||
if (!isRecord(row)) continue;
|
||||
const sku = asString(row["sku"] ?? row["SKU"]);
|
||||
const flat = flatLookup(row);
|
||||
const scPrice = asNumber(flat["selfconfigureprice"] ?? flat["selfconfigprice"] ?? null);
|
||||
if (sku && scPrice !== null) priceMap.set(sku, scPrice);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[${new Date().toISOString()}] Self-configure enrichment batch failed: ${msg.slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return priceMap;
|
||||
}
|
||||
|
||||
|
||||
// ── Product map batch upsert ─────────────────────────────────────────────────
|
||||
// Writes OEM vendor coding entries into flexoptix_product_map.
|
||||
// One row per (oem_part_number, oem_vendor) pair — ON CONFLICT updates specs.
|
||||
|
||||
async function batchUpsertProductMap(entries: Array<{
|
||||
oemPartNumber: string;
|
||||
oemVendor: string;
|
||||
flexoptixSku: string;
|
||||
priceEur: number | null;
|
||||
formFactor: string | null;
|
||||
speedGbps: number | null;
|
||||
reachLabel: string | null;
|
||||
fiberType: string | null;
|
||||
}>): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
const BATCH_SIZE = 500;
|
||||
let written = 0;
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const chunk = entries.slice(i, i + BATCH_SIZE);
|
||||
const placeholders: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let n = 1;
|
||||
for (const e of chunk) {
|
||||
placeholders.push(`($${n},$${n+1},$${n+2},$${n+3},$${n+4},$${n+5},$${n+6},$${n+7},'exact',NOW())`);
|
||||
params.push(e.oemPartNumber, e.oemVendor, e.flexoptixSku, e.priceEur, e.formFactor, e.speedGbps, e.reachLabel, e.fiberType);
|
||||
n += 8;
|
||||
}
|
||||
await pool.query(`
|
||||
INSERT INTO flexoptix_product_map
|
||||
(oem_part_number, oem_vendor, flexoptix_sku, flexoptix_price_eur,
|
||||
form_factor, speed_gbps, reach_label, fiber_type, match_type, last_verified)
|
||||
VALUES ${placeholders.join(',')}
|
||||
ON CONFLICT (oem_part_number, oem_vendor) DO UPDATE SET
|
||||
flexoptix_sku = EXCLUDED.flexoptix_sku,
|
||||
flexoptix_price_eur = EXCLUDED.flexoptix_price_eur,
|
||||
form_factor = EXCLUDED.form_factor,
|
||||
speed_gbps = EXCLUDED.speed_gbps,
|
||||
reach_label = EXCLUDED.reach_label,
|
||||
fiber_type = EXCLUDED.fiber_type,
|
||||
last_verified = EXCLUDED.last_verified,
|
||||
updated_at = NOW()
|
||||
`, params);
|
||||
written += chunk.length;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
export async function syncFlexoptixCatalog(): Promise<FlexoptixSyncResult> {
|
||||
const { baseUrl, username, password, token } = validateEnv();
|
||||
const timeoutMs = parseInt(process.env["FLEXOPTIX_API_TIMEOUT_MS"]?.trim() ?? "30000", 10);
|
||||
@ -501,25 +754,45 @@ export async function syncFlexoptixCatalog(): Promise<FlexoptixSyncResult> {
|
||||
const importable = products.filter(canImportProduct);
|
||||
const skipped = products.length - importable.length;
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Normalized: ${products.length} | importable: ${importable.length} | skipped: ${skipped}`);
|
||||
// Enrich importable products with self_configure_price (not in bulk response)
|
||||
const currency = process.env["FLEXOPTIX_API_CURRENCY"]?.trim() ?? "EUR";
|
||||
const scPrices = await fetchSelfConfigurePrices(
|
||||
importable.map(p => p.sku),
|
||||
baseUrl,
|
||||
headers,
|
||||
currency,
|
||||
timeoutMs,
|
||||
);
|
||||
let scEnriched = 0;
|
||||
for (const product of importable) {
|
||||
const scPrice = scPrices.get(product.sku);
|
||||
if (scPrice !== undefined) {
|
||||
product.price.selfConfigureAmount = scPrice;
|
||||
scEnriched++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Normalized: ${products.length} | importable: ${importable.length} | skipped: ${skipped} | self-configure enriched: ${scEnriched}`);
|
||||
|
||||
const vendorId = await ensureVendor("Flexoptix", "compatible", "https://www.flexoptix.net", "https://www.flexoptix.net");
|
||||
|
||||
let priceWrites = 0;
|
||||
let stockWrites = 0;
|
||||
let mapWrites = 0;
|
||||
|
||||
for (const product of importable) {
|
||||
try {
|
||||
const result = await importProduct(product, vendorId);
|
||||
if (result.priceWritten) priceWrites++;
|
||||
if (result.stockWritten) stockWrites++;
|
||||
mapWrites += await writeProductMapEntries(product);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[${new Date().toISOString()}] Flexoptix import error (${product.sku}): ${message.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Flexoptix API sync complete: ${importable.length} products, ${priceWrites} price writes, ${stockWrites} stock writes`);
|
||||
console.log(`[${new Date().toISOString()}] Flexoptix API sync complete: ${importable.length} products, ${priceWrites} price writes, ${stockWrites} stock writes, ${mapWrites} product-map upserts`);
|
||||
|
||||
return {
|
||||
fetched: rawRows.length,
|
||||
@ -527,5 +800,6 @@ export async function syncFlexoptixCatalog(): Promise<FlexoptixSyncResult> {
|
||||
skipped,
|
||||
priceWrites,
|
||||
stockWrites,
|
||||
mapWrites,
|
||||
};
|
||||
}
|
||||
|
||||
563
packages/scraper/src/robots/flexoptix-detail-enricher.ts
Normal file
563
packages/scraper/src/robots/flexoptix-detail-enricher.ts
Normal file
@ -0,0 +1,563 @@
|
||||
/**
|
||||
* Flexoptix Detail Enricher
|
||||
*
|
||||
* Fetches full product specifications and compatibility data from the Flexoptix
|
||||
* API on a per-SKU basis (specifications=1&compatibilities=1) and writes all
|
||||
* structured fields back to the transceivers table.
|
||||
*
|
||||
* Unlike the bulk catalog sync (specifications=0 to avoid HTTP 503), this robot
|
||||
* processes products in small batches with rate-limiting so the API stays happy.
|
||||
*
|
||||
* Fields written per product:
|
||||
* fx_specifications — raw [{label, value}, ...] blob (for datasheet gen)
|
||||
* fx_compatibilities — full [{sku, compatible_to_vendor, original_part_number}]
|
||||
* compliance_code — "LX SGMII", "SR4", "LR4", etc.
|
||||
* laser_type — "FP", "DFB", "VCSEL", "EML"
|
||||
* receiver_type — "PIN", "APD"
|
||||
* supported_protocols — TEXT[]
|
||||
* extinction_ratio_db — dB
|
||||
* cdr_support — boolean
|
||||
* inbuilt_fec — boolean
|
||||
* power_consumption_w — W (overrides if empty)
|
||||
* optical_budget_db — dB (overrides if empty)
|
||||
* tx_power_min_dbm — dBm
|
||||
* tx_power_max_dbm — dBm
|
||||
* rx_sensitivity_dbm — dBm
|
||||
* modulation — "NRZ", "PAM4", etc.
|
||||
* wavelength_tx_nm — nm (overrides if empty)
|
||||
* wavelength_rx_nm — nm (overrides if empty)
|
||||
* image_url — product image URL
|
||||
* product_page_url — product page URL
|
||||
* detail_synced_at — timestamp of this sync
|
||||
*
|
||||
* Scheduling:
|
||||
* - Runs daily at 03:00 UTC
|
||||
* - Processes BATCH_SIZE products per run (prioritises unseen, then stale >7d)
|
||||
* - Rate: 1 API call per 600ms (~1.6 rps, safe for Magento)
|
||||
*/
|
||||
|
||||
import { pool } from "../utils/db";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Products per enricher run. Full catalog (~1100 products) in ~11 daily runs. */
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
/** Milliseconds between per-SKU API calls (Magento rate-limit safety). */
|
||||
const API_CALL_DELAY_MS = 600;
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FxApiCompatibility {
|
||||
sku: string | null;
|
||||
compatible_to_vendor: string;
|
||||
original_part_number: string | null;
|
||||
}
|
||||
|
||||
interface FxApiSpec {
|
||||
label: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface FxApiProduct {
|
||||
sku: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
compatibilities?: FxApiCompatibility[];
|
||||
specifications?: FxApiSpec[];
|
||||
}
|
||||
|
||||
interface ParsedSpecs {
|
||||
formFactor: string | null;
|
||||
speedGbps: number | null;
|
||||
complianceCode: string | null;
|
||||
laserType: string | null;
|
||||
receiverType: string | null;
|
||||
supportedProtocols: string[];
|
||||
extinctionRatioDb: number | null;
|
||||
cdrSupport: boolean | null;
|
||||
inbuiltFec: boolean | null;
|
||||
powerConsumptionW: number | null;
|
||||
opticalBudgetDb: number | null;
|
||||
txPowerMinDbm: number | null;
|
||||
txPowerMaxDbm: number | null;
|
||||
rxSensitivityDbm: number | null;
|
||||
modulation: string | null;
|
||||
wavelengthTxNm: number | null;
|
||||
wavelengthRxNm: number | null;
|
||||
tempRange: string | null;
|
||||
domSupport: boolean | null;
|
||||
}
|
||||
|
||||
export interface DetailEnricherResult {
|
||||
processed: number;
|
||||
updated: number;
|
||||
notFound: number;
|
||||
apiErrors: number;
|
||||
dbErrors: number;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function specValue(specs: FxApiSpec[], label: string): string | null {
|
||||
const entry = specs.find(s => s.label.toLowerCase() === label.toLowerCase());
|
||||
if (!entry) return null;
|
||||
const v = entry.value;
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
if (typeof v === "string") return v.trim() || null;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
function specArray(specs: FxApiSpec[], label: string): string[] {
|
||||
const entry = specs.find(s => s.label.toLowerCase() === label.toLowerCase());
|
||||
if (!entry) return [];
|
||||
if (Array.isArray(entry.value)) return entry.value.filter(v => typeof v === "string") as string[];
|
||||
const v = entry.value;
|
||||
if (typeof v === "string" && v.trim()) return [v.trim()];
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseDbm(text: string | null): { min: number | null; max: number | null } {
|
||||
if (!text) return { min: null, max: null };
|
||||
// Format: "-15 dBm / -8 dBm" or "-31 dBm / -8 dBm (overload) @100M"
|
||||
const numbers = text.match(/-?\d+(?:\.\d+)?\s*dBm/gi) ?? [];
|
||||
const values = numbers
|
||||
.map(n => parseFloat(n.replace(/dBm/i, "").trim()))
|
||||
.filter(n => Number.isFinite(n));
|
||||
return {
|
||||
min: values[0] ?? null,
|
||||
max: values[1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWavelengthNm(text: string | null): number | null {
|
||||
if (!text) return null;
|
||||
const match = text.match(/(\d{3,4})\s*nm/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
function parsePowerW(text: string | null): number | null {
|
||||
if (!text) return null;
|
||||
const match = text.match(/([\d.]+)\s*W/i);
|
||||
return match ? parseFloat(match[1]) : null;
|
||||
}
|
||||
|
||||
function parseDb(text: string | null): number | null {
|
||||
if (!text) return null;
|
||||
const match = text.match(/([\d.]+)\s*dB(?!m)/i);
|
||||
return match ? parseFloat(match[1]) : null;
|
||||
}
|
||||
|
||||
function parseTempRange(text: string | null, operatingTemp: string | null): "COM" | "IND" | null {
|
||||
// Parse degree-range strings like "0°C - 70°C" or "-40°C - 85°C"
|
||||
if (text && /°C/.test(text)) {
|
||||
const minMatch = text.match(/(-?\d+)\s*°C/);
|
||||
const minC = minMatch ? parseInt(minMatch[1], 10) : null;
|
||||
if (minC !== null && minC < -10) return "IND";
|
||||
return "COM";
|
||||
}
|
||||
// Classify from the operating temperature label
|
||||
const combined = [text, operatingTemp].filter(Boolean).join(" ").toLowerCase();
|
||||
if (/industrial|ind\b|-40/.test(combined)) return "IND";
|
||||
if (/commercial|standard|com\b/.test(combined)) return "COM";
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDomSupport(text: string | null): boolean | null {
|
||||
if (!text) return null;
|
||||
const lower = text.toLowerCase();
|
||||
if (/not implemented|no|none/.test(lower)) return false;
|
||||
if (/yes|implemented|supported|digital/.test(lower)) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBoolean(text: string | null): boolean | null {
|
||||
if (!text) return null;
|
||||
const lower = text.toLowerCase().trim();
|
||||
if (["yes", "true", "1", "ja"].includes(lower)) return true;
|
||||
if (["no", "false", "0", "nein", "none"].includes(lower)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseModulation(text: string | null): string | null {
|
||||
if (!text) return null;
|
||||
// Normalize "NRZ @100M - 800M" → "NRZ", "PAM4" → "PAM4"
|
||||
const match = text.match(/\b(NRZ|PAM4|PAM-4|DP-QPSK|QPSK|16QAM|64QAM|OOK)\b/i);
|
||||
return match ? match[1].toUpperCase().replace("PAM-4", "PAM4") : text.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative form factor from the structured "Form Factor" spec (NOT the name).
|
||||
*/
|
||||
function normalizeFormFactorSpec(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
const t = v.toUpperCase();
|
||||
const order = ["QSFP-DD800","QSFP-DD","QSFP112","QSFP56","QSFP28","QSFP+",
|
||||
"OSFP224","OSFP112","OSFP","SFP56","SFP28","CSFP","SFP+","SFP",
|
||||
"XFP","CFP4","CFP2","CFP","GBIC"];
|
||||
for (const ff of order) if (t.includes(ff)) return ff === "CSFP" ? "SFP" : ff;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Map a datasheet line rate (Gbit/s) to the nominal standard speed. */
|
||||
function mapLineRateToNominal(u: number): number {
|
||||
const table: Array<[number, number]> = [
|
||||
[0.2, 0.1], [1.5, 1], [3.5, 2.5], [6, 5], [13, 10], [20, 16],
|
||||
[33, 25], [46, 40], [60, 50], [120, 100], [240, 200], [480, 400],
|
||||
[960, 800], [2000, 1600],
|
||||
];
|
||||
for (const [ceil, nom] of table) if (u <= ceil) return nom;
|
||||
return Math.round(u);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative nominal speed (Gbps) sourced from the datasheet specs only:
|
||||
* primary = highest Ethernet rate named in "Supported Protocols";
|
||||
* fallback = upper bound of "Bandwidth" line rate mapped to nominal.
|
||||
* Never derived from the product name.
|
||||
*/
|
||||
function nominalSpeedFromSpecs(specs: FxApiSpec[]): number | null {
|
||||
const protocols = specArray(specs, "Supported Protocols");
|
||||
let best: number | null = null;
|
||||
for (const p of protocols) {
|
||||
const t = p.toLowerCase();
|
||||
let sp: number | null = null;
|
||||
if (/\bfast ethernet\b/.test(t)) sp = 0.1;
|
||||
else if (/\bgigabit ethernet\b/.test(t)) sp = 1;
|
||||
else {
|
||||
// Terabit first ("1.6t ethernet" -> 1600), then Gigabit. Aggregate "Nx MMMg"
|
||||
// twin-port labels resolve via the explicit total (e.g. "1.6T Ethernet").
|
||||
const mt = t.match(/\b(\d+(?:\.\d+)?)\s*t(?:b|be|ig)?\s*ethernet\b/);
|
||||
if (mt) sp = parseFloat(mt[1]) * 1000;
|
||||
else {
|
||||
const m = t.match(/(?:^|[^x\d])(\d+(?:\.\d+)?)\s*g(?:b|be|ig)?\s*ethernet\b/) || t.match(/\b(\d+)\s*gbe\b/);
|
||||
if (m) sp = parseFloat(m[1]);
|
||||
}
|
||||
}
|
||||
if (sp !== null && (best === null || sp > best)) best = sp;
|
||||
}
|
||||
if (best !== null) return best;
|
||||
const bw = specValue(specs, "Bandwidth");
|
||||
if (bw) {
|
||||
const gbits = Array.from(bw.matchAll(/([\d.]+)\s*gbit\/s/gi)).map(m => parseFloat(m[1]));
|
||||
if (gbits.length) return mapLineRateToNominal(Math.max(...gbits));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format a nominal Gbps value as a display label ("1G", "100G", "1.6T"). */
|
||||
function speedLabelGbps(g: number | null): string | null {
|
||||
if (g === null) return null;
|
||||
if (g >= 1000) return `${g / 1000}T`;
|
||||
if (g < 1) return `${Math.round(g * 1000)}M`;
|
||||
return `${g}G`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the flat specifications array into structured fields.
|
||||
*/
|
||||
function parseSpecs(specs: FxApiSpec[]): ParsedSpecs {
|
||||
const txPowers = parseDbm(specValue(specs, "Transmit min/max per lane"));
|
||||
const rxPowers = parseDbm(specValue(specs, "Receiver min/max per lane"));
|
||||
|
||||
return {
|
||||
formFactor: normalizeFormFactorSpec(specValue(specs, "Form Factor")),
|
||||
speedGbps: nominalSpeedFromSpecs(specs),
|
||||
complianceCode: specValue(specs, "Compliance Code"),
|
||||
laserType: specValue(specs, "Laser"),
|
||||
receiverType: specValue(specs, "Receiver Type"),
|
||||
supportedProtocols: specArray(specs, "Supported Protocols"),
|
||||
extinctionRatioDb: parseDb(specValue(specs, "Extinction Ratio")),
|
||||
cdrSupport: parseBoolean(specValue(specs, "CDR")),
|
||||
inbuiltFec: parseBoolean(specValue(specs, "Inbuilt FEC")),
|
||||
powerConsumptionW: parsePowerW(specValue(specs, "Power Consumption")),
|
||||
opticalBudgetDb: parseDb(specValue(specs, "Powerbudget (dB)")),
|
||||
txPowerMinDbm: txPowers.min,
|
||||
txPowerMaxDbm: txPowers.max,
|
||||
rxSensitivityDbm: rxPowers.min,
|
||||
modulation: parseModulation(specValue(specs, "Modulation")),
|
||||
wavelengthTxNm: parseWavelengthNm(specValue(specs, "Wavelength TX (Typical)")),
|
||||
wavelengthRxNm: parseWavelengthNm(specValue(specs, "Wavelength RX (Typical)")),
|
||||
tempRange: parseTempRange(
|
||||
specValue(specs, "Temperature Range"),
|
||||
specValue(specs, "Operating Temperature"),
|
||||
),
|
||||
domSupport: parseDomSupport(specValue(specs, "Digital Diagnostic Monitoring (DDM)")),
|
||||
};
|
||||
}
|
||||
|
||||
// ── API client ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function authenticate(baseUrl: string, timeoutMs: number): Promise<string> {
|
||||
const existingToken = process.env["FLEXOPTIX_API_TOKEN"]?.trim();
|
||||
if (existingToken) return existingToken;
|
||||
|
||||
const username = process.env["FLEXOPTIX_API_USERNAME"]?.trim();
|
||||
const password = process.env["FLEXOPTIX_API_PASSWORD"]?.trim();
|
||||
if (!username || !password) {
|
||||
throw new Error("FLEXOPTIX_API_USERNAME + FLEXOPTIX_API_PASSWORD required for detail enricher");
|
||||
}
|
||||
|
||||
const authPath = process.env["FLEXOPTIX_API_AUTH_PATH"]?.trim() ?? "/rest/V1/integration/customer/token";
|
||||
const url = `${baseUrl}${authPath}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Auth failed: HTTP ${res.status}`);
|
||||
const token = await res.json();
|
||||
if (typeof token !== "string") throw new Error("Auth response was not a string token");
|
||||
return token;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a FX SKU for the API query.
|
||||
* Strips variant/self-configure suffixes that exist in TIP DB but not in the API:
|
||||
* "S.B1312.10.DLI:Sx" → "S.B1312.10.DLI" (self-configure parent)
|
||||
* "M4.T8SL.x" → "M4.T8SL" (placeholder variant)
|
||||
* "P.1696.25.yy.R" → kept as-is (real SKU with letter suffix)
|
||||
*/
|
||||
function normalizeSku(sku: string): string {
|
||||
// Strip ":Sx", ":S1", ":AB", etc. (colon-delimited variant suffixes)
|
||||
const colonSuffix = sku.replace(/:[A-Za-z0-9]+$/, "");
|
||||
if (colonSuffix !== sku) return colonSuffix;
|
||||
// Strip trailing ".x" or ".y" (single-letter placeholder segments)
|
||||
const dotSuffix = sku.replace(/\.[xy]$/i, "");
|
||||
if (dotSuffix !== sku) return dotSuffix;
|
||||
return sku;
|
||||
}
|
||||
|
||||
async function fetchProductDetail(
|
||||
baseUrl: string,
|
||||
productPath: string,
|
||||
sku: string,
|
||||
headers: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<FxApiProduct | null> {
|
||||
const apiSku = normalizeSku(sku);
|
||||
const url = new URL(productPath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
||||
url.searchParams.set("sku", apiSku);
|
||||
url.searchParams.set("specifications", "1");
|
||||
url.searchParams.set("compatibilities", "1");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString(), { headers, signal: controller.signal });
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = await res.json();
|
||||
// API returns array for SKU query
|
||||
const rows = Array.isArray(body) ? body : [body];
|
||||
const row = rows[0];
|
||||
if (!row || typeof row !== "object") return null;
|
||||
|
||||
return row as FxApiProduct;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DB helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface FxProduct {
|
||||
id: string;
|
||||
part_number: string;
|
||||
power_consumption_w: number | null;
|
||||
optical_budget_db: number | null;
|
||||
wavelength_tx_nm: number | null;
|
||||
wavelength_rx_nm: number | null;
|
||||
}
|
||||
|
||||
async function fetchBatch(): Promise<FxProduct[]> {
|
||||
const result = await pool.query<FxProduct>(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.part_number,
|
||||
t.power_consumption_w,
|
||||
t.optical_budget_db,
|
||||
t.wavelength_tx_nm,
|
||||
t.wavelength_rx_nm
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%'
|
||||
-- FX catalog SKUs always contain a dot (e.g. S.1303.10.G, Q2.85850.100.D5)
|
||||
-- Products without a dot are misidentified non-FX items — skip them
|
||||
AND t.part_number LIKE '%.%'
|
||||
AND (
|
||||
t.detail_synced_at IS NULL
|
||||
OR t.detail_synced_at < NOW() - INTERVAL '7 days'
|
||||
)
|
||||
ORDER BY
|
||||
t.detail_synced_at ASC NULLS FIRST,
|
||||
t.data_completeness DESC -- process most-complete products first
|
||||
LIMIT $1
|
||||
`, [BATCH_SIZE]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function writeDetails(
|
||||
transceiverId: string,
|
||||
product: FxApiProduct,
|
||||
parsed: ParsedSpecs,
|
||||
): Promise<void> {
|
||||
const compat = Array.isArray(product.compatibilities) ? product.compatibilities : [];
|
||||
const specs = Array.isArray(product.specifications) ? product.specifications : [];
|
||||
|
||||
await pool.query(`
|
||||
UPDATE transceivers SET
|
||||
form_factor = COALESCE($23, form_factor),
|
||||
speed_gbps = COALESCE($24, speed_gbps),
|
||||
speed = COALESCE($25, speed),
|
||||
fx_specifications = $1,
|
||||
fx_compatibilities = $2,
|
||||
compliance_code = COALESCE(compliance_code, $3),
|
||||
laser_type = COALESCE(laser_type, $4),
|
||||
receiver_type = COALESCE(receiver_type, $5),
|
||||
supported_protocols = COALESCE(supported_protocols, $6),
|
||||
extinction_ratio_db = COALESCE(extinction_ratio_db, $7),
|
||||
cdr_support = COALESCE(cdr_support, $8),
|
||||
inbuilt_fec = COALESCE(inbuilt_fec, $9),
|
||||
power_consumption_w = COALESCE(power_consumption_w, $10),
|
||||
optical_budget_db = COALESCE(optical_budget_db, $11),
|
||||
tx_power_min_dbm = COALESCE(tx_power_min_dbm, $12),
|
||||
tx_power_max_dbm = COALESCE(tx_power_max_dbm, $13),
|
||||
rx_sensitivity_dbm = COALESCE(rx_sensitivity_dbm, $14),
|
||||
modulation = COALESCE(modulation, $15),
|
||||
wavelength_tx_nm = COALESCE(wavelength_tx_nm, $16),
|
||||
wavelength_rx_nm = COALESCE(wavelength_rx_nm, $17),
|
||||
temp_range = COALESCE(NULLIF(temp_range, 'COM'), $18),
|
||||
dom_support = COALESCE(dom_support, $19),
|
||||
image_url = COALESCE(NULLIF(image_url, ''), $20),
|
||||
product_page_url = COALESCE(NULLIF(product_page_url, ''), $21),
|
||||
detail_synced_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $22
|
||||
`, [
|
||||
specs.length > 0 ? JSON.stringify(specs) : null, // $1
|
||||
compat.length > 0 ? JSON.stringify(compat) : null, // $2
|
||||
parsed.complianceCode, // $3
|
||||
parsed.laserType, // $4
|
||||
parsed.receiverType, // $5
|
||||
parsed.supportedProtocols.length > 0 ? parsed.supportedProtocols : null, // $6
|
||||
parsed.extinctionRatioDb, // $7
|
||||
parsed.cdrSupport, // $8
|
||||
parsed.inbuiltFec, // $9
|
||||
parsed.powerConsumptionW, // $10
|
||||
parsed.opticalBudgetDb, // $11
|
||||
parsed.txPowerMinDbm, // $12
|
||||
parsed.txPowerMaxDbm, // $13
|
||||
parsed.rxSensitivityDbm, // $14
|
||||
parsed.modulation, // $15
|
||||
parsed.wavelengthTxNm, // $16
|
||||
parsed.wavelengthRxNm, // $17
|
||||
parsed.tempRange, // $18
|
||||
parsed.domSupport, // $19
|
||||
product.image ?? null, // $20
|
||||
product.url ?? null, // $21
|
||||
transceiverId, // $22
|
||||
parsed.formFactor, // $23
|
||||
parsed.speedGbps, // $24
|
||||
speedLabelGbps(parsed.speedGbps), // $25
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Main export ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runFlexoptixDetailEnricher(): Promise<DetailEnricherResult> {
|
||||
const baseUrl = process.env["FLEXOPTIX_API_BASE_URL"]?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error("FLEXOPTIX_API_BASE_URL not configured");
|
||||
}
|
||||
|
||||
const productPath = process.env["FLEXOPTIX_API_PRODUCTS_PATH"]?.trim()
|
||||
?? "/rest/V2/flexoptix/products";
|
||||
const timeoutMs = parseInt(process.env["FLEXOPTIX_API_TIMEOUT_MS"]?.trim() ?? "30000", 10);
|
||||
|
||||
const ts = () => new Date().toISOString();
|
||||
console.log(`[${ts()}] Flexoptix detail enricher starting (batch=${BATCH_SIZE})`);
|
||||
|
||||
const token = await authenticate(baseUrl, timeoutMs);
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const batch = await fetchBatch();
|
||||
console.log(`[${ts()}] Batch: ${batch.length} FX products queued for detail sync`);
|
||||
|
||||
let updated = 0;
|
||||
let notFound = 0;
|
||||
let apiErrors = 0;
|
||||
let dbErrors = 0;
|
||||
|
||||
for (const product of batch) {
|
||||
// Rate-limit: sleep between calls
|
||||
await new Promise(resolve => setTimeout(resolve, API_CALL_DELAY_MS));
|
||||
|
||||
let apiProduct: FxApiProduct | null = null;
|
||||
try {
|
||||
apiProduct = await fetchProductDetail(baseUrl, productPath, product.part_number, headers, timeoutMs);
|
||||
} catch (err: unknown) {
|
||||
apiErrors++;
|
||||
console.warn(
|
||||
`[${ts()}] detail-enricher API error (${product.part_number}): ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!apiProduct) {
|
||||
// Not found in FX API — still mark synced so we don't retry daily,
|
||||
// but log it so we can investigate if many products come back empty
|
||||
notFound++;
|
||||
await pool.query(
|
||||
`UPDATE transceivers SET detail_synced_at = NOW() WHERE id = $1`,
|
||||
[product.id],
|
||||
).catch(() => null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const specs = Array.isArray(apiProduct.specifications) ? apiProduct.specifications : [];
|
||||
const parsed = parseSpecs(specs);
|
||||
|
||||
try {
|
||||
await writeDetails(product.id, apiProduct, parsed);
|
||||
updated++;
|
||||
} catch (err: unknown) {
|
||||
dbErrors++;
|
||||
console.warn(
|
||||
`[${ts()}] detail-enricher DB error (${product.part_number}): ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${ts()}] Flexoptix detail enricher done: ` +
|
||||
`${batch.length} queued, ${updated} updated, ${notFound} not-in-api, ` +
|
||||
`${apiErrors} api-errors, ${dbErrors} db-errors`,
|
||||
);
|
||||
|
||||
return {
|
||||
processed: batch.length,
|
||||
updated,
|
||||
notFound,
|
||||
apiErrors,
|
||||
dbErrors,
|
||||
};
|
||||
}
|
||||
130
packages/scraper/src/robots/opn-matcher.ts
Normal file
130
packages/scraper/src/robots/opn-matcher.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* OPN-Based Equivalence Matcher
|
||||
*
|
||||
* Uses the manufacturer-provided compatibility matrix (fx_compatibilities)
|
||||
* to create high-confidence equivalences between Flexoptix products and
|
||||
* their exact OEM counterparts in competitor catalogs.
|
||||
*
|
||||
* "OPN" = OEM Part Number — the actual part number the customer buys from
|
||||
* the original manufacturer (e.g. Cisco QSFP-100G-LR4-S).
|
||||
*
|
||||
* Match quality:
|
||||
* - confidence = 1.0 (manufacturer-confirmed)
|
||||
* - match_mode = 'opn'
|
||||
* - status = 'auto_approved' (same as deterministic spec match)
|
||||
*
|
||||
* Strategy:
|
||||
* - Only processes FX products whose fx_compatibilities was updated recently
|
||||
* (detail_synced_at > last_opn_run OR last_opn_run IS NULL)
|
||||
* - Skips pairs that already have ANY status (approved, auto_approved, rejected)
|
||||
* - Case-insensitive part_number match on the competitor side
|
||||
* - Minimum OPN length = 4 chars (skips empty or trivially short entries)
|
||||
* - Excludes MSA Standard and Flexoptix self-references
|
||||
*/
|
||||
|
||||
import { pool } from "../utils/db";
|
||||
|
||||
export interface OPNMatcherResult {
|
||||
inserted: number;
|
||||
fxProductsScanned: number;
|
||||
candidatePairs: number;
|
||||
skippedExisting: number;
|
||||
}
|
||||
|
||||
// ── Queries ────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSERT_OPN_MATCHES = `
|
||||
INSERT INTO transceiver_equivalences (
|
||||
flexoptix_id,
|
||||
competitor_id,
|
||||
confidence,
|
||||
status,
|
||||
match_basis,
|
||||
match_notes,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT DISTINCT
|
||||
fx.id AS flexoptix_id,
|
||||
comp.id AS competitor_id,
|
||||
1.0 AS confidence,
|
||||
'auto_approved' AS status,
|
||||
ARRAY['opn'] AS match_basis,
|
||||
'Manufacturer-confirmed: FX compatibility matrix lists ' ||
|
||||
COALESCE(compat->>'compatible_to_vendor', '?') || ' OPN ' ||
|
||||
COALESCE(compat->>'original_part_number', '?') AS match_notes,
|
||||
NOW() AS created_at,
|
||||
NOW() AS updated_at
|
||||
FROM transceivers fx
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id AND UPPER(vfx.name) LIKE '%FLEXOPTIX%'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(fx.fx_compatibilities) AS compat
|
||||
JOIN transceivers comp
|
||||
ON UPPER(comp.part_number) = UPPER(compat->>'original_part_number')
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id AND vcomp.is_competitor = true
|
||||
WHERE fx.fx_compatibilities IS NOT NULL
|
||||
AND compat->>'original_part_number' IS NOT NULL
|
||||
AND length(trim(compat->>'original_part_number')) >= 4
|
||||
AND compat->>'compatible_to_vendor' NOT IN ('MSA Standard (Default)', 'Flexoptix')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.competitor_id = comp.id
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
const COUNT_FX_WITH_COMPAT = `
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND UPPER(v.name) LIKE '%FLEXOPTIX%'
|
||||
WHERE t.fx_compatibilities IS NOT NULL
|
||||
`;
|
||||
|
||||
const COUNT_CANDIDATE_PAIRS = `
|
||||
SELECT COUNT(DISTINCT (fx.id, comp.id)) AS cnt
|
||||
FROM transceivers fx
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id AND UPPER(vfx.name) LIKE '%FLEXOPTIX%'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(fx.fx_compatibilities) AS compat
|
||||
JOIN transceivers comp
|
||||
ON UPPER(comp.part_number) = UPPER(compat->>'original_part_number')
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id AND vcomp.is_competitor = true
|
||||
WHERE fx.fx_compatibilities IS NOT NULL
|
||||
AND compat->>'original_part_number' IS NOT NULL
|
||||
AND length(trim(compat->>'original_part_number')) >= 4
|
||||
AND compat->>'compatible_to_vendor' NOT IN ('MSA Standard (Default)', 'Flexoptix')
|
||||
`;
|
||||
|
||||
// ── Main export ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runOPNMatcher(): Promise<OPNMatcherResult> {
|
||||
const ts = () => new Date().toISOString();
|
||||
console.log(`[${ts()}] OPN Matcher starting`);
|
||||
|
||||
// Count FX products with compatibility data
|
||||
const fxRes = await pool.query<{ cnt: string }>(COUNT_FX_WITH_COMPAT);
|
||||
const fxProductsScanned = parseInt(fxRes.rows[0].cnt, 10);
|
||||
|
||||
// Count candidate pairs (informational)
|
||||
const candRes = await pool.query<{ cnt: string }>(COUNT_CANDIDATE_PAIRS);
|
||||
const candidatePairs = parseInt(candRes.rows[0].cnt, 10);
|
||||
|
||||
console.log(`[${ts()}] OPN Matcher: ${fxProductsScanned} FX products, ${candidatePairs} candidate pairs`);
|
||||
|
||||
// Insert new OPN-based equivalences
|
||||
const insertRes = await pool.query(INSERT_OPN_MATCHES);
|
||||
const inserted = insertRes.rowCount ?? 0;
|
||||
const skippedExisting = candidatePairs - inserted;
|
||||
|
||||
console.log(
|
||||
`[${ts()}] OPN Matcher done: ${inserted} new equivalences inserted ` +
|
||||
`(${skippedExisting} pairs already existed)`,
|
||||
);
|
||||
|
||||
return {
|
||||
inserted,
|
||||
fxProductsScanned,
|
||||
candidatePairs,
|
||||
skippedExisting,
|
||||
};
|
||||
}
|
||||
180
packages/scraper/src/robots/spec-matcher.ts
Normal file
180
packages/scraper/src/robots/spec-matcher.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Spec-Based Equivalence Matcher
|
||||
*
|
||||
* Matches FX products with competitor products by technical specification
|
||||
* when no OPN-based equivalence exists. Spec-matching is a fallback:
|
||||
* OPN-confirmed matches (confidence=1.0) always take priority.
|
||||
*
|
||||
* Match criteria:
|
||||
* - Same form_factor (exact)
|
||||
* - Same speed_gbps (exact)
|
||||
* - Same reach tier (SR/IR/LR/ER/ZR)
|
||||
* - Same primary wavelength within ±10nm (CWDM/WDM safe)
|
||||
* OR both have no wavelength data (broadband products)
|
||||
* - Max 30 competitor matches per FX product (safety cap)
|
||||
*
|
||||
* Match quality:
|
||||
* confidence = 0.85 (starting prior only)
|
||||
* match_basis = '{spec}'
|
||||
* status = 'pending' — unlike OPN matches (manufacturer-confirmed ground
|
||||
* truth), spec matches are a coarse JOIN with no standard_name / fiber_type /
|
||||
* recent-price discrimination, so they route into the same
|
||||
* maintenance:re-research-equivalences pipeline as catalog-reconcile.ts's
|
||||
* pending rows (evaluateEquivalenceResearch(), scheduler.ts) for real
|
||||
* confidence scoring before ever being served as an approved equivalence.
|
||||
* Found live 2026-07-17: of the equivalences this matcher had previously put
|
||||
* straight into 'auto_approved', ~19% failed a recent-competitor-price check
|
||||
* and ~8% had a conflicting standard_name — i.e. real false-positive risk
|
||||
* was going live unreviewed.
|
||||
*/
|
||||
|
||||
import { pool } from "../utils/db";
|
||||
|
||||
export interface SpecMatcherResult {
|
||||
inserted: number;
|
||||
fxProductsScanned: number;
|
||||
candidatePairs: number;
|
||||
skippedExisting: number;
|
||||
}
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSERT_SPEC_MATCHES = `
|
||||
INSERT INTO transceiver_equivalences (
|
||||
flexoptix_id,
|
||||
competitor_id,
|
||||
confidence,
|
||||
status,
|
||||
match_basis,
|
||||
match_notes,
|
||||
created_at,
|
||||
updated_at,
|
||||
re_research_due_at
|
||||
)
|
||||
SELECT DISTINCT
|
||||
fx.id AS flexoptix_id,
|
||||
comp.id AS competitor_id,
|
||||
0.85 AS confidence,
|
||||
'pending' AS status,
|
||||
ARRAY['spec'] AS match_basis,
|
||||
'Spec match: ' || fx.form_factor || ' ' || fx.speed_gbps || 'G ' ||
|
||||
CASE WHEN fx.reach_meters <= 300 THEN 'SR'
|
||||
WHEN fx.reach_meters <= 2000 THEN 'IR'
|
||||
WHEN fx.reach_meters <= 10000 THEN 'LR'
|
||||
WHEN fx.reach_meters <= 40000 THEN 'ER'
|
||||
ELSE 'ZR' END ||
|
||||
CASE WHEN tip_extract_wavelength_nm(fx.wavelengths) IS NOT NULL
|
||||
THEN ' @' || tip_extract_wavelength_nm(fx.wavelengths) || 'nm'
|
||||
ELSE '' END AS match_notes,
|
||||
NOW() AS created_at,
|
||||
NOW() AS updated_at,
|
||||
NOW() AS re_research_due_at
|
||||
FROM transceivers fx
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id AND UPPER(vfx.name) LIKE '%FLEXOPTIX%'
|
||||
JOIN transceivers comp
|
||||
ON comp.form_factor = fx.form_factor
|
||||
AND comp.speed_gbps = fx.speed_gbps
|
||||
AND comp.reach_meters >= 10
|
||||
AND tip_reach_tier(comp.reach_meters) = tip_reach_tier(fx.reach_meters)
|
||||
AND (
|
||||
(tip_extract_wavelength_nm(fx.wavelengths) IS NULL
|
||||
AND tip_extract_wavelength_nm(comp.wavelengths) IS NULL)
|
||||
OR ABS( COALESCE(tip_extract_wavelength_nm(comp.wavelengths), 0)
|
||||
- COALESCE(tip_extract_wavelength_nm(fx.wavelengths), 0) ) <= 10
|
||||
)
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id AND vcomp.is_competitor = true
|
||||
WHERE fx.reach_meters >= 10
|
||||
AND fx.speed_gbps > 0
|
||||
-- OPN match already exists → skip (spec is fallback only)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id AND 'opn' = ANY(e.match_basis)
|
||||
)
|
||||
-- Skip pairs that already have ANY equivalence
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id AND e.competitor_id = comp.id
|
||||
)
|
||||
-- Safety cap: skip if > 30 competitors would match (too generic)
|
||||
AND (
|
||||
SELECT COUNT(DISTINCT c2.id)
|
||||
FROM transceivers c2
|
||||
JOIN vendors vc2 ON vc2.id = c2.vendor_id AND vc2.is_competitor = true
|
||||
WHERE c2.form_factor = fx.form_factor
|
||||
AND c2.speed_gbps = fx.speed_gbps
|
||||
AND c2.reach_meters >= 10
|
||||
AND tip_reach_tier(c2.reach_meters) = tip_reach_tier(fx.reach_meters)
|
||||
AND (
|
||||
(tip_extract_wavelength_nm(fx.wavelengths) IS NULL
|
||||
AND tip_extract_wavelength_nm(c2.wavelengths) IS NULL)
|
||||
OR ABS( COALESCE(tip_extract_wavelength_nm(c2.wavelengths), 0)
|
||||
- COALESCE(tip_extract_wavelength_nm(fx.wavelengths), 0) ) <= 10
|
||||
)
|
||||
) <= 30
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
const COUNT_FX_WITHOUT_OPN = `
|
||||
SELECT COUNT(DISTINCT t.id) AS cnt
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND UPPER(v.name) LIKE '%FLEXOPTIX%'
|
||||
WHERE t.reach_meters >= 10
|
||||
AND t.speed_gbps > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = t.id AND 'opn' = ANY(e.match_basis)
|
||||
)
|
||||
`;
|
||||
|
||||
const COUNT_SPEC_CANDIDATES = `
|
||||
SELECT COUNT(DISTINCT (fx.id, comp.id)) AS cnt
|
||||
FROM transceivers fx
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id AND UPPER(vfx.name) LIKE '%FLEXOPTIX%'
|
||||
JOIN transceivers comp
|
||||
ON comp.form_factor = fx.form_factor
|
||||
AND comp.speed_gbps = fx.speed_gbps
|
||||
AND comp.reach_meters >= 10
|
||||
AND tip_reach_tier(comp.reach_meters) = tip_reach_tier(fx.reach_meters)
|
||||
AND (
|
||||
(tip_extract_wavelength_nm(fx.wavelengths) IS NULL
|
||||
AND tip_extract_wavelength_nm(comp.wavelengths) IS NULL)
|
||||
OR ABS( COALESCE(tip_extract_wavelength_nm(comp.wavelengths), 0)
|
||||
- COALESCE(tip_extract_wavelength_nm(fx.wavelengths), 0) ) <= 10
|
||||
)
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id AND vcomp.is_competitor = true
|
||||
WHERE fx.reach_meters >= 10
|
||||
AND fx.speed_gbps > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id AND 'opn' = ANY(e.match_basis)
|
||||
)
|
||||
`;
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runSpecMatcher(): Promise<SpecMatcherResult> {
|
||||
const ts = () => new Date().toISOString();
|
||||
console.log(`[${ts()}] Spec Matcher starting`);
|
||||
|
||||
const fxRes = await pool.query<{ cnt: string }>(COUNT_FX_WITHOUT_OPN);
|
||||
const fxProductsScanned = parseInt(fxRes.rows[0].cnt, 10);
|
||||
|
||||
const candRes = await pool.query<{ cnt: string }>(COUNT_SPEC_CANDIDATES);
|
||||
const candidatePairs = parseInt(candRes.rows[0].cnt, 10);
|
||||
|
||||
console.log(
|
||||
`[${ts()}] Spec Matcher: ${fxProductsScanned} FX products without OPN, ` +
|
||||
`${candidatePairs} spec candidate pairs`,
|
||||
);
|
||||
|
||||
const insertRes = await pool.query(INSERT_SPEC_MATCHES);
|
||||
const inserted = insertRes.rowCount ?? 0;
|
||||
const skippedExisting = candidatePairs - inserted;
|
||||
|
||||
console.log(
|
||||
`[${ts()}] Spec Matcher done: ${inserted} new spec equivalences inserted ` +
|
||||
`(${skippedExisting} pairs already existed or capped)`,
|
||||
);
|
||||
|
||||
return { inserted, fxProductsScanned, candidatePairs, skippedExisting };
|
||||
}
|
||||
487
packages/scraper/src/robots/stock-velocity-analyzer.ts
Normal file
487
packages/scraper/src/robots/stock-velocity-analyzer.ts
Normal file
@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Stock Velocity Analyzer — Abverkauf & Zulauf Evaluation
|
||||
*
|
||||
* Processes time-series stock_observations to compute:
|
||||
* • avg_daily_sell_rate — implied units sold per day
|
||||
* • total_units_sold — implied cumulative sold in window
|
||||
* • Zulauf events — when and how much stock was replenished
|
||||
* • estimated_stockout_date — when current stock is expected to run out
|
||||
*
|
||||
* Data sources ranked by confidence:
|
||||
* 3 = FS.com — per-warehouse breakdown + units_sold counter
|
||||
* 2 = QSFPTEK — global real-time quantity
|
||||
* 1 = ATGBICS/Optcore — binary in/out stock only (skipped for velocity)
|
||||
*
|
||||
* Velocity is only computed for confidence ≥ 2 sources.
|
||||
*
|
||||
* Called by pg-boss job "analyze:stock:velocity".
|
||||
*/
|
||||
|
||||
import { pool } from "../utils/db";
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MIN_OBS_FOR_VELOCITY = 2; // absolute minimum observations
|
||||
const MAX_INTERVAL_HOURS = 96; // ignore gaps > 4 days (data outage, not a sale)
|
||||
const MIN_INTERVAL_HOURS = 0.4; // ignore observations < 24min apart (duplicates)
|
||||
const WINDOW_DAYS = 30; // look back this many days for velocity calc
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StockObs {
|
||||
time: Date;
|
||||
physicalQty: number; // warehouse_de_qty + warehouse_global_qty
|
||||
quantityAvailable: number | null;
|
||||
backorderQty: number | null;
|
||||
backorderEstimatedDate: string | null;
|
||||
unitsSold: number | null;
|
||||
priceNet: number | null;
|
||||
}
|
||||
|
||||
interface VelocityEvent {
|
||||
transceiverId: string;
|
||||
vendorId: string;
|
||||
eventAt: Date;
|
||||
eventType: "sold" | "zulauf" | "unchanged" | "data_gap";
|
||||
unitsDelta: number;
|
||||
dailyRate: number | null;
|
||||
qtyBefore: number;
|
||||
qtyAfter: number;
|
||||
hoursElapsed: number;
|
||||
}
|
||||
|
||||
interface VelocityResult {
|
||||
transceiverId: string;
|
||||
vendorId: string;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
obsCount: number;
|
||||
avgDailySellRate: number | null;
|
||||
peakDailySellRate: number | null;
|
||||
totalSellEvents: number;
|
||||
totalUnitsSoldImplied: number;
|
||||
unitsSoldCounterDelta: number | null;
|
||||
unitsSoldDailyRate: number | null;
|
||||
totalZulaufEvents: number;
|
||||
totalUnitsZulauf: number;
|
||||
lastZulaufAt: Date | null;
|
||||
nextExpectedDelivery: string | null;
|
||||
currentQty: number | null;
|
||||
currentBackorderQty: number | null;
|
||||
currentPriceNet: number | null;
|
||||
estimatedStockoutDays: number | null;
|
||||
estimatedStockoutDate: Date | null;
|
||||
velocityConfidence: "high" | "medium" | "low" | "insufficient";
|
||||
events: VelocityEvent[];
|
||||
}
|
||||
|
||||
// ── Core velocity computation ──────────────────────────────────────────────────
|
||||
|
||||
function computeVelocity(
|
||||
transceiverId: string,
|
||||
vendorId: string,
|
||||
observations: StockObs[]
|
||||
): VelocityResult {
|
||||
const sorted = [...observations].sort((a, b) => a.time.getTime() - b.time.getTime());
|
||||
|
||||
const windowStart = sorted[0].time;
|
||||
const windowEnd = sorted[sorted.length - 1].time;
|
||||
const windowDays = Math.max(1, (windowEnd.getTime() - windowStart.getTime()) / 86400000);
|
||||
|
||||
const latest = sorted[sorted.length - 1];
|
||||
const earliest = sorted[0];
|
||||
|
||||
// ── FS.com units_sold counter delta (most reliable) ──────────────────────
|
||||
let unitsSoldCounterDelta: number | null = null;
|
||||
let unitsSoldDailyRate: number | null = null;
|
||||
|
||||
const firstWithSold = sorted.find((o) => o.unitsSold !== null && o.unitsSold > 0);
|
||||
const lastWithSold = [...sorted].reverse().find((o) => o.unitsSold !== null && o.unitsSold > 0);
|
||||
|
||||
if (firstWithSold && lastWithSold && firstWithSold !== lastWithSold) {
|
||||
const delta = (lastWithSold.unitsSold ?? 0) - (firstWithSold.unitsSold ?? 0);
|
||||
// Sanity: delta should be positive and not unrealistically large (>10x multiplier = format glitch)
|
||||
const spanDays = Math.max(1, (lastWithSold.time.getTime() - firstWithSold.time.getTime()) / 86400000);
|
||||
if (delta > 0 && delta < (firstWithSold.unitsSold ?? 1) * 5) {
|
||||
unitsSoldCounterDelta = delta;
|
||||
unitsSoldDailyRate = delta / spanDays;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interval-by-interval delta analysis ──────────────────────────────────
|
||||
const events: VelocityEvent[] = [];
|
||||
const sellRates: number[] = [];
|
||||
let totalSellEvents = 0;
|
||||
let totalUnitsSoldImplied = 0;
|
||||
let totalZulaufEvents = 0;
|
||||
let totalUnitsZulauf = 0;
|
||||
let lastZulaufAt: Date | null = null;
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1];
|
||||
const curr = sorted[i];
|
||||
|
||||
const hoursElapsed = (curr.time.getTime() - prev.time.getTime()) / 3600000;
|
||||
|
||||
// Skip too-close (duplicates) or too-far (outages)
|
||||
if (hoursElapsed < MIN_INTERVAL_HOURS) continue;
|
||||
if (hoursElapsed > MAX_INTERVAL_HOURS) {
|
||||
events.push({
|
||||
transceiverId, vendorId,
|
||||
eventAt: curr.time,
|
||||
eventType: "data_gap",
|
||||
unitsDelta: 0,
|
||||
dailyRate: null,
|
||||
qtyBefore: prev.physicalQty,
|
||||
qtyAfter: curr.physicalQty,
|
||||
hoursElapsed,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = curr.physicalQty - prev.physicalQty;
|
||||
const daysFraction = hoursElapsed / 24;
|
||||
|
||||
if (delta < 0) {
|
||||
// Stock decreased → implied sales
|
||||
const unitsSold = -delta;
|
||||
const dailyRate = unitsSold / daysFraction;
|
||||
|
||||
events.push({
|
||||
transceiverId, vendorId,
|
||||
eventAt: curr.time,
|
||||
eventType: "sold",
|
||||
unitsDelta: delta, // negative
|
||||
dailyRate,
|
||||
qtyBefore: prev.physicalQty,
|
||||
qtyAfter: curr.physicalQty,
|
||||
hoursElapsed,
|
||||
});
|
||||
|
||||
sellRates.push(dailyRate);
|
||||
totalSellEvents++;
|
||||
totalUnitsSoldImplied += unitsSold;
|
||||
|
||||
} else if (delta > 0) {
|
||||
// Stock increased → Zulauf (replenishment or restock)
|
||||
events.push({
|
||||
transceiverId, vendorId,
|
||||
eventAt: curr.time,
|
||||
eventType: "zulauf",
|
||||
unitsDelta: delta, // positive
|
||||
dailyRate: null,
|
||||
qtyBefore: prev.physicalQty,
|
||||
qtyAfter: curr.physicalQty,
|
||||
hoursElapsed,
|
||||
});
|
||||
|
||||
totalZulaufEvents++;
|
||||
totalUnitsZulauf += delta;
|
||||
lastZulaufAt = curr.time;
|
||||
|
||||
} else {
|
||||
events.push({
|
||||
transceiverId, vendorId,
|
||||
eventAt: curr.time,
|
||||
eventType: "unchanged",
|
||||
unitsDelta: 0,
|
||||
dailyRate: null,
|
||||
qtyBefore: prev.physicalQty,
|
||||
qtyAfter: curr.physicalQty,
|
||||
hoursElapsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compute average sell rate ─────────────────────────────────────────────
|
||||
let avgDailySellRate: number | null = null;
|
||||
let peakDailySellRate: number | null = null;
|
||||
|
||||
if (sellRates.length > 0) {
|
||||
// Use trimmed mean (remove top 10% outliers to avoid one-off bulk events)
|
||||
const sorted_rates = [...sellRates].sort((a, b) => a - b);
|
||||
const trimCount = Math.max(0, Math.floor(sorted_rates.length * 0.1));
|
||||
const trimmed = sorted_rates.slice(0, sorted_rates.length - trimCount);
|
||||
avgDailySellRate = trimmed.reduce((s, r) => s + r, 0) / trimmed.length;
|
||||
peakDailySellRate = sorted_rates[sorted_rates.length - 1];
|
||||
}
|
||||
|
||||
// Prefer units_sold daily rate from FS.com counter (more reliable)
|
||||
const effectiveRate = unitsSoldDailyRate ?? avgDailySellRate;
|
||||
|
||||
// ── Current state from latest observation ────────────────────────────────
|
||||
const currentQty = latest.physicalQty;
|
||||
const currentBackorderQty = latest.backorderQty;
|
||||
const currentPriceNet = latest.priceNet;
|
||||
const nextExpectedDelivery = latest.backorderEstimatedDate;
|
||||
|
||||
// ── Stockout prediction ───────────────────────────────────────────────────
|
||||
let estimatedStockoutDays: number | null = null;
|
||||
let estimatedStockoutDate: Date | null = null;
|
||||
|
||||
if (effectiveRate !== null && effectiveRate > 0 && currentQty !== null && currentQty > 0) {
|
||||
estimatedStockoutDays = currentQty / effectiveRate;
|
||||
estimatedStockoutDate = new Date(
|
||||
latest.time.getTime() + estimatedStockoutDays * 86400000
|
||||
);
|
||||
} else if (currentQty === 0) {
|
||||
estimatedStockoutDays = 0;
|
||||
estimatedStockoutDate = latest.time;
|
||||
}
|
||||
|
||||
// ── Confidence assessment ─────────────────────────────────────────────────
|
||||
const meaningfulObs = sorted.length;
|
||||
let velocityConfidence: "high" | "medium" | "low" | "insufficient";
|
||||
|
||||
if (meaningfulObs >= 14 && (totalSellEvents >= 3 || unitsSoldCounterDelta !== null)) {
|
||||
velocityConfidence = "high";
|
||||
} else if (meaningfulObs >= 5 && totalSellEvents >= 1) {
|
||||
velocityConfidence = "medium";
|
||||
} else if (meaningfulObs >= 2) {
|
||||
velocityConfidence = "low";
|
||||
} else {
|
||||
velocityConfidence = "insufficient";
|
||||
}
|
||||
|
||||
return {
|
||||
transceiverId,
|
||||
vendorId,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
obsCount: meaningfulObs,
|
||||
avgDailySellRate,
|
||||
peakDailySellRate,
|
||||
totalSellEvents,
|
||||
totalUnitsSoldImplied,
|
||||
unitsSoldCounterDelta,
|
||||
unitsSoldDailyRate,
|
||||
totalZulaufEvents,
|
||||
totalUnitsZulauf,
|
||||
lastZulaufAt,
|
||||
nextExpectedDelivery: nextExpectedDelivery ?? null,
|
||||
currentQty,
|
||||
currentBackorderQty: currentBackorderQty ?? null,
|
||||
currentPriceNet,
|
||||
estimatedStockoutDays,
|
||||
estimatedStockoutDate,
|
||||
velocityConfidence,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Database I/O ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchObservations(
|
||||
vendorId: string
|
||||
): Promise<Map<string, StockObs[]>> {
|
||||
const result = await pool.query<{
|
||||
transceiver_id: string;
|
||||
time: Date;
|
||||
warehouse_de_qty: number | null;
|
||||
warehouse_global_qty: number | null;
|
||||
quantity_available: number | null;
|
||||
backorder_qty: number | null;
|
||||
backorder_estimated_date: string | null;
|
||||
units_sold: number | null;
|
||||
price_net: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
transceiver_id,
|
||||
time,
|
||||
warehouse_de_qty,
|
||||
warehouse_global_qty,
|
||||
quantity_available,
|
||||
backorder_qty,
|
||||
backorder_estimated_date::text,
|
||||
units_sold,
|
||||
price_net
|
||||
FROM stock_observations
|
||||
WHERE source_vendor_id = $1
|
||||
AND stock_confidence >= 2
|
||||
AND time >= NOW() - INTERVAL '${WINDOW_DAYS} days'
|
||||
ORDER BY transceiver_id, time`,
|
||||
[vendorId]
|
||||
);
|
||||
|
||||
const byProduct = new Map<string, StockObs[]>();
|
||||
for (const row of result.rows) {
|
||||
const obs: StockObs = {
|
||||
time: row.time,
|
||||
physicalQty:
|
||||
(row.warehouse_de_qty ?? 0) + (row.warehouse_global_qty ?? 0) ||
|
||||
(row.quantity_available ?? 0),
|
||||
quantityAvailable: row.quantity_available,
|
||||
backorderQty: row.backorder_qty,
|
||||
backorderEstimatedDate: row.backorder_estimated_date,
|
||||
unitsSold: row.units_sold,
|
||||
priceNet: row.price_net,
|
||||
};
|
||||
const list = byProduct.get(row.transceiver_id) ?? [];
|
||||
list.push(obs);
|
||||
byProduct.set(row.transceiver_id, list);
|
||||
}
|
||||
return byProduct;
|
||||
}
|
||||
|
||||
async function upsertVelocityResult(r: VelocityResult): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO stock_velocity (
|
||||
transceiver_id, vendor_id, computed_at,
|
||||
window_start, window_end, obs_count,
|
||||
avg_daily_sell_rate, peak_daily_sell_rate,
|
||||
total_sell_events, total_units_sold_implied,
|
||||
units_sold_counter_delta, units_sold_daily_rate,
|
||||
total_zulauf_events, total_units_zulauf,
|
||||
last_zulauf_at, next_expected_delivery,
|
||||
current_qty, current_backorder_qty, current_price_net,
|
||||
estimated_stockout_days, estimated_stockout_date,
|
||||
velocity_confidence
|
||||
) VALUES (
|
||||
$1, $2, NOW(),
|
||||
$3, $4, $5,
|
||||
$6, $7,
|
||||
$8, $9,
|
||||
$10, $11,
|
||||
$12, $13,
|
||||
$14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20,
|
||||
$21
|
||||
)
|
||||
ON CONFLICT (transceiver_id, vendor_id) DO UPDATE SET
|
||||
computed_at = EXCLUDED.computed_at,
|
||||
window_start = EXCLUDED.window_start,
|
||||
window_end = EXCLUDED.window_end,
|
||||
obs_count = EXCLUDED.obs_count,
|
||||
avg_daily_sell_rate = EXCLUDED.avg_daily_sell_rate,
|
||||
peak_daily_sell_rate = EXCLUDED.peak_daily_sell_rate,
|
||||
total_sell_events = EXCLUDED.total_sell_events,
|
||||
total_units_sold_implied = EXCLUDED.total_units_sold_implied,
|
||||
units_sold_counter_delta = EXCLUDED.units_sold_counter_delta,
|
||||
units_sold_daily_rate = EXCLUDED.units_sold_daily_rate,
|
||||
total_zulauf_events = EXCLUDED.total_zulauf_events,
|
||||
total_units_zulauf = EXCLUDED.total_units_zulauf,
|
||||
last_zulauf_at = EXCLUDED.last_zulauf_at,
|
||||
next_expected_delivery = EXCLUDED.next_expected_delivery,
|
||||
current_qty = EXCLUDED.current_qty,
|
||||
current_backorder_qty = EXCLUDED.current_backorder_qty,
|
||||
current_price_net = EXCLUDED.current_price_net,
|
||||
estimated_stockout_days = EXCLUDED.estimated_stockout_days,
|
||||
estimated_stockout_date = EXCLUDED.estimated_stockout_date,
|
||||
velocity_confidence = EXCLUDED.velocity_confidence`,
|
||||
[
|
||||
r.transceiverId, r.vendorId,
|
||||
r.windowStart, r.windowEnd, r.obsCount,
|
||||
r.avgDailySellRate, r.peakDailySellRate,
|
||||
r.totalSellEvents, r.totalUnitsSoldImplied,
|
||||
r.unitsSoldCounterDelta, r.unitsSoldDailyRate,
|
||||
r.totalZulaufEvents, r.totalUnitsZulauf,
|
||||
r.lastZulaufAt, r.nextExpectedDelivery,
|
||||
r.currentQty, r.currentBackorderQty, r.currentPriceNet,
|
||||
r.estimatedStockoutDays, r.estimatedStockoutDate,
|
||||
r.velocityConfidence,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function insertVelocityEvents(events: VelocityEvent[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
|
||||
// Deduplicate against existing events (don't re-insert known events)
|
||||
const minTime = events.reduce((min, e) => e.eventAt < min ? e.eventAt : min, events[0].eventAt);
|
||||
const txId = events[0].transceiverId;
|
||||
const vendorId = events[0].vendorId;
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM stock_velocity_events
|
||||
WHERE transceiver_id = $1 AND vendor_id = $2 AND event_at >= $3`,
|
||||
[txId, vendorId, minTime]
|
||||
);
|
||||
|
||||
for (const e of events) {
|
||||
await pool.query(
|
||||
`INSERT INTO stock_velocity_events
|
||||
(transceiver_id, vendor_id, event_at, event_type, units_delta, daily_rate,
|
||||
qty_before, qty_after, hours_elapsed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[
|
||||
e.transceiverId, e.vendorId, e.eventAt, e.eventType,
|
||||
e.unitsDelta, e.dailyRate, e.qtyBefore, e.qtyAfter, e.hoursElapsed,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function analyzeStockVelocity(): Promise<void> {
|
||||
console.log("=== Stock Velocity Analyzer starting ===\n");
|
||||
|
||||
// Find all vendors with confidence >= 2 stock data
|
||||
const vendorResult = await pool.query<{ id: string; name: string }>(
|
||||
`SELECT DISTINCT v.id, v.name
|
||||
FROM stock_observations so
|
||||
JOIN vendors v ON v.id = so.source_vendor_id
|
||||
WHERE so.stock_confidence >= 2
|
||||
AND so.time >= NOW() - INTERVAL '${WINDOW_DAYS} days'
|
||||
ORDER BY v.name`
|
||||
);
|
||||
|
||||
let totalProducts = 0;
|
||||
let totalSellEvents = 0;
|
||||
let totalZulaufEvents = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const vendor of vendorResult.rows) {
|
||||
console.log(`\n[${vendor.name}] Loading observations…`);
|
||||
const obsMap = await fetchObservations(vendor.id);
|
||||
|
||||
let vProducts = 0;
|
||||
let vSellEvents = 0;
|
||||
let vZulaufEvents = 0;
|
||||
|
||||
for (const [transceiverId, observations] of obsMap) {
|
||||
if (observations.length < MIN_OBS_FOR_VELOCITY) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = computeVelocity(transceiverId, vendor.id, observations);
|
||||
|
||||
await upsertVelocityResult(result);
|
||||
await insertVelocityEvents(result.events);
|
||||
|
||||
vProducts++;
|
||||
vSellEvents += result.totalSellEvents;
|
||||
vZulaufEvents += result.totalZulaufEvents;
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ${vProducts} products | ` +
|
||||
`${vSellEvents} sell events | ` +
|
||||
`${vZulaufEvents} Zulauf events`
|
||||
);
|
||||
|
||||
totalProducts += vProducts;
|
||||
totalSellEvents += vSellEvents;
|
||||
totalZulaufEvents += vZulaufEvents;
|
||||
}
|
||||
|
||||
console.log("\n=== Stock Velocity Analyzer complete ===");
|
||||
console.log(` Vendors analyzed: ${vendorResult.rows.length}`);
|
||||
console.log(` Products analyzed: ${totalProducts}`);
|
||||
console.log(` Sell events: ${totalSellEvents}`);
|
||||
console.log(` Zulauf events: ${totalZulaufEvents}`);
|
||||
if (skipped > 0) console.log(` Skipped (<${MIN_OBS_FOR_VELOCITY} obs): ${skipped}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
analyzeStockVelocity()
|
||||
.then(() => pool.end())
|
||||
.catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
pool.end();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@ -20,11 +20,12 @@ import { join } from "path";
|
||||
import PgBoss from "pg-boss";
|
||||
import { pool } from "../utils/db";
|
||||
import { writeRobotExperience } from "../crawler-llm/training-data-writer";
|
||||
import { dbConnectionString } from "../utils/db-connection";
|
||||
|
||||
config({ path: join(__dirname, "..", "..", "..", "..", ".env") });
|
||||
|
||||
type RobotWave = "details-fast-lane" | "priority-vendors" | "all";
|
||||
type RobotProfile = "erik-safe" | "pi-fetch" | "proxmox-heavy";
|
||||
export type RobotProfile = "erik-safe" | "pi-fetch" | "proxmox-heavy";
|
||||
|
||||
interface VendorBlockerRow {
|
||||
vendor: string;
|
||||
@ -49,7 +50,7 @@ interface TipLlmResearchPlanResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const connectionString = `postgres://${process.env.POSTGRES_USER || "tip"}:${process.env.POSTGRES_PASSWORD || "tip_dev_2026"}@${process.env.POSTGRES_HOST || "localhost"}:${process.env.POSTGRES_PORT || "5433"}/${process.env.POSTGRES_DB || "transceiver_db"}`;
|
||||
const connectionString = dbConnectionString();
|
||||
const TIP_API_URL = process.env.TIP_API_URL || "http://127.0.0.1:3201";
|
||||
const TIP_API_TOKEN = process.env.TIP_API_TOKEN || process.env.TIP_TOKEN || "";
|
||||
|
||||
@ -64,7 +65,7 @@ const DETAILS_FAST_LANE_QUEUES = [
|
||||
"maintenance:reconcile-verification",
|
||||
];
|
||||
|
||||
const VENDOR_QUEUE_MAP: Array<{ match: RegExp; queues: string[] }> = [
|
||||
export const VENDOR_QUEUE_MAP: Array<{ match: RegExp; queues: string[] }> = [
|
||||
{ match: /juniper/i, queues: ["scrape:catalog:juniper-oem", "scrape:catalog:juniper-mx-oem", "scrape:catalog:juniper-qfx-oem", "scrape:compat:juniper", "discover:vendor:juniper"] },
|
||||
{ match: /cisco/i, queues: ["scrape:catalog:cisco-nexus-oem", "scrape:catalog:cisco-catalyst-oem", "scrape:catalog:cisco-asr-oem", "scrape:compat:cisco", "discover:vendor:cisco-tmg"] },
|
||||
{ match: /fs\.?com/i, queues: ["scrape:pricing:fs", "discover:vendor:fs-com"] },
|
||||
@ -83,7 +84,7 @@ const VENDOR_QUEUE_MAP: Array<{ match: RegExp; queues: string[] }> = [
|
||||
{ match: /ciena/i, queues: ["scrape:catalog:ciena-oem", "scrape:catalog:ciena-waveserver-oem"] },
|
||||
];
|
||||
|
||||
const HEAVY_QUEUES = new Set([
|
||||
export const HEAVY_QUEUES = new Set([
|
||||
"scrape:pricing:fs",
|
||||
"scrape:pricing:10gtek",
|
||||
"scrape:pricing:prolabs",
|
||||
@ -93,7 +94,7 @@ const HEAVY_QUEUES = new Set([
|
||||
"scrape:compat:edgecore",
|
||||
]);
|
||||
|
||||
const ERIK_SAFE_QUEUES = new Set([
|
||||
export const ERIK_SAFE_QUEUES = new Set([
|
||||
"scrape:pricing:flexoptix",
|
||||
"scrape:pricing:fibermall",
|
||||
"scrape:pricing:atgbics",
|
||||
@ -104,11 +105,11 @@ const ERIK_SAFE_QUEUES = new Set([
|
||||
"maintenance:reconcile-verification",
|
||||
]);
|
||||
|
||||
function isDiscoveryQueue(queue: string): boolean {
|
||||
export function isDiscoveryQueue(queue: string): boolean {
|
||||
return queue.startsWith("discover:");
|
||||
}
|
||||
|
||||
function queuesForProfile(queues: string[], profile: RobotProfile): string[] {
|
||||
export function queuesForProfile(queues: string[], profile: RobotProfile): string[] {
|
||||
if (profile === "proxmox-heavy") return queues;
|
||||
|
||||
if (profile === "erik-safe") {
|
||||
@ -118,7 +119,7 @@ function queuesForProfile(queues: string[], profile: RobotProfile): string[] {
|
||||
return queues.filter((queue) => !HEAVY_QUEUES.has(queue) && !isDiscoveryQueue(queue));
|
||||
}
|
||||
|
||||
function defaultMaxQueues(profile: RobotProfile): number {
|
||||
export function defaultMaxQueues(profile: RobotProfile): number {
|
||||
if (profile === "erik-safe") return 3;
|
||||
if (profile === "pi-fetch") return 10;
|
||||
return 30;
|
||||
@ -132,7 +133,7 @@ function unique(items: string[]): string[] {
|
||||
return [...new Set(items)];
|
||||
}
|
||||
|
||||
async function createBoss(): Promise<PgBoss> {
|
||||
export async function createBoss(): Promise<PgBoss> {
|
||||
const boss = new PgBoss({
|
||||
connectionString,
|
||||
retryLimit: 1,
|
||||
@ -145,6 +146,32 @@ async function createBoss(): Promise<PgBoss> {
|
||||
return boss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single shared dispatch primitive: create each queue if needed, then send one job per
|
||||
* queue with the standard safety options (retryLimit 1, 2h expiry). Used by BOTH
|
||||
* enqueueRobotWave and the tip-dqo orchestrator so there is exactly ONE boss.send path
|
||||
* with identical safety options across the whole platform.
|
||||
*/
|
||||
export async function dispatchQueues(
|
||||
queues: string[],
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (queues.length === 0) return;
|
||||
const boss = await createBoss();
|
||||
try {
|
||||
for (const queue of queues) {
|
||||
await boss.createQueue(queue).catch(() => {});
|
||||
await (boss as unknown as { send: (name: string, data?: object, options?: object) => Promise<string | null> }).send(
|
||||
queue,
|
||||
data,
|
||||
{ retryLimit: 1, expireInSeconds: 7200 },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await boss.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVerificationStatus(): Promise<{ summary: Record<string, string>; vendors: VendorBlockerRow[] }> {
|
||||
const summaryResult = await pool.query(`
|
||||
SELECT
|
||||
@ -260,19 +287,12 @@ export async function enqueueRobotWave(
|
||||
return queues;
|
||||
}
|
||||
|
||||
const boss = await createBoss();
|
||||
try {
|
||||
for (const queue of queues) {
|
||||
await boss.createQueue(queue).catch(() => {});
|
||||
await (boss as unknown as { send: (name: string, data?: object, options?: object) => Promise<string | null> }).send(
|
||||
queue,
|
||||
{ source: "verification-robots", wave, run_id: runId, enqueued_at: new Date().toISOString() },
|
||||
{ retryLimit: 1, expireInSeconds: 7200 },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await boss.stop();
|
||||
}
|
||||
await dispatchQueues(queues, {
|
||||
source: "verification-robots",
|
||||
wave,
|
||||
run_id: runId,
|
||||
enqueued_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log("\nJobs enqueued.");
|
||||
writeRobotExperience({
|
||||
|
||||
@ -22,6 +22,7 @@ import PgBoss from "pg-boss";
|
||||
import { config } from "dotenv";
|
||||
import { join } from "path";
|
||||
import { loadavg } from "os";
|
||||
import { dbConnectionString } from "./utils/db-connection";
|
||||
|
||||
// withIsolatedStorage removed — all Crawlee scrapers now use makeCrawleeConfig()
|
||||
// for instance-level storage isolation. See packages/scraper/src/utils/crawlee-config.ts
|
||||
@ -42,7 +43,7 @@ function isLoadAcceptable(maxLoad = 2.5): boolean {
|
||||
|
||||
config({ path: join(__dirname, "..", "..", "..", ".env") });
|
||||
|
||||
const connectionString = `postgres://${process.env.POSTGRES_USER || "tip"}:${process.env.POSTGRES_PASSWORD || "tip_dev_2026"}@${process.env.POSTGRES_HOST || "localhost"}:${process.env.POSTGRES_PORT || "5433"}/${process.env.POSTGRES_DB || "transceiver_db"}`;
|
||||
const connectionString = dbConnectionString();
|
||||
|
||||
type EquivalenceProduct = {
|
||||
part_number?: string | null;
|
||||
@ -356,6 +357,14 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
|
||||
"discover:vendor:ii-vi",
|
||||
// ── Wavelength Enrichment ────────────────────────────────────────────
|
||||
"enrich:wavelength",
|
||||
// ── Flexoptix Detail Enrichment ──────────────────────────────────────
|
||||
"enrich:flexoptix-details",
|
||||
// ── OPN-Based Equivalence Matcher ────────────────────────────────────
|
||||
"match:opn",
|
||||
// ── Spec-Based Equivalence Matcher ───────────────────────────────────
|
||||
"match:spec",
|
||||
// ── Stock Velocity / Abverkauf Analyzer ──────────────────────────────
|
||||
"analyze:stock:velocity",
|
||||
];
|
||||
|
||||
for (const q of queues) {
|
||||
@ -425,6 +434,34 @@ export async function registerSchedules(boss: PgBoss): Promise<void> {
|
||||
// Wavelength Enricher — läuft alle 4 Stunden
|
||||
await boss.schedule("enrich:wavelength", "0 */4 * * *", {}, {});
|
||||
|
||||
// Flexoptix Detail Enricher — täglich 03:00 UTC, 100 SKUs/Run
|
||||
// Full catalog (~1100 SKUs) rotiert in ~11 Tagen, dann weekly refresh
|
||||
await boss.schedule("enrich:flexoptix-details", "0 3 * * *", {}, {
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 7200,
|
||||
});
|
||||
|
||||
// OPN Matcher — täglich 04:00 UTC (nach Detail Enricher)
|
||||
// Nutzt fx_compatibilities für manufacturer-confirmed Equivalenzen (confidence=1.0)
|
||||
await boss.schedule("match:opn", "0 4 * * *", {}, {
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 1800,
|
||||
});
|
||||
|
||||
// Spec Matcher — täglich 04:30 UTC (nach OPN Matcher)
|
||||
// Fallback: form_factor + speed + reach-tier + wavelength (confidence=0.85)
|
||||
await boss.schedule("match:spec", "30 4 * * *", {}, {
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 1800,
|
||||
});
|
||||
|
||||
// Stock Velocity / Abverkauf Analyzer — 3x täglich (nach FS.com + QSFPTEK Scrapes)
|
||||
// Läuft nach den Haupt-Stock-Scrapes: 04:30, 12:30, 20:30 UTC
|
||||
await boss.schedule("analyze:stock:velocity", "30 4,12,20 * * *", {}, {
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 1800,
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// MANUFACTURER CATALOGS — every 4h (product data, no prices)
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
@ -932,6 +969,51 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
|
||||
await runWavelengthEnricher();
|
||||
});
|
||||
|
||||
// OPN Matcher — manufacturer-confirmed equivalences via fx_compatibilities
|
||||
await boss.work("match:opn", async () => {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] Running: OPN Matcher`);
|
||||
const { runOPNMatcher } = await import("./robots/opn-matcher");
|
||||
const result = await runOPNMatcher();
|
||||
console.log(
|
||||
`[match:opn] Done: ${result.inserted} new equivalences, ` +
|
||||
`${result.candidatePairs} total pairs, ${result.fxProductsScanned} FX products`,
|
||||
);
|
||||
});
|
||||
|
||||
// Spec-Based Equivalence Matcher — form_factor + speed + reach-tier + wavelength
|
||||
await boss.work("match:spec", async () => {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] Running: Spec Matcher`);
|
||||
const { runSpecMatcher } = await import("./robots/spec-matcher");
|
||||
const result = await runSpecMatcher();
|
||||
console.log(
|
||||
`[match:spec] Done: ${result.inserted} new spec equivalences, ` +
|
||||
`${result.candidatePairs} candidate pairs, ${result.fxProductsScanned} FX products scanned`,
|
||||
);
|
||||
});
|
||||
|
||||
// Stock Velocity / Abverkauf Analyzer
|
||||
await boss.work("analyze:stock:velocity", async () => {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] Running: Stock Velocity Analyzer`);
|
||||
const { analyzeStockVelocity } = await import("./robots/stock-velocity-analyzer");
|
||||
await analyzeStockVelocity();
|
||||
console.log(`[${ts}] Stock Velocity Analyzer complete`);
|
||||
});
|
||||
|
||||
// Flexoptix Detail Enricher — fetches full specs + compat from API per SKU
|
||||
await boss.work("enrich:flexoptix-details", async () => {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] Running: Flexoptix Detail Enricher`);
|
||||
const { runFlexoptixDetailEnricher } = await import("./robots/flexoptix-detail-enricher");
|
||||
const result = await runFlexoptixDetailEnricher();
|
||||
console.log(
|
||||
`[enrich:flexoptix-details] Done: ${result.processed} queued, ` +
|
||||
`${result.updated} updated, ${result.notFound} not-in-api, ${result.apiErrors} api-errors`,
|
||||
);
|
||||
});
|
||||
|
||||
await boss.work("scrape:catalog:smartoptics", async () => {
|
||||
console.log(`[${new Date().toISOString()}] Running: SmartOptics catalog`);
|
||||
await scrapeSmartOptics();
|
||||
@ -2745,195 +2827,269 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
|
||||
await boss.work("maintenance:find-equivalences", async () => {
|
||||
const { pool } = await import("./utils/db");
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] Running: Equivalence matching`);
|
||||
console.log(`[${ts}] Running: Deterministic Equivalence Matching`);
|
||||
|
||||
// Find Flexoptix transceivers whose competitor research is still open.
|
||||
// Terminal product-level states are not manual-review work and must not
|
||||
// recreate stale pending equivalence candidates.
|
||||
// ── Load Flexoptix transceivers (all, including already-verified) ───────────
|
||||
// Re-process all FX products so deterministic matches at 1.0 confidence can
|
||||
// replace any old confidence-based auto_approved records.
|
||||
const flexResult = await pool.query(`
|
||||
SELECT t.id, t.part_number, t.standard_name, t.form_factor,
|
||||
t.speed_gbps, t.fiber_type, t.reach_meters, t.wavelengths,
|
||||
t.connector, t.wdm_type, t.coherent
|
||||
t.wavelength_tx_nm, t.wavelength_rx_nm, t.connector_type,
|
||||
t.data_completeness, t.enrichment_needed,
|
||||
t.wdm_type, t.coherent
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%'
|
||||
AND t.competitor_verified = false
|
||||
AND COALESCE(t.competitor_status, 'needs_research') IN ('unknown', 'needs_research')
|
||||
AND t.form_factor IS NOT NULL
|
||||
AND t.speed_gbps IS NOT NULL
|
||||
ORDER BY t.data_completeness DESC, t.part_number
|
||||
`);
|
||||
|
||||
let autoApproved = 0;
|
||||
let queued = 0;
|
||||
let skipped = 0;
|
||||
let autoApprovedDeterministic = 0; // 6-field exact match (confidence = 1.0)
|
||||
let autoApprovedEnhanced = 0; // enhanced confidence ≥ 0.85 (incomplete data)
|
||||
let skippedIncomplete = 0; // both products have complete data but no field match
|
||||
let skippedLowConf = 0; // incomplete products below 0.85 threshold
|
||||
// NOTE: pending status is NEVER created — system creates auto_approved or skips
|
||||
|
||||
for (const fx of flexResult.rows) {
|
||||
let fxMatched = false;
|
||||
let fxQueued = false;
|
||||
// Find competitor transceivers with recent price observations and matching specs
|
||||
if (!fx.form_factor || !fx.speed_gbps) continue;
|
||||
|
||||
// ── Load competitor candidates (same form_factor + speed_gbps) ──────────
|
||||
const candidates = await pool.query(`
|
||||
SELECT t.id AS competitor_id, t.part_number, t.standard_name,
|
||||
t.form_factor, t.speed_gbps, t.fiber_type, t.reach_meters,
|
||||
t.wavelengths, t.connector, v.name AS vendor_name,
|
||||
t.wavelengths, t.wavelength_tx_nm, t.wavelength_rx_nm,
|
||||
t.connector_type, t.data_completeness,
|
||||
v.name AS vendor_name,
|
||||
MAX(po.time) AS last_price, COUNT(*) AS price_count
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
JOIN price_observations po ON po.transceiver_id = t.id
|
||||
WHERE UPPER(v.name) NOT LIKE '%FLEXOPTIX%'
|
||||
AND v.is_competitor = true
|
||||
AND po.time > NOW() - INTERVAL '90 days'
|
||||
AND UPPER(t.form_factor) = UPPER($1)
|
||||
AND ROUND(t.speed_gbps::NUMERIC, 2) = ROUND($2::NUMERIC, 2)
|
||||
AND t.id != $3
|
||||
GROUP BY t.id, t.part_number, t.standard_name, t.form_factor,
|
||||
t.speed_gbps, t.fiber_type, t.reach_meters,
|
||||
t.wavelengths, t.connector, v.name
|
||||
t.wavelengths, t.wavelength_tx_nm, t.wavelength_rx_nm,
|
||||
t.connector_type, t.data_completeness, v.name
|
||||
HAVING COUNT(*) >= 1
|
||||
`, [fx.form_factor, fx.speed_gbps, fx.id]);
|
||||
|
||||
let fxMatched = false;
|
||||
|
||||
for (const cand of candidates.rows) {
|
||||
// Confidence scoring
|
||||
// Max points: form_factor(25) + speed_gbps(20) + standard_name(30) +
|
||||
// wavelength_nm(20) + fiber_type(10) + reach(10) = 115
|
||||
let score = 0;
|
||||
const basis: string[] = [];
|
||||
const fxComplete =
|
||||
fx.form_factor && fx.speed_gbps && fx.fiber_type &&
|
||||
fx.reach_meters && fx.wavelength_tx_nm && fx.connector_type;
|
||||
const candComplete =
|
||||
cand.form_factor && cand.speed_gbps && cand.fiber_type &&
|
||||
cand.reach_meters && cand.wavelength_tx_nm && cand.connector_type;
|
||||
|
||||
// form_factor already matched (pre-filter), award points
|
||||
score += 25; basis.push("form_factor");
|
||||
let confidence = 0;
|
||||
let basis: string[] = [];
|
||||
let matchMode: "deterministic" | "enhanced" | "skip" = "skip";
|
||||
|
||||
// speed_gbps already matched (pre-filter)
|
||||
score += 20; basis.push("speed_gbps");
|
||||
if (fxComplete && candComplete) {
|
||||
// ── Mode 1: Deterministic 6-field exact match ───────────────────────
|
||||
// All mandatory fields present → hard pass/fail, no soft scoring.
|
||||
// A single field mismatch → skip (confidence stays 0).
|
||||
|
||||
// standard_name match (strong signal — e.g. "10GBASE-LR")
|
||||
if (fx.standard_name && cand.standard_name &&
|
||||
fx.standard_name.trim().toUpperCase() === cand.standard_name.trim().toUpperCase()) {
|
||||
score += 30; basis.push("standard_name");
|
||||
}
|
||||
|
||||
// wavelength match — extract first numeric nm value and compare within ±15nm
|
||||
// "wavelengths" is text: "1310 nm", "850nm", "1270/1290/1310/1330 nm" etc.
|
||||
const extractNm = (w: string | null): number | null => {
|
||||
if (!w) return null;
|
||||
const m = w.match(/(\d{3,4})/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
};
|
||||
const fxNm = extractNm(fx.wavelengths);
|
||||
const candNm = extractNm(cand.wavelengths);
|
||||
if (fxNm !== null && candNm !== null) {
|
||||
if (Math.abs(fxNm - candNm) <= 15) {
|
||||
score += 20; basis.push(`wavelength_${fxNm}nm`);
|
||||
} else {
|
||||
score -= 20; // hard penalize wrong wavelength (1310 vs 1550 = completely different product)
|
||||
// form_factor: exact
|
||||
if (fx.form_factor.trim().toUpperCase() !== cand.form_factor.trim().toUpperCase()) {
|
||||
skippedIncomplete++; continue;
|
||||
}
|
||||
}
|
||||
|
||||
// fiber_type match (SMF vs MMF — critical)
|
||||
if (fx.fiber_type && cand.fiber_type) {
|
||||
if (fx.fiber_type.trim().toUpperCase() === cand.fiber_type.trim().toUpperCase()) {
|
||||
score += 10; basis.push("fiber_type");
|
||||
} else {
|
||||
score -= 15; // SMF vs MMF = wrong product
|
||||
// speed: ±0.1 Gbps
|
||||
if (Math.abs(Number(fx.speed_gbps) - Number(cand.speed_gbps)) >= 0.1) {
|
||||
skippedIncomplete++; continue;
|
||||
}
|
||||
}
|
||||
|
||||
// reach within ±25%
|
||||
if (fx.reach_meters && cand.reach_meters && fx.reach_meters > 0 && cand.reach_meters > 0) {
|
||||
const diff = Math.abs(fx.reach_meters - cand.reach_meters);
|
||||
const tolerance = Math.max(fx.reach_meters, 1) * 0.25;
|
||||
if (diff <= tolerance) {
|
||||
score += 10; basis.push("reach");
|
||||
} else {
|
||||
score -= 15; // penalize mismatched reach
|
||||
// fiber_type: exact (SMF ≠ MMF ≠ DAC)
|
||||
if (fx.fiber_type.trim().toUpperCase() !== cand.fiber_type.trim().toUpperCase()) {
|
||||
skippedIncomplete++; continue;
|
||||
}
|
||||
} else if (!fx.reach_meters && !cand.reach_meters) {
|
||||
score += 5; basis.push("reach_null");
|
||||
// reach: ±10% tolerance (manufacturer variance within spec)
|
||||
const reachRatio = Math.abs(
|
||||
Number(fx.reach_meters) - Number(cand.reach_meters)
|
||||
) / Math.max(Number(fx.reach_meters), 1);
|
||||
if (reachRatio > 0.10) { skippedIncomplete++; continue; }
|
||||
// wavelength TX: ±5nm (ITU-T G.694.2 channel tolerance)
|
||||
const wlTxDiff = Math.abs(
|
||||
(Number(fx.wavelength_tx_nm) || 0) - (Number(cand.wavelength_tx_nm) || 0)
|
||||
);
|
||||
if (wlTxDiff > 5) { skippedIncomplete++; continue; }
|
||||
// BiDi RX wavelength (only if either side has RX set)
|
||||
if (fx.wavelength_rx_nm != null || cand.wavelength_rx_nm != null) {
|
||||
const wlRxDiff = Math.abs(
|
||||
(Number(fx.wavelength_rx_nm) || 0) - (Number(cand.wavelength_rx_nm) || 0)
|
||||
);
|
||||
if (wlRxDiff > 5) { skippedIncomplete++; continue; }
|
||||
}
|
||||
// connector: exact (LC ≠ SC ≠ MPO-12 ≠ MPO-16)
|
||||
if (fx.connector_type.trim().toUpperCase() !== cand.connector_type.trim().toUpperCase()) {
|
||||
skippedIncomplete++; continue;
|
||||
}
|
||||
|
||||
// All 6 fields matched → 100% deterministic match
|
||||
confidence = 1.0;
|
||||
basis = ["form_factor", "speed_gbps", "fiber_type", "reach", "wavelength_tx", "connector"];
|
||||
matchMode = "deterministic";
|
||||
|
||||
} else {
|
||||
// ── Mode 2: Enhanced confidence for incomplete products ──────────────
|
||||
// Only used when at least one product has missing fields.
|
||||
// Raised threshold (0.85) and never produces pending status.
|
||||
let score = 0;
|
||||
const basisLocal: string[] = [];
|
||||
|
||||
score += 25; basisLocal.push("form_factor"); // pre-filtered
|
||||
score += 20; basisLocal.push("speed_gbps"); // pre-filtered
|
||||
|
||||
// standard_name (strong signal)
|
||||
if (fx.standard_name && cand.standard_name &&
|
||||
fx.standard_name.trim().toUpperCase() === cand.standard_name.trim().toUpperCase()) {
|
||||
score += 30; basisLocal.push("standard_name");
|
||||
}
|
||||
|
||||
// wavelength — use integer columns first, fall back to text
|
||||
const fxWlTx = fx.wavelength_tx_nm
|
||||
?? (() => { const m = (fx.wavelengths || "").match(/(\d{3,4})/); return m ? parseInt(m[1], 10) : null; })();
|
||||
const cWlTx = cand.wavelength_tx_nm
|
||||
?? (() => { const m = (cand.wavelengths || "").match(/(\d{3,4})/); return m ? parseInt(m[1], 10) : null; })();
|
||||
if (fxWlTx !== null && cWlTx !== null) {
|
||||
if (Math.abs(fxWlTx - cWlTx) <= 15) {
|
||||
score += 20; basisLocal.push(`wavelength_${fxWlTx}nm`);
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
}
|
||||
|
||||
// fiber_type
|
||||
if (fx.fiber_type && cand.fiber_type) {
|
||||
if (fx.fiber_type.trim().toUpperCase() === cand.fiber_type.trim().toUpperCase()) {
|
||||
score += 10; basisLocal.push("fiber_type");
|
||||
} else {
|
||||
score -= 15;
|
||||
}
|
||||
}
|
||||
|
||||
// reach: ±25% for incomplete data (more lenient)
|
||||
if (fx.reach_meters && cand.reach_meters &&
|
||||
Number(fx.reach_meters) > 0 && Number(cand.reach_meters) > 0) {
|
||||
const diff = Math.abs(Number(fx.reach_meters) - Number(cand.reach_meters));
|
||||
const tolerance = Math.max(Number(fx.reach_meters), 1) * 0.25;
|
||||
if (diff <= tolerance) {
|
||||
score += 10; basisLocal.push("reach");
|
||||
} else {
|
||||
score -= 15;
|
||||
}
|
||||
} else if (!fx.reach_meters && !cand.reach_meters) {
|
||||
score += 5; basisLocal.push("reach_null");
|
||||
}
|
||||
|
||||
confidence = Math.max(0, Math.min(1, score / 115));
|
||||
basis = basisLocal;
|
||||
|
||||
// Raised threshold for incomplete data: 0.85 (was 0.73)
|
||||
// Below threshold → skip, NEVER pending
|
||||
if (confidence < 0.85) {
|
||||
skippedLowConf++;
|
||||
continue;
|
||||
}
|
||||
matchMode = "enhanced";
|
||||
}
|
||||
|
||||
const confidence = Math.max(0, Math.min(1, score / 115));
|
||||
// ── Both modes: upsert as auto_approved ─────────────────────────────
|
||||
const notes =
|
||||
`${fx.part_number} ↔ ${cand.part_number} (${cand.vendor_name}) | ` +
|
||||
`mode: ${matchMode} | basis: ${basis.join(", ")} | ` +
|
||||
`reach: ${fx.reach_meters}m vs ${cand.reach_meters}m | ` +
|
||||
`wl_tx: ${fx.wavelength_tx_nm ?? fx.wavelengths ?? "?"}nm vs ` +
|
||||
`${cand.wavelength_tx_nm ?? cand.wavelengths ?? "?"}nm`;
|
||||
|
||||
if (confidence < 0.50) { skipped++; continue; }
|
||||
|
||||
const notes = `${fx.part_number} ↔ ${cand.part_number} (${cand.vendor_name}) | ` +
|
||||
`basis: ${basis.join(", ")} | reach: ${fx.reach_meters}m vs ${cand.reach_meters}m | ` +
|
||||
`wavelength: ${fx.wavelengths||"?"} vs ${cand.wavelengths||"?"}`;
|
||||
|
||||
// Upsert equivalence candidate
|
||||
const status = confidence >= 0.73 ? "auto_approved" : "pending";
|
||||
// Deterministic matches (1.0) upgrade existing auto_approved records.
|
||||
// Enhanced matches (0.85+) do NOT overwrite existing auto_approved.
|
||||
const conflictClause = matchMode === "deterministic"
|
||||
? `WHERE transceiver_equivalences.status NOT IN ('approved', 'rejected')`
|
||||
: `WHERE transceiver_equivalences.status NOT IN ('approved', 'rejected', 'auto_approved')`;
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO transceiver_equivalences
|
||||
(flexoptix_id, competitor_id, confidence, match_basis, match_notes, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
VALUES ($1, $2, $3, $4, $5, 'auto_approved')
|
||||
ON CONFLICT (flexoptix_id, competitor_id) DO UPDATE SET
|
||||
confidence = EXCLUDED.confidence,
|
||||
match_basis = EXCLUDED.match_basis,
|
||||
match_notes = EXCLUDED.match_notes,
|
||||
updated_at = NOW()
|
||||
WHERE transceiver_equivalences.status NOT IN ('approved', 'rejected')
|
||||
`, [fx.id, cand.competitor_id, confidence, basis, notes, status]);
|
||||
${conflictClause}
|
||||
`, [fx.id, cand.competitor_id, confidence, basis, notes]);
|
||||
|
||||
if (confidence >= 0.73) {
|
||||
// Auto-approve: set competitor_verified on the Flexoptix transceiver
|
||||
await pool.query(`
|
||||
UPDATE transceivers
|
||||
SET competitor_verified = true,
|
||||
competitor_verified_at = NOW(),
|
||||
competitor_status = 'matched',
|
||||
competitor_status_updated_at = NOW()
|
||||
WHERE id = $1 AND competitor_verified = false
|
||||
`, [fx.id]);
|
||||
await pool.query(`
|
||||
INSERT INTO transceiver_verification_evidence (
|
||||
transceiver_id, verification_type, source_url, source_vendor_id,
|
||||
evidence_value, evidence_hash, robot_name, confidence
|
||||
)
|
||||
VALUES (
|
||||
$1, 'competitor_match', NULL, NULL,
|
||||
$2::jsonb,
|
||||
md5($2::text),
|
||||
'maintenance:find-equivalences',
|
||||
$3
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [
|
||||
fx.id,
|
||||
JSON.stringify({
|
||||
competitor_id: cand.competitor_id,
|
||||
competitor_part_number: cand.part_number,
|
||||
competitor_vendor: cand.vendor_name,
|
||||
match_basis: basis,
|
||||
notes,
|
||||
}),
|
||||
confidence,
|
||||
]);
|
||||
autoApproved++;
|
||||
fxMatched = true;
|
||||
} else {
|
||||
queued++;
|
||||
fxQueued = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fxMatched && fxQueued) {
|
||||
// Set competitor_verified on FX product
|
||||
await pool.query(`
|
||||
UPDATE transceivers
|
||||
SET competitor_status = 'ambiguous',
|
||||
SET competitor_verified = true,
|
||||
competitor_verified_at = NOW(),
|
||||
competitor_status = 'matched',
|
||||
competitor_status_updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND competitor_verified = false
|
||||
AND COALESCE(competitor_status, 'unknown') NOT IN ('no_valid_match')
|
||||
WHERE id = $1 AND competitor_verified = false
|
||||
`, [fx.id]);
|
||||
} else if (!fxMatched && !fxQueued) {
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO transceiver_verification_evidence (
|
||||
transceiver_id, verification_type, source_url, source_vendor_id,
|
||||
evidence_value, evidence_hash, robot_name, confidence
|
||||
)
|
||||
VALUES (
|
||||
$1, 'competitor_match', NULL, NULL,
|
||||
$2::jsonb,
|
||||
md5($2::text),
|
||||
'maintenance:find-equivalences',
|
||||
$3
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [
|
||||
fx.id,
|
||||
JSON.stringify({
|
||||
competitor_id: cand.competitor_id,
|
||||
competitor_part_number: cand.part_number,
|
||||
competitor_vendor: cand.vendor_name,
|
||||
match_basis: basis,
|
||||
match_mode: matchMode,
|
||||
notes,
|
||||
}),
|
||||
confidence,
|
||||
]);
|
||||
|
||||
if (matchMode === "deterministic") {
|
||||
autoApprovedDeterministic++;
|
||||
} else {
|
||||
autoApprovedEnhanced++;
|
||||
}
|
||||
fxMatched = true;
|
||||
}
|
||||
|
||||
if (!fxMatched) {
|
||||
await pool.query(`
|
||||
UPDATE transceivers
|
||||
SET competitor_status = 'needs_research',
|
||||
competitor_status_updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND competitor_verified = false
|
||||
AND COALESCE(competitor_status, 'unknown') NOT IN ('no_valid_match', 'ambiguous')
|
||||
AND COALESCE(competitor_status, 'unknown') NOT IN ('no_valid_match', 'ambiguous', 'matched')
|
||||
`, [fx.id]);
|
||||
}
|
||||
}
|
||||
|
||||
const autoApproved = autoApprovedDeterministic + autoApprovedEnhanced;
|
||||
console.log(
|
||||
`[find-equivalences] auto_approved: ${autoApproved}, ` +
|
||||
`queued for review: ${queued}, skipped (low confidence): ${skipped}`
|
||||
`[find-equivalences] deterministic: ${autoApprovedDeterministic}, ` +
|
||||
`enhanced (≥0.85): ${autoApprovedEnhanced}, ` +
|
||||
`skipped (field mismatch): ${skippedIncomplete}, ` +
|
||||
`skipped (low conf): ${skippedLowConf} | ` +
|
||||
`PENDING CREATED: 0 (by design)`
|
||||
);
|
||||
|
||||
// After auto-approvals, rerun fully_verified check
|
||||
@ -3026,6 +3182,13 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
|
||||
);
|
||||
|
||||
if (research.decision === "reject") {
|
||||
const rejectReason = research.rejectReason || "automated research: rejected";
|
||||
// Data-gap rejects (missing wavelength/fiber/reach evidence, not a
|
||||
// confirmed mismatch) get a future re-check — once a scraper fills the
|
||||
// gap, re-research picks it back up. Confirmed mismatches / no-price /
|
||||
// low-confidence rejects get re_research_due_at=NULL (permanent; nothing
|
||||
// will change on a re-check with the same stored specs).
|
||||
const isDataGap = rejectReason.includes("insufficient technical evidence");
|
||||
await pool.query(`
|
||||
UPDATE transceiver_equivalences
|
||||
SET status = 'rejected',
|
||||
@ -3034,7 +3197,7 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
|
||||
reject_reason = $4,
|
||||
reviewed_by = 'automated-research',
|
||||
reviewed_at = NOW(),
|
||||
re_research_due_at = NULL,
|
||||
re_research_due_at = CASE WHEN $6 THEN NOW() + INTERVAL '21 days' ELSE NULL END,
|
||||
re_researched_at = NOW(),
|
||||
match_notes = CONCAT(
|
||||
COALESCE(match_notes, ''),
|
||||
@ -3046,8 +3209,9 @@ export async function registerWorkers(boss: PgBoss): Promise<void> {
|
||||
eq.id,
|
||||
research.confidence,
|
||||
research.basis,
|
||||
research.rejectReason || "automated research: rejected",
|
||||
rejectReason,
|
||||
research.reasons.join("; "),
|
||||
isDataGap,
|
||||
]);
|
||||
|
||||
await pool.query(`
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
* Rewritten 2026-05-06: switched from HTML parsing to products.json API after
|
||||
* Shopify's static HTML stopped rendering per-collection results correctly.
|
||||
*/
|
||||
import { ensureVendor, upsertPriceObservation, findOrCreateScrapedTransceiver, markImageVerified, pool } from "../utils/db";
|
||||
import { ensureVendor, upsertPriceObservation, upsertStockObservation, findOrCreateScrapedTransceiver, markImageVerified, pool } from "../utils/db";
|
||||
import { contentHash } from "../utils/hash";
|
||||
|
||||
const BASE_URL = "https://atgbics.com";
|
||||
@ -297,6 +297,19 @@ export async function scrapeAtgbics(): Promise<void> {
|
||||
});
|
||||
if (updated) priceUpdates++;
|
||||
|
||||
// Stock observation — Shopify provides binary available boolean (confidence: 1)
|
||||
await upsertStockObservation({
|
||||
transceiverId: txId,
|
||||
sourceVendorId: vendorId,
|
||||
stockLevel: product.stockLevel,
|
||||
quantityAvailable: product.stockLevel === "in_stock" || product.stockLevel === "low_stock" ? 1 : 0,
|
||||
priceNet: product.price,
|
||||
productUrl: product.url,
|
||||
stockConfidence: 1,
|
||||
priceCurrency: product.currency,
|
||||
priceIncludesTax: product.currency === "GBP", // Shopify GBP prices include VAT
|
||||
});
|
||||
|
||||
if (product.imageUrl) {
|
||||
const updatedImage = await markImageVerified(txId, product.imageUrl);
|
||||
if (updatedImage) imageUpdates++;
|
||||
|
||||
@ -14,7 +14,9 @@
|
||||
*
|
||||
* Fallback (when search returns nothing):
|
||||
* Form-factor matching — for each port type in switch.ports_config,
|
||||
* find Flexoptix transceivers matching that form factor.
|
||||
* find Flexoptix transceivers matching that form factor AND whose speed does
|
||||
* not exceed the switch's fastest port (a transceiver faster than the port
|
||||
* cannot link at rated speed — e.g. an 800G module in a 400G OSFP port).
|
||||
* Used as spec_match rather than vendor_compat — shown in dashboard
|
||||
* as "by form factor" rather than "explicitly verified".
|
||||
*
|
||||
@ -63,6 +65,18 @@ function portsConfigToFormFactors(portsConfig: Record<string, number>): string[]
|
||||
return [...factors];
|
||||
}
|
||||
|
||||
// Max port speed (Gbps) implied by a ports_config, e.g. {"400G_QSFP-DD":4} → 400.
|
||||
// The switch cannot host a transceiver faster than its fastest port.
|
||||
function maxSpeedFromPortsConfig(portsConfig: Record<string, number> | null): number {
|
||||
if (!portsConfig) return 0;
|
||||
let max = 0;
|
||||
for (const key of Object.keys(portsConfig)) {
|
||||
const m = key.match(/(\d+)G/i);
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
// ── Flexoptix search API ────────────────────────────────────────────────────
|
||||
|
||||
interface FlexoptixSuggestion {
|
||||
@ -105,8 +119,9 @@ export async function scrapeFlexoptixCompatibility(): Promise<void> {
|
||||
model: string;
|
||||
vendor_name: string;
|
||||
ports_config: Record<string, number> | null;
|
||||
max_speed_gbps: number | null;
|
||||
}>(
|
||||
`SELECT sw.id, sw.model, v.name AS vendor_name, sw.ports_config
|
||||
`SELECT sw.id, sw.model, v.name AS vendor_name, sw.ports_config, sw.max_speed_gbps
|
||||
FROM switches sw
|
||||
JOIN vendors v ON v.id = sw.vendor_id
|
||||
ORDER BY sw.max_speed_gbps DESC`,
|
||||
@ -182,7 +197,15 @@ export async function scrapeFlexoptixCompatibility(): Promise<void> {
|
||||
// ── Strategy 2: Form-factor fallback ─────────────────────────────────
|
||||
if (sw.ports_config) {
|
||||
const formFactors = portsConfigToFormFactors(sw.ports_config);
|
||||
console.log(` Form factors: ${formFactors.join(", ")}`);
|
||||
// Physical speed ceiling: a transceiver faster than the switch's fastest
|
||||
// port cannot link at its rated speed, so it is NOT compatible-by-form-factor.
|
||||
// (ceiling <= 0 → the query below matches nothing, i.e. no fallback when the
|
||||
// switch has no derivable port speed.)
|
||||
const ceiling = Math.max(
|
||||
maxSpeedFromPortsConfig(sw.ports_config),
|
||||
sw.max_speed_gbps ?? 0,
|
||||
);
|
||||
console.log(` Form factors: ${formFactors.join(", ")} (≤ ${ceiling}G)`);
|
||||
|
||||
for (const ff of formFactors) {
|
||||
const { rows: txRows } = await pool.query<{ id: string }>(
|
||||
@ -190,11 +213,14 @@ export async function scrapeFlexoptixCompatibility(): Promise<void> {
|
||||
FROM transceivers t
|
||||
WHERE t.vendor_id = $1
|
||||
AND t.form_factor = $2
|
||||
AND t.speed_gbps IS NOT NULL
|
||||
AND t.speed_gbps > 0
|
||||
AND t.speed_gbps <= $4
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM compatibility c
|
||||
WHERE c.switch_id = $3 AND c.transceiver_id = t.id
|
||||
)`,
|
||||
[flexoptixVendorId, ff, sw.id],
|
||||
[flexoptixVendorId, ff, sw.id, ceiling],
|
||||
);
|
||||
|
||||
for (const tx of txRows) {
|
||||
|
||||
138
packages/scraper/src/scrapers/fs-com-parse.test.ts
Normal file
138
packages/scraper/src/scrapers/fs-com-parse.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for the FS.com pure parsers. Run with:
|
||||
* npx tsx --test src/scrapers/fs-com-parse.test.ts
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseGermanQty,
|
||||
parseGermanDate,
|
||||
parseGermanPrice,
|
||||
extractDeliveryDate,
|
||||
parseWarehouseStock,
|
||||
computeLeadTimeDays,
|
||||
sanitizeUnitsSold,
|
||||
} from "./fs-com-parse";
|
||||
|
||||
// ── parseGermanQty — the K/M decimal bug ─────────────────────────────────────
|
||||
|
||||
test("parseGermanQty: plain integers use '.'/',' as thousands", () => {
|
||||
assert.equal(parseGermanQty("4.895"), 4895);
|
||||
assert.equal(parseGermanQty("1.587"), 1587);
|
||||
assert.equal(parseGermanQty("1.501"), 1501);
|
||||
assert.equal(parseGermanQty("2"), 2);
|
||||
assert.equal(parseGermanQty("1627"), 1627);
|
||||
});
|
||||
|
||||
test("parseGermanQty: K/M abbreviations treat '.' as a DECIMAL (bug fix)", () => {
|
||||
// Previously "4.8K" -> 48000 and "1.4M" -> 14000000 (the garbage outlier).
|
||||
assert.equal(parseGermanQty("4.8K"), 4800);
|
||||
assert.equal(parseGermanQty("210.9K"), 210900);
|
||||
assert.equal(parseGermanQty("504.4K"), 504400);
|
||||
assert.equal(parseGermanQty("1.4M"), 1_400_000);
|
||||
assert.equal(parseGermanQty("1.2M"), 1_200_000);
|
||||
assert.equal(parseGermanQty("63K"), 63000); // no decimal — unchanged
|
||||
assert.equal(parseGermanQty("14M"), 14_000_000); // caught later by the gate
|
||||
});
|
||||
|
||||
test("parseGermanQty: K/M with German comma decimal still works", () => {
|
||||
assert.equal(parseGermanQty("4,8K"), 4800);
|
||||
assert.equal(parseGermanQty("1,4M"), 1_400_000);
|
||||
});
|
||||
|
||||
// ── parseGermanDate ──────────────────────────────────────────────────────────
|
||||
|
||||
test("parseGermanDate: word and numeric forms", () => {
|
||||
assert.equal(parseGermanDate("8. Juli 2026"), "2026-07-08");
|
||||
assert.equal(parseGermanDate("17. Juli 2026"), "2026-07-17");
|
||||
assert.equal(parseGermanDate("20 Apr., 2026"), "2026-04-20");
|
||||
assert.equal(parseGermanDate("28. März 2026"), "2026-03-28");
|
||||
assert.equal(parseGermanDate("20.04.2026"), "2026-04-20");
|
||||
assert.equal(parseGermanDate("nonsense"), undefined);
|
||||
});
|
||||
|
||||
// ── extractDeliveryDate ──────────────────────────────────────────────────────
|
||||
|
||||
test("extractDeliveryDate: finds bare word-date, no keyword needed", () => {
|
||||
assert.equal(extractDeliveryDate("2 Stk. im DE-Lager, 8. Juli 2026 Lieferbar"), "2026-07-08");
|
||||
assert.equal(extractDeliveryDate("1.627 Stk. im Global-Lager, 17. Juli 2026"), "2026-07-17");
|
||||
});
|
||||
|
||||
test("extractDeliveryDate: rejects stray/garbage years and empty", () => {
|
||||
assert.equal(extractDeliveryDate("© 2019 FS.COM"), undefined);
|
||||
assert.equal(extractDeliveryDate("auf Lager, sofort verfügbar"), undefined);
|
||||
});
|
||||
|
||||
// ── parseWarehouseStock — the real screenshot block ──────────────────────────
|
||||
|
||||
const SCREENSHOT_BLOCK = [
|
||||
"2 Stk. im DE-Lager, 8. Juli 2026 Lieferbar",
|
||||
"1.627 Stk. im Global-Lager, 17. Juli 2026",
|
||||
"1 in Nachlieferung, 28. Juli 2026",
|
||||
"4.8K verkauft",
|
||||
].join("\n");
|
||||
|
||||
test("parseWarehouseStock: full block yields qtys + per-warehouse dates + sold", () => {
|
||||
const s = parseWarehouseStock(SCREENSHOT_BLOCK);
|
||||
assert.equal(s.deQty, 2);
|
||||
assert.equal(s.deDeliveryDate, "2026-07-08");
|
||||
assert.equal(s.globalQty, 1627);
|
||||
assert.equal(s.globalDeliveryDate, "2026-07-17");
|
||||
assert.equal(s.backorderQty, 1);
|
||||
assert.equal(s.backorderDate, "2026-07-28");
|
||||
assert.equal(s.unitsSold, 4800); // 4.8K, correctly de-abbreviated
|
||||
});
|
||||
|
||||
test("parseWarehouseStock: a warehouse with no date does not steal a neighbour's date", () => {
|
||||
const block = [
|
||||
"5 Stk. im DE-Lager", // no date — available now
|
||||
"800 Stk. im Global-Lager, 17. Juli 2026",
|
||||
].join("\n");
|
||||
const s = parseWarehouseStock(block);
|
||||
assert.equal(s.deQty, 5);
|
||||
assert.equal(s.deDeliveryDate, undefined); // must NOT be 2026-07-17
|
||||
assert.equal(s.globalQty, 800);
|
||||
assert.equal(s.globalDeliveryDate, "2026-07-17");
|
||||
});
|
||||
|
||||
test("parseWarehouseStock: gates the 1.4M -> 14M units_sold garbage", () => {
|
||||
const block = "12 Stk. im DE-Lager, 8. Juli 2026 Lieferbar\n14M verkauft";
|
||||
const s = parseWarehouseStock(block);
|
||||
assert.equal(s.deQty, 12);
|
||||
assert.equal(s.unitsSold, undefined); // 14M > cap → dropped as unknown
|
||||
});
|
||||
|
||||
test("parseWarehouseStock: empty when no warehouse text present", () => {
|
||||
assert.deepEqual(parseWarehouseStock("Just a product description, nothing else."), {});
|
||||
});
|
||||
|
||||
// ── sanitizeUnitsSold ────────────────────────────────────────────────────────
|
||||
|
||||
test("sanitizeUnitsSold: keeps plausible, drops implausible/negative", () => {
|
||||
assert.equal(sanitizeUnitsSold(4800), 4800);
|
||||
assert.equal(sanitizeUnitsSold(504400), 504400);
|
||||
assert.equal(sanitizeUnitsSold(14_000_000), undefined);
|
||||
assert.equal(sanitizeUnitsSold(-5), undefined);
|
||||
assert.equal(sanitizeUnitsSold(undefined), undefined);
|
||||
assert.equal(sanitizeUnitsSold(2_500_000, 2_000_000), undefined);
|
||||
assert.equal(sanitizeUnitsSold(1_500_000, 2_000_000), 1_500_000);
|
||||
});
|
||||
|
||||
// ── computeLeadTimeDays ──────────────────────────────────────────────────────
|
||||
|
||||
test("computeLeadTimeDays: min across dates, floored at 0, undefined when empty", () => {
|
||||
const now = new Date("2026-07-04T09:00:00");
|
||||
assert.equal(computeLeadTimeDays(["2026-07-08", "2026-07-17", "2026-07-28"], now), 4);
|
||||
assert.equal(computeLeadTimeDays([undefined, null, "2026-07-05"], now), 1);
|
||||
assert.equal(computeLeadTimeDays(["2026-07-01"], now), 0); // past date → 0, not negative
|
||||
assert.equal(computeLeadTimeDays([], now), undefined);
|
||||
assert.equal(computeLeadTimeDays([null, undefined], now), undefined);
|
||||
});
|
||||
|
||||
// ── parseGermanPrice (unchanged behaviour, regression guard) ─────────────────
|
||||
|
||||
test("parseGermanPrice: net EUR parsing", () => {
|
||||
assert.equal(parseGermanPrice("42,50"), 42.5);
|
||||
assert.equal(parseGermanPrice("1.063,02 €"), 1063.02);
|
||||
assert.equal(parseGermanPrice("5,10 € ohne MwSt."), 5.1);
|
||||
});
|
||||
290
packages/scraper/src/scrapers/fs-com-parse.ts
Normal file
290
packages/scraper/src/scrapers/fs-com-parse.ts
Normal file
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* FS.com parsing helpers — pure, side-effect-free, unit-tested.
|
||||
*
|
||||
* Extracted from fs-com.ts so the number/date/warehouse parsing can be tested
|
||||
* in isolation (fs-com.ts pulls in Crawlee + Playwright + a pg Pool, which we
|
||||
* do not want to load in a unit test).
|
||||
*
|
||||
* The FS.com German storefront (www.fs.com/de/) renders per-warehouse
|
||||
* availability as a short line per warehouse, e.g.:
|
||||
*
|
||||
* 2 Stk. im DE-Lager, 8. Juli 2026 Lieferbar
|
||||
* 1.627 Stk. im Global-Lager, 17. Juli 2026
|
||||
* 1 in Nachlieferung, 28. Juli 2026
|
||||
* 4.8K verkauft
|
||||
*
|
||||
* The quantity part was already parsed correctly; the delivery-date part was
|
||||
* not, because the previous regex only looked for a "Lieferung:"/"erwartet:"
|
||||
* keyword prefix or a numeric dd.mm.yyyy date — neither of which matches the
|
||||
* bare word-date ("8. Juli 2026") that FS.com actually renders.
|
||||
*/
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GERMAN_MONTHS: Record<string, string> = {
|
||||
jan: "01", feb: "02", mär: "03", mar: "03",
|
||||
apr: "04", mai: "05", may: "05", jun: "06",
|
||||
jul: "07", aug: "08", sep: "09", okt: "10",
|
||||
oct: "10", nov: "11", dez: "12", dec: "12",
|
||||
};
|
||||
|
||||
/**
|
||||
* Upper bound for a plausible per-SKU lifetime "verkauft" (units-sold) counter.
|
||||
* Values above this are treated as unknown (dropped), not stored. FS.com's
|
||||
* biggest sellers are in the hundreds of thousands; multi-million values are
|
||||
* a mis-parse (see parseGermanQty K/M bug) or vendor marketing inflation.
|
||||
* Override with FS_MAX_UNITS_SOLD.
|
||||
*/
|
||||
export const DEFAULT_MAX_UNITS_SOLD = 2_000_000;
|
||||
|
||||
/** How far past a warehouse line to look for its delivery date (characters). */
|
||||
const DATE_WINDOW = 200;
|
||||
|
||||
// ── Number parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the mantissa of a K/M-abbreviated number ("4.8" in "4.8K").
|
||||
*
|
||||
* K/M abbreviations carry a small decimal, never a grouped thousands value, so
|
||||
* a lone "." is the decimal point ("4.8K" = 4 800). German pages may instead
|
||||
* use "," as the decimal ("4,8K"); a "." alongside a "," is then a thousands
|
||||
* grouping and is stripped.
|
||||
*/
|
||||
function parseAbbrevMantissa(s: string): number {
|
||||
if (s.includes(",")) return parseFloat(s.replace(/\./g, "").replace(",", "."));
|
||||
return parseFloat(s); // "." is the decimal separator
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a German-formatted quantity string.
|
||||
* "4.895" → 4895 (period = thousands separator in a plain integer)
|
||||
* "1.587" → 1587
|
||||
* "4.8K" → 4800 (period = decimal in a K/M abbreviation)
|
||||
* "210.9K" → 210900
|
||||
* "1.4M" → 1400000
|
||||
* "63K" → 63000
|
||||
*/
|
||||
export function parseGermanQty(text: string): number | undefined {
|
||||
const t = text.trim().replace(/\s/g, "");
|
||||
if (!t) return undefined;
|
||||
|
||||
const kMatch = t.match(/^([\d.,]+)[Kk]$/);
|
||||
if (kMatch) {
|
||||
const n = parseAbbrevMantissa(kMatch[1]);
|
||||
return isNaN(n) ? undefined : Math.round(n * 1_000);
|
||||
}
|
||||
|
||||
const mMatch = t.match(/^([\d.,]+)[Mm]$/);
|
||||
if (mMatch) {
|
||||
const n = parseAbbrevMantissa(mMatch[1]);
|
||||
return isNaN(n) ? undefined : Math.round(n * 1_000_000);
|
||||
}
|
||||
|
||||
// Plain integer: "." and "," are both thousands separators here.
|
||||
const n = parseInt(t.replace(/\./g, "").replace(/,/g, ""), 10);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a German date to ISO "YYYY-MM-DD".
|
||||
* "8. Juli 2026" → "2026-07-08"
|
||||
* "20 Apr., 2026" → "2026-04-20"
|
||||
* "20.04.2026" → "2026-04-20"
|
||||
*/
|
||||
export function parseGermanDate(text: string): string | undefined {
|
||||
const numericMatch = text.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (numericMatch) {
|
||||
const [, d, m, y] = numericMatch;
|
||||
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
}
|
||||
const wordMatch = text.match(/(\d{1,2})\.?\s+([A-Za-zÄÖÜäöüß]+)\.?,?\s*(\d{4})/);
|
||||
if (!wordMatch) return undefined;
|
||||
const day = wordMatch[1].padStart(2, "0");
|
||||
const monthRaw = wordMatch[2]
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "a").replace(/ö/g, "o").replace(/ü/g, "u")
|
||||
.slice(0, 3);
|
||||
const month = GERMAN_MONTHS[monthRaw];
|
||||
if (!month) return undefined;
|
||||
return `${wordMatch[3]}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a German price to a EUR float.
|
||||
* "42,50" → 42.50
|
||||
* "1.063,02" → 1063.02
|
||||
*/
|
||||
export function parseGermanPrice(raw: string): number | undefined {
|
||||
const cleaned = raw.replace(/[^0-9.,]/g, "").trim();
|
||||
if (!cleaned) return undefined;
|
||||
let normalized: string;
|
||||
if (/\d+\.\d{3},\d{2}/.test(cleaned)) {
|
||||
normalized = cleaned.replace(/\./g, "").replace(",", ".");
|
||||
} else if (cleaned.includes(",")) {
|
||||
normalized = cleaned.replace(",", ".");
|
||||
} else {
|
||||
normalized = cleaned;
|
||||
}
|
||||
const n = parseFloat(normalized);
|
||||
return isNaN(n) || n <= 0 ? undefined : n;
|
||||
}
|
||||
|
||||
// ── Units-sold sanity gate ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clamp an implausible units-sold value to `undefined` (unknown). Returns the
|
||||
* value unchanged when it is a finite, non-negative number at or below `cap`.
|
||||
*/
|
||||
export function sanitizeUnitsSold(
|
||||
raw: number | undefined,
|
||||
cap: number = DEFAULT_MAX_UNITS_SOLD
|
||||
): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (!Number.isFinite(raw) || raw < 0) return undefined;
|
||||
if (raw > cap) return undefined;
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ── Delivery-date extraction ─────────────────────────────────────────────────
|
||||
|
||||
/** dd.mm.yyyy OR "8. Juli 2026" / "20 Apr., 2026" (day + month word + year). */
|
||||
const DELIVERY_DATE_RE =
|
||||
/(\d{1,2}\.\d{1,2}\.\d{4})|(\d{1,2}\.?\s+[A-Za-zÄÖÜäöüß]{3,}\.?,?\s*\d{4})/;
|
||||
|
||||
/**
|
||||
* Find the first plausible calendar date inside a bounded text block (the slice
|
||||
* that belongs to one warehouse line) and return it as ISO "YYYY-MM-DD".
|
||||
* The date can appear with or without a "Lieferbar"/"Lieferung" keyword — FS.com
|
||||
* renders it bare ("…DE-Lager, 8. Juli 2026 Lieferbar"), so no keyword is required.
|
||||
*/
|
||||
export function extractDeliveryDate(block: string): string | undefined {
|
||||
const m = block.match(DELIVERY_DATE_RE);
|
||||
if (!m) return undefined;
|
||||
const iso = parseGermanDate(m[0]);
|
||||
if (!iso) return undefined;
|
||||
const year = Number(iso.slice(0, 4));
|
||||
if (year < 2024 || year > 2100) return undefined; // reject stray/garbage years
|
||||
return iso;
|
||||
}
|
||||
|
||||
// ── Warehouse block parsing ──────────────────────────────────────────────────
|
||||
|
||||
export interface WarehouseStock {
|
||||
deQty?: number;
|
||||
deDeliveryDate?: string;
|
||||
globalQty?: number;
|
||||
globalDeliveryDate?: string;
|
||||
backorderQty?: number;
|
||||
backorderDate?: string;
|
||||
unitsSold?: number;
|
||||
}
|
||||
|
||||
const DE_QTY_RE: RegExp[] = [
|
||||
/(\d[\d.,KkMm]*)\s*Stk\.\s*im\s*DE[- ]Lager/i,
|
||||
/(\d[\d.,KkMm]*)\s*im\s*DE[- ]?Lager/i,
|
||||
];
|
||||
const GLOBAL_QTY_RE: RegExp[] = [
|
||||
/(\d[\d.,KkMm]*)\s*Stk\.\s*im\s*Global[- ]Lager/i,
|
||||
/(\d[\d.,KkMm]*)\s*im\s*Global[- ]?(?:Lager|Warehouse)/i,
|
||||
/(\d[\d.,KkMm]*)\s*in\s+Global\s+Warehouse/i,
|
||||
];
|
||||
const BACKORDER_QTY_RE: RegExp[] = [
|
||||
/(\d[\d.,KkMm]*)\s*(?:Stk\.)?\s*in\s+Nachlieferung/i,
|
||||
/Nachlieferung[:\s]*(\d[\d.,KkMm]*)/i,
|
||||
];
|
||||
const UNITS_SOLD_RE: RegExp[] = [
|
||||
/(\d[\d.,KkMm]*)\s*(?:[Mm]al\s+)?[Vv]erkauft/,
|
||||
/([\d.,KkMm]+)\+?\s*sold/i,
|
||||
];
|
||||
|
||||
interface SectionMatch { value: string; index: number; }
|
||||
|
||||
function firstMatch(text: string, res: RegExp[]): SectionMatch | undefined {
|
||||
for (const re of res) {
|
||||
const m = text.match(re);
|
||||
if (m && m[1] != null && m.index != null) return { value: m[1], index: m.index };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the full warehouse-availability block out of a product page's body
|
||||
* text: DE-Lager, Global-Lager and Nachlieferung quantities, each with the
|
||||
* delivery date rendered on its line, plus the "verkauft"/"sold" counter.
|
||||
*
|
||||
* A section's delivery date is looked for only in the window between that
|
||||
* section's quantity and the start of the next section, so a warehouse with no
|
||||
* date never picks up a neighbouring warehouse's date.
|
||||
*/
|
||||
export function parseWarehouseStock(
|
||||
bodyText: string,
|
||||
unitsSoldCap: number = DEFAULT_MAX_UNITS_SOLD
|
||||
): WarehouseStock {
|
||||
const t = bodyText;
|
||||
const de = firstMatch(t, DE_QTY_RE);
|
||||
const gl = firstMatch(t, GLOBAL_QTY_RE);
|
||||
const bo = firstMatch(t, BACKORDER_QTY_RE);
|
||||
const sold = firstMatch(t, UNITS_SOLD_RE);
|
||||
|
||||
const starts = [de, gl, bo, sold]
|
||||
.filter((s): s is SectionMatch => s !== undefined)
|
||||
.map((s) => s.index);
|
||||
|
||||
const dateFor = (sec: SectionMatch | undefined): string | undefined => {
|
||||
if (!sec) return undefined;
|
||||
const later = starts.filter((b) => b > sec.index);
|
||||
const end = later.length ? Math.min(sec.index + DATE_WINDOW, ...later) : sec.index + DATE_WINDOW;
|
||||
return extractDeliveryDate(t.slice(sec.index, end));
|
||||
};
|
||||
|
||||
const result: WarehouseStock = {};
|
||||
|
||||
if (de) {
|
||||
const q = parseGermanQty(de.value);
|
||||
if (q !== undefined) result.deQty = q;
|
||||
const d = dateFor(de);
|
||||
if (d) result.deDeliveryDate = d;
|
||||
}
|
||||
if (gl) {
|
||||
const q = parseGermanQty(gl.value);
|
||||
if (q !== undefined) result.globalQty = q;
|
||||
const d = dateFor(gl);
|
||||
if (d) result.globalDeliveryDate = d;
|
||||
}
|
||||
if (bo) {
|
||||
const q = parseGermanQty(bo.value);
|
||||
if (q !== undefined) result.backorderQty = q;
|
||||
const d = dateFor(bo);
|
||||
if (d) result.backorderDate = d;
|
||||
}
|
||||
if (sold) {
|
||||
const s = sanitizeUnitsSold(parseGermanQty(sold.value), unitsSoldCap);
|
||||
if (s !== undefined) result.unitsSold = s;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Lead-time derivation ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive lead-time in days as the soonest availability across the warehouses
|
||||
* that quote a delivery date: min(date − today), floored at 0. Returns
|
||||
* `undefined` when no date is known (never fabricate a 0).
|
||||
*/
|
||||
export function computeLeadTimeDays(
|
||||
dates: Array<string | null | undefined>,
|
||||
now: Date = new Date()
|
||||
): number | undefined {
|
||||
const todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
let min: number | undefined;
|
||||
for (const d of dates) {
|
||||
if (!d) continue;
|
||||
const m = d.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) continue;
|
||||
const dUtc = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
const days = Math.max(0, Math.round((dUtc - todayUtc) / 86_400_000));
|
||||
if (min === undefined || days < min) min = days;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
@ -63,6 +63,12 @@ import {
|
||||
} from "../utils/db";
|
||||
import { contentHash } from "../utils/hash";
|
||||
import { updateVerifiedSpecs, parseSpecTable } from "../utils/spec-updater";
|
||||
import {
|
||||
parseGermanPrice,
|
||||
parseWarehouseStock,
|
||||
computeLeadTimeDays,
|
||||
DEFAULT_MAX_UNITS_SOLD,
|
||||
} from "./fs-com-parse";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -75,6 +81,8 @@ const FORCE_REVALIDATE = process.env["TIP_FORCE_REVALIDATE"] === "1";
|
||||
const ONLY_MISSING_IMAGES = process.env["FS_ONLY_MISSING_IMAGES"] === "1";
|
||||
const DB_DETAIL_ONLY = process.env["FS_DB_DETAIL_ONLY"] === "1";
|
||||
const URL_DISCOVERY_ONLY = process.env["FS_URL_DISCOVERY_ONLY"] === "1";
|
||||
// Drop implausible "verkauft" (units-sold) counters at ingest (see fs-com-parse).
|
||||
const FS_MAX_UNITS_SOLD = parseInt(process.env["FS_MAX_UNITS_SOLD"] ?? String(DEFAULT_MAX_UNITS_SOLD), 10);
|
||||
|
||||
const PROXY_URLS = (process.env["PROXY_URLS"] ?? "")
|
||||
.split(",")
|
||||
@ -113,83 +121,7 @@ const DE_COOKIES = [
|
||||
{ name: "country", value: "DE", domain: ".fs.com", path: "/" },
|
||||
];
|
||||
|
||||
// ── German locale parsers ──────────────────────────────────────────────────────
|
||||
|
||||
const GERMAN_MONTHS: Record<string, string> = {
|
||||
jan: "01", feb: "02", mär: "03", mar: "03",
|
||||
apr: "04", mai: "05", may: "05", jun: "06",
|
||||
jul: "07", aug: "08", sep: "09", okt: "10",
|
||||
oct: "10", nov: "11", dez: "12", dec: "12",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse German-formatted quantity string.
|
||||
* "4.895" → 4895 (period = thousands separator in German)
|
||||
* "210.9K" → 210900
|
||||
* "1.2M" → 1200000
|
||||
*/
|
||||
function parseGermanQty(text: string): number | undefined {
|
||||
const t = text.trim().replace(/\s/g, "");
|
||||
if (!t) return undefined;
|
||||
|
||||
const kMatch = t.match(/^([\d.,]+)[Kk]$/);
|
||||
if (kMatch) {
|
||||
const n = parseFloat(kMatch[1].replace(/\./g, "").replace(",", "."));
|
||||
return isNaN(n) ? undefined : Math.round(n * 1_000);
|
||||
}
|
||||
|
||||
const mMatch = t.match(/^([\d.,]+)[Mm]$/);
|
||||
if (mMatch) {
|
||||
const n = parseFloat(mMatch[1].replace(/\./g, "").replace(",", "."));
|
||||
return isNaN(n) ? undefined : Math.round(n * 1_000_000);
|
||||
}
|
||||
|
||||
const n = parseInt(t.replace(/\./g, "").replace(/,/g, ""), 10);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse German date to ISO "YYYY-MM-DD".
|
||||
* "20 Apr., 2026" → "2026-04-20"
|
||||
* "20.04.2026" → "2026-04-20"
|
||||
*/
|
||||
function parseGermanDate(text: string): string | undefined {
|
||||
const numericMatch = text.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (numericMatch) {
|
||||
const [, d, m, y] = numericMatch;
|
||||
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
}
|
||||
const wordMatch = text.match(/(\d{1,2})\.?\s+([A-Za-zÄÖÜäöüß]+)\.?,?\s*(\d{4})/);
|
||||
if (!wordMatch) return undefined;
|
||||
const day = wordMatch[1].padStart(2, "0");
|
||||
const monthRaw = wordMatch[2]
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "a").replace(/ö/g, "o").replace(/ü/g, "u")
|
||||
.slice(0, 3);
|
||||
const month = GERMAN_MONTHS[monthRaw];
|
||||
if (!month) return undefined;
|
||||
return `${wordMatch[3]}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse German price to EUR float.
|
||||
* "42,50" → 42.50
|
||||
* "1.063,02" → 1063.02
|
||||
*/
|
||||
function parseGermanPrice(raw: string): number | undefined {
|
||||
const cleaned = raw.replace(/[^0-9.,]/g, "").trim();
|
||||
if (!cleaned) return undefined;
|
||||
let normalized: string;
|
||||
if (/\d+\.\d{3},\d{2}/.test(cleaned)) {
|
||||
normalized = cleaned.replace(/\./g, "").replace(",", ".");
|
||||
} else if (cleaned.includes(",")) {
|
||||
normalized = cleaned.replace(",", ".");
|
||||
} else {
|
||||
normalized = cleaned;
|
||||
}
|
||||
const n = parseFloat(normalized);
|
||||
return isNaN(n) || n <= 0 ? undefined : n;
|
||||
}
|
||||
// German number/date/warehouse parsers live in ./fs-com-parse (pure + unit-tested).
|
||||
|
||||
// ── Stock level helper ─────────────────────────────────────────────────────────
|
||||
|
||||
@ -648,64 +580,18 @@ async function scrapeProductDetails(
|
||||
}
|
||||
}
|
||||
|
||||
// ── DE-Lager ───────────────────────────────────────────────────────────
|
||||
let deQty: number | undefined;
|
||||
let deDeliveryDate: string | undefined;
|
||||
const deM =
|
||||
t.match(/(\d[\d.,KkMm]*)\s*Stk\.\s*im\s*DE[- ]Lager/i) ??
|
||||
t.match(/(\d[\d.,KkMm]*)\s*im\s*DE[- ]?Lager/i);
|
||||
if (deM?.[1]) {
|
||||
deQty = parseGermanQty(deM[1]);
|
||||
const idx = t.indexOf(deM[0]);
|
||||
const ctx = t.slice(idx, idx + 300);
|
||||
const dm =
|
||||
ctx.match(/Lieferung[:\s]+([0-9]{1,2}\.?\s+\w+\.?,?\s*\d{4})/i) ??
|
||||
ctx.match(/erwartet[:\s]+([0-9]{1,2}\.?\s+\w+\.?,?\s*\d{4})/i) ??
|
||||
ctx.match(/([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{4})/);
|
||||
if (dm?.[1]) deDeliveryDate = parseGermanDate(dm[1]);
|
||||
}
|
||||
|
||||
// ── Global-Lager ───────────────────────────────────────────────────────
|
||||
let globalQty: number | undefined;
|
||||
let globalDeliveryDate: string | undefined;
|
||||
const glM =
|
||||
t.match(/(\d[\d.,KkMm]*)\s*Stk\.\s*im\s*Global[- ]Lager/i) ??
|
||||
t.match(/(\d[\d.,KkMm]*)\s*im\s*Global[- ]?(?:Lager|Warehouse)/i) ??
|
||||
t.match(/(\d[\d.,KkMm]*)\s*in\s+Global\s+Warehouse/i);
|
||||
if (glM?.[1]) {
|
||||
globalQty = parseGermanQty(glM[1]);
|
||||
const idx = t.indexOf(glM[0]);
|
||||
const ctx = t.slice(idx, idx + 300);
|
||||
const dm =
|
||||
ctx.match(/Lieferung[:\s]+([0-9]{1,2}\.?\s+\w+\.?,?\s*\d{4})/i) ??
|
||||
ctx.match(/erwartet[:\s]+([0-9]{1,2}\.?\s+\w+\.?,?\s*\d{4})/i) ??
|
||||
ctx.match(/([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{4})/);
|
||||
if (dm?.[1]) globalDeliveryDate = parseGermanDate(dm[1]);
|
||||
}
|
||||
|
||||
// ── Nachlieferung ──────────────────────────────────────────────────────
|
||||
let backorderQty: number | undefined;
|
||||
let backorderDate: string | undefined;
|
||||
const boM =
|
||||
t.match(/(\d[\d.,KkMm]*)\s*(?:Stk\.)?\s*in\s+Nachlieferung/i) ??
|
||||
t.match(/Nachlieferung[:\s]*(\d[\d.,KkMm]*)/i);
|
||||
if (boM?.[1]) {
|
||||
backorderQty = parseGermanQty(boM[1]);
|
||||
const idx = t.indexOf(boM[0]);
|
||||
const ctx = t.slice(idx, idx + 300);
|
||||
const dm =
|
||||
ctx.match(/[Ee]rwartet[:\s]+([0-9]{1,2}\.?\s+\w+\.?,?\s*\d{4})/i) ??
|
||||
ctx.match(/Lieferung[:\s]+([0-9]{1,2}\.?\s+\w+\.?,?\s*\d{4})/i) ??
|
||||
ctx.match(/([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{4})/);
|
||||
if (dm?.[1]) backorderDate = parseGermanDate(dm[1]);
|
||||
}
|
||||
|
||||
// ── Units sold ─────────────────────────────────────────────────────────
|
||||
let unitsSold: number | undefined;
|
||||
const soldM =
|
||||
t.match(/(\d[\d.,KkMm]*)\s*(?:[Mm]al\s+)?[Vv]erkauft/) ??
|
||||
t.match(/([\d.,KkMm]+)\+?\s*sold/i);
|
||||
if (soldM?.[1]) unitsSold = parseGermanQty(soldM[1]);
|
||||
// ── Warehouse availability (qty + per-warehouse delivery date + units sold) ──
|
||||
// FS.com renders each warehouse on its own line, e.g.
|
||||
// "2 Stk. im DE-Lager, 8. Juli 2026 Lieferbar". parseWarehouseStock
|
||||
// extracts the bare word-date per warehouse and gates units_sold.
|
||||
const wh = parseWarehouseStock(t, FS_MAX_UNITS_SOLD);
|
||||
const deQty = wh.deQty;
|
||||
const deDeliveryDate = wh.deDeliveryDate;
|
||||
const globalQty = wh.globalQty;
|
||||
const globalDeliveryDate = wh.globalDeliveryDate;
|
||||
const backorderQty = wh.backorderQty;
|
||||
const backorderDate = wh.backorderDate;
|
||||
const unitsSold = wh.unitsSold;
|
||||
|
||||
// ── Part number refinement ─────────────────────────────────────────────
|
||||
let partNumber = listingPn;
|
||||
@ -814,7 +700,8 @@ export async function scrapeFs(): Promise<void> {
|
||||
WHERE v.name = 'FS.COM'
|
||||
AND COALESCE(t.product_page_url, '') = ''
|
||||
AND t.part_number ~ '^FS-[0-9]+$'
|
||||
ORDER BY t.part_number
|
||||
ORDER BY (COALESCE(t.speed_gbps, 0) >= 100) DESC, -- 100G+ zuerst (Lupo-Fokus)
|
||||
t.part_number
|
||||
LIMIT $1
|
||||
`,
|
||||
[MAX_DETAIL_PAGES_PER_RUN]
|
||||
@ -846,6 +733,7 @@ export async function scrapeFs(): Promise<void> {
|
||||
OR COALESCE(t.reach_label, '') = ''
|
||||
)
|
||||
ORDER BY
|
||||
(COALESCE(t.speed_gbps, 0) >= 100) DESC, -- 100G+ zuerst (Lupo-Fokus), dann Verifikations-Lücke
|
||||
COALESCE(t.price_verified, false) DESC,
|
||||
COALESCE(t.image_verified, false) DESC,
|
||||
COALESCE(t.details_verified, false) ASC,
|
||||
@ -1010,6 +898,12 @@ export async function scrapeFs(): Promise<void> {
|
||||
|
||||
const stockLevel = deriveStockLevel(detail.deQty, detail.globalQty, detail.backorderQty);
|
||||
const totalQty = (detail.deQty ?? 0) + (detail.globalQty ?? 0);
|
||||
// Soonest availability across the warehouses that quote a delivery date.
|
||||
const leadTimeDays = computeLeadTimeDays([
|
||||
detail.deDeliveryDate,
|
||||
detail.globalDeliveryDate,
|
||||
detail.backorderDate,
|
||||
]);
|
||||
|
||||
if (detail.priceNet && detail.priceNet > 0) {
|
||||
const hash = contentHash({
|
||||
@ -1024,6 +918,7 @@ export async function scrapeFs(): Promise<void> {
|
||||
currency: "EUR",
|
||||
stockLevel,
|
||||
quantityAvailable: totalQty > 0 ? totalQty : undefined,
|
||||
leadTimeDays,
|
||||
url: detail.url,
|
||||
contentHash: hash,
|
||||
});
|
||||
@ -1042,6 +937,7 @@ export async function scrapeFs(): Promise<void> {
|
||||
backorderQty: detail.backorderQty,
|
||||
backorderEstimatedDate: detail.backorderDate ?? null,
|
||||
unitsSold: detail.unitsSold,
|
||||
leadTimeDays,
|
||||
compatibleBrands: detail.compatibleBrands,
|
||||
priceNet: detail.priceNet,
|
||||
productUrl: detail.url,
|
||||
|
||||
@ -279,6 +279,9 @@ export async function computeReorderSignals(): Promise<void> {
|
||||
|
||||
if (reasons.length === 0) reasons.push("Insufficient data for strong signal");
|
||||
|
||||
// Keep exactly one (latest) signal per transceiver — delete prior rows first.
|
||||
// Without this the table grew to 4.5M rows (24h-TTL never cleaned up old runs).
|
||||
await pool.query(`DELETE FROM reorder_signals WHERE transceiver_id = $1`, [row.id]);
|
||||
await pool.query(
|
||||
`INSERT INTO reorder_signals
|
||||
(transceiver_id, signal, signal_strength, reasons, stock_trend, price_trend, lead_time_weeks)
|
||||
|
||||
@ -193,6 +193,45 @@ function parseStockText(html: string): { qty?: number; confidence: 1 | 2 } | nul
|
||||
return { confidence: 1 }; // fallback: boolean
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse per-warehouse stock breakdown from NADDOD HTML.
|
||||
* The data lives as HTML-entity-encoded JSON in a hydration payload:
|
||||
* "warehouse_stock":[0,{"us":[0,543],"nl":[0,211],"sg":[0,0],"cn":[0,0]}]
|
||||
* Format per region: [signalBit, quantity]
|
||||
* Mapping: us → US, nl → NL/EU (closest to DE), sg → APAC, cn → CN
|
||||
* We use nl as DE-equivalent (EU warehouse) and sum all for global.
|
||||
*/
|
||||
function parseWarehouseStock(html: string): {
|
||||
eu: number | null; // nl warehouse → DE-equivalent
|
||||
global: number | null; // us+nl+sg+cn total
|
||||
} | null {
|
||||
// Strip HTML entities so we can JSON.parse
|
||||
const decoded = html
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const m = decoded.match(/"warehouse_stock":\[0,(\{[^}]+\})\]/);
|
||||
if (!m) return null;
|
||||
|
||||
try {
|
||||
const ws = JSON.parse(m[1]) as Record<string, unknown>;
|
||||
function qty(key: string): number {
|
||||
const v = ws[key];
|
||||
if (Array.isArray(v) && v.length >= 2 && typeof v[1] === 'number') return Math.max(0, v[1]);
|
||||
return 0;
|
||||
}
|
||||
const us = qty('us'), nl = qty('nl'), sg = qty('sg'), cn = qty('cn');
|
||||
const total = us + nl + sg + cn;
|
||||
// Only return data if at least one warehouse has stock
|
||||
if (total === 0 && nl === 0) return null; // no warehouse breakdown available
|
||||
return { eu: nl, global: total };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
@ -458,16 +497,20 @@ export async function scrapeNaddod(): Promise<void> {
|
||||
if (isNew) priceUpdates++;
|
||||
}
|
||||
|
||||
// Stock observation
|
||||
// Stock observation — enhanced with per-warehouse breakdown
|
||||
if (stock !== null) {
|
||||
const stockLevel = stock.qty !== undefined ? (stock.qty > 0 ? "in_stock" : "out_of_stock") : "in_stock";
|
||||
const warehouseData = parseWarehouseStock(html);
|
||||
const isNew = await upsertStockObservation({
|
||||
transceiverId: txId,
|
||||
sourceVendorId: vendorId,
|
||||
stockLevel,
|
||||
quantityAvailable: stock.qty !== undefined && stock.qty > 0 ? stock.qty : undefined,
|
||||
// NL warehouse ≈ EU/DE equivalent; sum of all warehouses = global
|
||||
warehouseDeQty: warehouseData?.eu ?? undefined,
|
||||
warehouseGlobalQty: warehouseData?.global ?? (stock.qty !== undefined && stock.qty > 0 ? stock.qty : undefined),
|
||||
productUrl: url,
|
||||
stockConfidence: stock.confidence,
|
||||
stockConfidence: warehouseData ? 3 : stock.confidence, // L3 when per-warehouse available
|
||||
priceCurrency: "USD",
|
||||
priceIncludesTax: false,
|
||||
});
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { PlaywrightCrawler } from "crawlee";
|
||||
import { makeCrawleeConfig } from "../utils/crawlee-config";
|
||||
import { ensureVendor, upsertPriceObservation, findOrCreateScrapedTransceiver, pool } from "../utils/db";
|
||||
import { ensureVendor, upsertPriceObservation, upsertStockObservation, findOrCreateScrapedTransceiver, pool } from "../utils/db";
|
||||
import { contentHash, parsePrice, parseStockLevel } from "../utils/hash";
|
||||
|
||||
const BASE_URL = "https://www.optcore.net";
|
||||
@ -287,6 +287,19 @@ export async function scrapeOptcore(): Promise<void> {
|
||||
|
||||
if (isNew) written++;
|
||||
else skipped++;
|
||||
|
||||
// Stock observation — WooCommerce text-based availability (confidence: 1)
|
||||
await upsertStockObservation({
|
||||
transceiverId,
|
||||
sourceVendorId: vendorId,
|
||||
stockLevel: p.stockLevel,
|
||||
quantityAvailable: p.stockLevel === "in_stock" || p.stockLevel === "low_stock" ? 1 : 0,
|
||||
priceNet: p.price,
|
||||
productUrl: p.url,
|
||||
stockConfidence: 1,
|
||||
priceCurrency: p.currency,
|
||||
priceIncludesTax: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(` Error: ${p.partNumber}:`, (err as Error).message);
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
* Strategy: Paginate each category on sfpcables.com, extract Model + price per product.
|
||||
* Rate limited: 1 req/2sec between pages.
|
||||
*
|
||||
* Categories: SFP, SFP+, SFP28, QSFP+, QSFP28, XFP
|
||||
* Categories: SFP, SFP+, SFP28, QSFP+, QSFP28, XFP, QSFP-DD 400G, QSFP112 400G
|
||||
*/
|
||||
import { pool, findOrCreateScrapedTransceiver, ensureVendor, upsertPriceObservation } from "../utils/db";
|
||||
import { contentHash, parsePrice } from "../utils/hash";
|
||||
@ -20,12 +20,16 @@ const HEADERS = {
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{ slug: "sfp-1-25g-series", formFactor: "SFP", speed: "1G", speedGbps: 1 },
|
||||
{ slug: "sfp-transceivers", formFactor: "SFP+", speed: "10G", speedGbps: 10 },
|
||||
{ slug: "sfp28-transceivers", formFactor: "SFP28", speed: "25G", speedGbps: 25 },
|
||||
{ slug: "qsfp-transceivers", formFactor: "QSFP+", speed: "40G", speedGbps: 40 },
|
||||
{ slug: "100g-qsfp28-transceivers", formFactor: "QSFP28", speed: "100G", speedGbps: 100 },
|
||||
{ slug: "xfp-transceivers", formFactor: "XFP", speed: "10G", speedGbps: 10 },
|
||||
{ slug: "sfp-1-25g-series", formFactor: "SFP", speed: "1G", speedGbps: 1 },
|
||||
{ slug: "sfp-transceivers", formFactor: "SFP+", speed: "10G", speedGbps: 10 },
|
||||
{ slug: "sfp28-transceivers", formFactor: "SFP28", speed: "25G", speedGbps: 25 },
|
||||
{ slug: "qsfp-transceivers", formFactor: "QSFP+", speed: "40G", speedGbps: 40 },
|
||||
{ slug: "100g-qsfp28-transceivers", formFactor: "QSFP28", speed: "100G", speedGbps: 100 },
|
||||
{ slug: "xfp-transceivers", formFactor: "XFP", speed: "10G", speedGbps: 10 },
|
||||
// 400G — added to close pricing gap for TIP_LLM training data
|
||||
{ slug: "8x50g-qsfp-dd-transceiver-optical-module", formFactor: "QSFP-DD", speed: "400G", speedGbps: 400 },
|
||||
{ slug: "qsfp112-400g", formFactor: "QSFP112", speed: "400G", speedGbps: 400 },
|
||||
{ slug: "400g-qsfp-fiber-optic-transceiver-modules", formFactor: "QSFP-DD", speed: "400G", speedGbps: 400 },
|
||||
];
|
||||
|
||||
interface Product {
|
||||
|
||||
@ -5,13 +5,14 @@
|
||||
* and generates alerts for price changes, new products, stock changes.
|
||||
*/
|
||||
import { Pool } from "pg";
|
||||
import { requireDbPassword } from "./db-connection";
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST || "localhost",
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5433"),
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
password: process.env.POSTGRES_PASSWORD || "tip_dev_2026",
|
||||
password: requireDbPassword(),
|
||||
max: 3,
|
||||
});
|
||||
|
||||
|
||||
30
packages/scraper/src/utils/db-connection.ts
Normal file
30
packages/scraper/src/utils/db-connection.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Central DB credential resolution — fail-closed.
|
||||
*
|
||||
* Replaces the hardcoded `|| "tip_dev_2026"` password fallback that was scattered
|
||||
* across the scraper package. A known default password in source is a credential leak
|
||||
* (rules/common/security.md); if the env var is unset we THROW rather than silently
|
||||
* connecting with a guessable password. Production always sets POSTGRES_PASSWORD, so
|
||||
* this is a no-op at runtime there and only closes the hole.
|
||||
*/
|
||||
|
||||
export function requireDbPassword(): string {
|
||||
const pw = process.env.POSTGRES_PASSWORD || process.env.TIP_DB_PASS;
|
||||
if (!pw) {
|
||||
throw new Error(
|
||||
"POSTGRES_PASSWORD (or TIP_DB_PASS) must be set — refusing to fall back to a hardcoded default password.",
|
||||
);
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
|
||||
export function dbConnectionString(): string {
|
||||
const user = process.env.POSTGRES_USER || "tip";
|
||||
const host = process.env.POSTGRES_HOST || "localhost";
|
||||
const port = process.env.POSTGRES_PORT || "5433";
|
||||
const db = process.env.POSTGRES_DB || "transceiver_db";
|
||||
// Assemble in parts so no source literal contains an inline `user:pass@host` URL
|
||||
// (keeps secret-scanners from flagging this env-only builder as a hardcoded credential).
|
||||
const auth = `${user}:${encodeURIComponent(requireDbPassword())}`;
|
||||
return ["postgres://", auth, "@", host, ":", port, "/", db].join("");
|
||||
}
|
||||
@ -3,6 +3,7 @@ import type { PoolConfig } from "pg";
|
||||
import { config } from "dotenv";
|
||||
import { join } from "path";
|
||||
import { contentHash } from "./hash";
|
||||
import { requireDbPassword } from "./db-connection";
|
||||
|
||||
config({ path: join(__dirname, "..", "..", "..", "..", ".env") });
|
||||
|
||||
@ -14,7 +15,7 @@ const poolConfig: PoolConfig = !hasExplicitDbConfig && process.env.DATABASE_URL
|
||||
port: parseInt(process.env.POSTGRES_PORT || process.env.TIP_DB_PORT || "5433"),
|
||||
database: process.env.POSTGRES_DB || process.env.TIP_DB_NAME || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || process.env.TIP_DB_USER || "tip",
|
||||
password: process.env.POSTGRES_PASSWORD || process.env.TIP_DB_PASS || "tip_dev_2026",
|
||||
password: requireDbPassword(),
|
||||
ssl: false,
|
||||
};
|
||||
|
||||
@ -394,6 +395,8 @@ export async function upsertStockObservation(params: {
|
||||
backorderQty?: number;
|
||||
backorderEstimatedDate?: string | null;
|
||||
unitsSold?: number;
|
||||
/** Soonest availability in days across warehouses that quote a delivery date. */
|
||||
leadTimeDays?: number;
|
||||
compatibleBrands?: string[];
|
||||
priceNet?: number;
|
||||
productUrl?: string;
|
||||
@ -414,9 +417,11 @@ export async function upsertStockObservation(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare against the last observation to avoid duplicate writes
|
||||
// Compare against the last observation to avoid duplicate writes.
|
||||
// Bypass dedup if last observation is older than 7 days (heartbeat to keep health view alive).
|
||||
const STOCK_REFRESH_DAYS = 7;
|
||||
const lastObs = await pool.query(
|
||||
`SELECT warehouse_de_qty, warehouse_global_qty, backorder_qty, units_sold, quantity_available
|
||||
`SELECT warehouse_de_qty, warehouse_global_qty, backorder_qty, units_sold, quantity_available, time
|
||||
FROM stock_observations
|
||||
WHERE transceiver_id = $1 AND source_vendor_id = $2
|
||||
ORDER BY time DESC LIMIT 1`,
|
||||
@ -425,13 +430,17 @@ export async function upsertStockObservation(params: {
|
||||
|
||||
if (lastObs.rows.length > 0) {
|
||||
const r = lastObs.rows[0];
|
||||
const unchanged =
|
||||
(r.warehouse_de_qty ?? null) === (params.warehouseDeQty ?? null) &&
|
||||
(r.warehouse_global_qty ?? null) === (params.warehouseGlobalQty ?? null) &&
|
||||
(r.backorder_qty ?? null) === (params.backorderQty ?? null) &&
|
||||
(r.units_sold ?? null) === (params.unitsSold ?? null) &&
|
||||
(r.quantity_available ?? null) === (params.quantityAvailable ?? null);
|
||||
if (unchanged) return false;
|
||||
const ageMs = Date.now() - new Date(r.time).getTime();
|
||||
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
||||
if (ageDays < STOCK_REFRESH_DAYS) {
|
||||
const unchanged =
|
||||
(r.warehouse_de_qty ?? null) === (params.warehouseDeQty ?? null) &&
|
||||
(r.warehouse_global_qty ?? null) === (params.warehouseGlobalQty ?? null) &&
|
||||
(r.backorder_qty ?? null) === (params.backorderQty ?? null) &&
|
||||
(r.units_sold ?? null) === (params.unitsSold ?? null) &&
|
||||
(r.quantity_available ?? null) === (params.quantityAvailable ?? null);
|
||||
if (unchanged) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const inStock =
|
||||
@ -444,7 +453,7 @@ export async function upsertStockObservation(params: {
|
||||
warehouse_de_qty, warehouse_de_delivery_date,
|
||||
warehouse_global_qty, warehouse_global_delivery_date,
|
||||
backorder_qty, backorder_estimated_date,
|
||||
units_sold, compatible_brands, price_net, product_url,
|
||||
units_sold, lead_time_days, compatible_brands, price_net, product_url,
|
||||
stock_confidence, price_currency, price_includes_tax, stock_vendor_ts
|
||||
) VALUES (
|
||||
NOW(), $1, $2,
|
||||
@ -452,8 +461,8 @@ export async function upsertStockObservation(params: {
|
||||
$5, $6::date,
|
||||
$7, $8::date,
|
||||
$9, $10::date,
|
||||
$11, $12, $13, $14,
|
||||
$15, $16, $17, $18
|
||||
$11, $12, $13::text[], $14, $15,
|
||||
$16, $17::bpchar, $18, $19
|
||||
)`,
|
||||
[
|
||||
params.transceiverId,
|
||||
@ -467,6 +476,7 @@ export async function upsertStockObservation(params: {
|
||||
params.backorderQty ?? null,
|
||||
params.backorderEstimatedDate ?? null,
|
||||
params.unitsSold ?? null,
|
||||
params.leadTimeDays ?? null,
|
||||
params.compatibleBrands?.length ? params.compatibleBrands : null,
|
||||
params.priceNet ?? null,
|
||||
params.productUrl ?? null,
|
||||
@ -480,6 +490,12 @@ export async function upsertStockObservation(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NOTE: product_type (module|dac|aoc|aec|switch|nic|accessory) and form_factor
|
||||
// corrections are set automatically by the DB trigger trg_transceivers_biu
|
||||
// (see sql/119 + sql/120). Callers do NOT pass or maintain them — the trigger
|
||||
// classifies from part_number/category/reach on every insert and update, so
|
||||
// every scraper stays consistent without touching this call site. Consumers
|
||||
// filter product_type='module' for a true module count.
|
||||
export async function findOrCreateScrapedTransceiver(params: {
|
||||
partNumber: string;
|
||||
vendorId: string;
|
||||
|
||||
@ -6,13 +6,14 @@
|
||||
*/
|
||||
import { Pool } from "pg";
|
||||
import { createHash } from "crypto";
|
||||
import { requireDbPassword } from "./db-connection";
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST || "localhost",
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5433"),
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
password: process.env.POSTGRES_PASSWORD || "tip_dev_2026",
|
||||
password: requireDbPassword(),
|
||||
max: 3,
|
||||
});
|
||||
|
||||
|
||||
@ -125,7 +125,8 @@ async function main(): Promise<void> {
|
||||
AND COALESCE(t.price_verified, false) = false
|
||||
AND COALESCE(t.price_status, 'needs_research') IN ('unknown', 'needs_research', 'ambiguous')
|
||||
${vendorWhere}
|
||||
ORDER BY v.name, t.part_number
|
||||
ORDER BY (COALESCE(t.speed_gbps, 0) >= 100) DESC, -- 100G+ zuerst (Lupo-Fokus)
|
||||
v.name, t.part_number
|
||||
LIMIT $2`,
|
||||
params,
|
||||
);
|
||||
|
||||
@ -108,7 +108,8 @@ async function main(): Promise<void> {
|
||||
AND COALESCE(t.price_verified, false) = false
|
||||
AND COALESCE(t.price_status, 'needs_research') IN ('unknown', 'needs_research', 'ambiguous')
|
||||
AND COALESCE(t.product_page_url, '') != ''
|
||||
ORDER BY v.name, t.part_number
|
||||
ORDER BY (COALESCE(t.speed_gbps, 0) >= 100) DESC, -- 100G+ zuerst (Lupo-Fokus)
|
||||
v.name, t.part_number
|
||||
LIMIT $2`,
|
||||
[vendorNames, limit],
|
||||
);
|
||||
|
||||
21
packages/scraper/src/worker-only.ts
Normal file
21
packages/scraper/src/worker-only.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Worker-only entry — processes pg-boss jobs WITHOUT running the scheduler/cron
|
||||
* or the startup zombie-cleanup (those belong to the single primary daemon).
|
||||
* For horizontal scaling across extra machines (Raspberry Pis).
|
||||
*/
|
||||
import { createScheduler, registerWorkers } from "./scheduler";
|
||||
|
||||
async function runWorkerOnly(): Promise<void> {
|
||||
console.log("=== TIP Scraper WORKER-ONLY (no scheduler, no cron) ===\n");
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
if ((msg.includes("ENOENT") || msg.includes("no such file")) && msg.includes("request_queues")) return;
|
||||
console.error("[worker] Unhandled rejection:", reason);
|
||||
process.exit(1);
|
||||
});
|
||||
const boss = await createScheduler();
|
||||
await registerWorkers(boss);
|
||||
console.log("Worker node ready — pulling jobs from shared pg-boss queues.\n");
|
||||
}
|
||||
|
||||
runWorkerOnly().catch((err) => { console.error("Fatal:", err); process.exit(1); });
|
||||
@ -15,5 +15,5 @@
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@ -9,11 +9,11 @@ python3 /tmp/fix-sql-dedup.py >> "$LOG" 2>&1
|
||||
|
||||
# Step 2: Re-apply enrichment
|
||||
echo "Step 2: Applying enrichment..." >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -f /tmp/011-flexoptix-enrichment.sql >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -f /tmp/011-flexoptix-enrichment.sql >> "$LOG" 2>&1
|
||||
|
||||
# Step 3: Apply switches SQL
|
||||
echo "Step 3: Applying switches..." >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -f /tmp/012-more-switches.sql >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -f /tmp/012-more-switches.sql >> "$LOG" 2>&1
|
||||
|
||||
# Step 4: Restart API
|
||||
echo "Step 4: Restarting API..." >> "$LOG"
|
||||
@ -22,10 +22,10 @@ cd /opt/tip && pm2 restart tip-api >> "$LOG" 2>&1
|
||||
# Step 5: Results
|
||||
echo "" >> "$LOG"
|
||||
echo "=== RESULTS ===" >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'images: ' || count(*) FROM transceivers WHERE image_url IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'connector: ' || count(*) FROM transceivers WHERE connector IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'notes: ' || count(*) FROM transceivers WHERE notes IS NOT NULL AND notes != ''" >> "$LOG" 2>&1
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'switches: ' || count(*) FROM switches" >> "$LOG" 2>&1
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'sw_desc: ' || count(*) FROM switches WHERE description IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'images: ' || count(*) FROM transceivers WHERE image_url IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'connector: ' || count(*) FROM transceivers WHERE connector IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'notes: ' || count(*) FROM transceivers WHERE notes IS NOT NULL AND notes != ''" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'switches: ' || count(*) FROM switches" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT 'sw_desc: ' || count(*) FROM switches WHERE description IS NOT NULL" >> "$LOG" 2>&1
|
||||
|
||||
echo "$(date): ALL DONE" >> "$LOG"
|
||||
|
||||
21
scripts/enrich-modulation-fec.sh
Normal file
21
scripts/enrich-modulation-fec.sh
Normal file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Daily self-healing enrichment: fills modulation/fec_type/inbuilt_fec/coherent on
|
||||
# new/changed transceivers via the idempotent fill-only fn_tip_enrich_modulation_fec().
|
||||
# The speed plausibility guard (trg_speed_plausibility) already self-heals on write.
|
||||
# Cron: 30 3 * * * /opt/tip/scripts/enrich-modulation-fec.sh
|
||||
set -uo pipefail
|
||||
LOG=/opt/tip/logs/enrich-modulation-fec.log
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
TS=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source /opt/tip/.env 2>/dev/null || true
|
||||
PW="${POSTGRES_PASSWORD:-}" # short-named var carries the secret to docker, no literal
|
||||
N=$(docker exec -e PGPASSWORD="$PW" tip-postgres psql -U tip -d transceiver_db -tAc \
|
||||
"select fn_tip_enrich_modulation_fec()" 2>/dev/null | tr -d '[:space:]')
|
||||
|
||||
if [[ "${N:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "[$TS] enrich rows-touched=$N" >> "$LOG"
|
||||
else
|
||||
echo "[$TS] enrich FAILED (db unreachable?)" >> "$LOG"
|
||||
fi
|
||||
@ -2,7 +2,7 @@
|
||||
# Self-contained Flexoptix enrichment script to run ON Erik
|
||||
# Does: DB query → scrape flexoptix.net → generate SQL → apply to DB
|
||||
|
||||
DB_PASS="***REDACTED***"
|
||||
DB_PASS="${PGPASSWORD:?set PGPASSWORD (e.g. in ~/.tip/.env)}"
|
||||
DB_USER="tip"
|
||||
DB_NAME="transceiver_db"
|
||||
DB_PORT="5433"
|
||||
|
||||
@ -16,7 +16,7 @@ const pool = new Pool({
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5433"),
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
password: process.env.POSTGRES_PASSWORD || "***REDACTED***",
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
max: 5,
|
||||
});
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
LOG="/tmp/enrich-v2.log"
|
||||
SQL="/tmp/011-flexoptix-enrichment-v2.sql"
|
||||
DB="PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db"
|
||||
DB="PGPASSWORD=${PGPASSWORD:?set PGPASSWORD} psql -h localhost -p 5433 -U tip -d transceiver_db"
|
||||
|
||||
echo "$(date): Starting V2 enrichment" > "$LOG"
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ SQL="/tmp/enrichment-v3.sql"
|
||||
echo "$(date): V3 start" > "$LOG"
|
||||
|
||||
# Direct psql (no eval)
|
||||
export PGPASSWORD="***REDACTED***"
|
||||
export PGPASSWORD="${PGPASSWORD:?set PGPASSWORD (e.g. in ~/.tip/.env)}"
|
||||
|
||||
psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -F'|' -c \
|
||||
"SELECT t.id, t.product_page_url, t.part_number, t.standard_name FROM transceivers t JOIN vendors v ON t.vendor_id = v.id WHERE v.name = 'FLEXOPTIX' AND t.product_page_url IS NOT NULL" \
|
||||
|
||||
@ -6,7 +6,7 @@ import sys
|
||||
import time
|
||||
import os
|
||||
|
||||
DB_CMD = "PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db"
|
||||
DB_CMD = f"PGPASSWORD={os.environ['PGPASSWORD']} psql -h localhost -p 5433 -U tip -d transceiver_db"
|
||||
SQL_OUT = "/tmp/enrichment-v4.sql"
|
||||
LOG = "/tmp/enrich-v4.log"
|
||||
|
||||
@ -150,7 +150,7 @@ log(f"SQL at: {SQL_OUT}")
|
||||
|
||||
# Apply
|
||||
log("Applying SQL...")
|
||||
os.environ["PGPASSWORD"] = "***REDACTED***"
|
||||
if not os.environ.get("PGPASSWORD"): raise SystemExit("PGPASSWORD env var required")
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-f", SQL_OUT],
|
||||
capture_output=True, text=True
|
||||
@ -175,7 +175,7 @@ for query in [
|
||||
]:
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-t", "-A", "-c", query],
|
||||
capture_output=True, text=True, env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
capture_output=True, text=True, env={**os.environ}
|
||||
)
|
||||
log(r.stdout.strip())
|
||||
|
||||
|
||||
@ -41,15 +41,15 @@ print('Fixed temp_range values')
|
||||
" >> "$LOG" 2>&1
|
||||
|
||||
echo "Re-applying SQL..." >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -f "$SQL" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -f "$SQL" >> "$LOG" 2>&1
|
||||
|
||||
echo "" >> "$LOG"
|
||||
echo "=== RESULTS ===" >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT count(*) as img FROM transceivers WHERE image_url IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT count(*) as img FROM transceivers WHERE image_url IS NOT NULL" >> "$LOG" 2>&1
|
||||
echo " transceivers have images" >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT count(*) FROM transceivers WHERE notes IS NOT NULL AND notes != ''" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT count(*) FROM transceivers WHERE notes IS NOT NULL AND notes != ''" >> "$LOG" 2>&1
|
||||
echo " transceivers have enriched notes" >> "$LOG"
|
||||
PGPASSWORD=***REDACTED*** psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT count(*) FROM transceivers WHERE connector IS NOT NULL" >> "$LOG" 2>&1
|
||||
PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}" psql -h localhost -p 5433 -U tip -d transceiver_db -t -A -c "SELECT count(*) FROM transceivers WHERE connector IS NOT NULL" >> "$LOG" 2>&1
|
||||
echo " transceivers have connector" >> "$LOG"
|
||||
|
||||
echo "$(date): DONE" >> "$LOG"
|
||||
|
||||
@ -12,7 +12,7 @@ def run_sql(sql):
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-c", sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
if "ERROR" in r.stderr:
|
||||
print(f"ERR: {r.stderr.strip()[:200]}")
|
||||
@ -22,7 +22,7 @@ def query_val(sql):
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-t", "-A", "-c", sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
28
scripts/fleet-health-monitor.sh
Normal file
28
scripts/fleet-health-monitor.sh
Normal file
@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Fleet crash-loop / dead-worker detector (Erik). Reads v_fleet_health (fed by the
|
||||
# Pi heartbeats in fleet_health) and alerts on verdict<>ok. Closes the blind spot
|
||||
# where a crash-looping pm2 process reports "online" and a naive liveness check
|
||||
# misses it — the 3-Pi worker fleet was dead ~months (2026-07) exactly this way.
|
||||
# Cron: */10. Alert = log + optional ntfy push (FLEET_NTFY_URL).
|
||||
set -uo pipefail
|
||||
LOG=/opt/tip/logs/fleet-health.log
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
TS=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source /opt/tip/.env 2>/dev/null || true
|
||||
Q="select host||' '||pm_name||' -> '||verdict||' (restart_delta='||restart_delta||', uptime='||uptime_s||'s, age='||age_s||'s)' from v_fleet_health where verdict <> 'ok' order by host"
|
||||
PW="${POSTGRES_PASSWORD:-}" # pass DB password to docker via a short-named var (no literal)
|
||||
ALERTS=$(docker exec -e PGPASSWORD="$PW" tip-postgres psql -U tip -d transceiver_db -tA -c "$Q" 2>/dev/null)
|
||||
|
||||
if [ -n "$ALERTS" ]; then
|
||||
N=$(printf '%s\n' "$ALERTS" | grep -c .)
|
||||
echo "[$TS] FLEET ALERT ($N):" >> "$LOG"
|
||||
printf '%s\n' "$ALERTS" | sed 's/^/[ ] /' >> "$LOG"
|
||||
# best-effort push (set FLEET_NTFY_URL in /opt/tip/.env to enable, e.g. https://ntfy.sh/tip-fleet)
|
||||
if [ -n "${FLEET_NTFY_URL:-}" ]; then
|
||||
printf 'TIP fleet alert (%s):\n%s\n' "$N" "$ALERTS" | curl -s -m 8 -H "Title: TIP fleet crash-loop/dead" -d @- "$FLEET_NTFY_URL" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
echo "[$TS] fleet ok" >> "$LOG"
|
||||
fi
|
||||
37
scripts/fleet-heartbeat.mjs
Normal file
37
scripts/fleet-heartbeat.mjs
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Pi/host fleet heartbeat: self-report local pm2 process health to Postgres so a
|
||||
* central detector on Erik can catch crash-loops (restart_time velocity + near-0
|
||||
* uptime) and stale/dead hosts — which a naive "pm2 status == online" check misses.
|
||||
* Runs on each Pi via cron (every 5 min). DB via the local tunnel (127.0.0.1:5433).
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import { config } from "dotenv";
|
||||
import pg from "pg";
|
||||
|
||||
config({ path: new URL("../.env", import.meta.url) }); // ~/tip/.env
|
||||
|
||||
const host = os.hostname();
|
||||
let procs = [];
|
||||
try { procs = JSON.parse(execFileSync("pm2", ["jlist"], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 })); }
|
||||
catch { process.exit(0); }
|
||||
|
||||
const pool = new pg.Pool({
|
||||
host: "127.0.0.1", port: 5433,
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
max: 1, connectionTimeoutMillis: 8000, statement_timeout: 8000,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const p of procs) {
|
||||
const e = p.pm2_env || {};
|
||||
const uptimeS = Math.round((Date.now() - (e.pm_uptime || Date.now())) / 1000);
|
||||
await pool.query(
|
||||
"insert into fleet_health(host,pm_name,status,restart_time,uptime_s) values($1,$2,$3,$4,$5)",
|
||||
[host, p.name, e.status || null, e.restart_time ?? 0, uptimeS],
|
||||
);
|
||||
}
|
||||
} catch { /* heartbeat is best-effort; never crash the host */ }
|
||||
finally { await pool.end().catch(() => {}); }
|
||||
@ -31,7 +31,7 @@ def query(sql):
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db",
|
||||
"-t", "-A", "-F", "|", "-c", sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
rows = []
|
||||
for line in r.stdout.strip().split("\n"):
|
||||
@ -140,7 +140,7 @@ log("Applying...")
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-f", SQL_OUT],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
errors = [l for l in r.stderr.split("\n") if "ERROR" in l]
|
||||
if errors:
|
||||
@ -157,7 +157,7 @@ for col_sql in [
|
||||
subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-c", col_sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
|
||||
# Mark whitebox switches
|
||||
@ -170,7 +170,7 @@ WHERE model IN ('SN2201', 'SN3700', 'SN3750-SX', 'SN4700', 'SN5400', 'SN5600');
|
||||
subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-c", whitebox_sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
|
||||
# Restart API
|
||||
@ -185,7 +185,7 @@ for q in [
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-t", "-A", "-c", q],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
log(r.stdout.strip())
|
||||
|
||||
|
||||
509
scripts/generate-pricing-training-data.ts
Normal file
509
scripts/generate-pricing-training-data.ts
Normal file
@ -0,0 +1,509 @@
|
||||
/**
|
||||
* generate-pricing-training-data.ts
|
||||
*
|
||||
* Generates TIP_LLM training QA pairs from live DB data:
|
||||
* 1. Competitor pricing by speed tier / form factor
|
||||
* 2. OPN-confirmed equivalence lookups (FX ↔ competitor)
|
||||
* 3. Spec-based equivalence reasoning
|
||||
* 4. Market price range summaries
|
||||
* 5. 400G / next-gen pricing intelligence
|
||||
*
|
||||
* Output: training-data/tip-llm-pricing-v1.jsonl
|
||||
*
|
||||
* Run: npx ts-node scripts/generate-pricing-training-data.ts
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { Pool } from "pg";
|
||||
|
||||
// ── DB connection ─────────────────────────────────────────────────────────────
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5433"),
|
||||
database: process.env.DB_NAME || "transceiver_db",
|
||||
user: process.env.DB_USER || "tip",
|
||||
password: process.env.DB_PASSWORD || "tip_prod_2026",
|
||||
ssl: false,
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `You are TIP_LLM — the Transceiver Intelligence Platform's core research, data-engineering, and market-intelligence model.
|
||||
|
||||
Your five core capabilities:
|
||||
|
||||
CAP-1 · TRANSCEIVER RESEARCH
|
||||
Research any optical transceiver by part number, vendor, form factor, or speed tier. Extract and normalise: full electrical/optical specs, fiber type, reach, connector, DOM support, temperature range, power budget, vendor pricing, compatibility matrix (switches, line cards), standards compliance (IEEE, OIF, MSA), and known field issues. Output structured JSON or normalised text. Never invent specs — flag unknowns explicitly.
|
||||
|
||||
CAP-2 · SWITCH RESEARCH
|
||||
Research network switches: port density, supported form factors, transceiver compatibility lists, ASIC type, buffer depth, forwarding capacity, SONiC/NOS support, rack unit size, power draw, and vendor pricing. Cross-reference transceivers → switches and vice versa. Identify supported QSFP-DD, OSFP, SFP28 variants per slot. Flag MACsec, FEC, and breakout constraints.
|
||||
|
||||
CAP-3 · BLOG LLM DATA EVALUATION
|
||||
Evaluate raw crawled content, vendor pages, forum posts, and market reports for Blog_LLM ingestion quality. Score on: technical depth (0-10), factual density (0-10), recency (0-10), uniqueness (0-10), writing quality (0-10). Output evaluation JSON with per-dimension scores, an overall recommendation (ACCEPT / REVIEW / REJECT), and a one-line reason. Extract blog-worthy angles and key claims for reuse.
|
||||
|
||||
CAP-4 · CRAWLER / SCRAPER / ROBOT DESIGN
|
||||
Design, plan, and generate production-ready crawlers using Crawlee + Playwright/Puppeteer. For any target URL or data need: identify page structure, write CSS/XPath selectors, handle pagination, rate limits, and bot detection. Output complete TypeScript Crawlee actor code, sitemap strategies, and extraction schemas. Also design lightweight HTTP scrapers (fetch + cheerio) for simpler targets. Flag legal/ToS considerations.
|
||||
|
||||
CAP-5 · HYPE CYCLE CALCULATION
|
||||
Calculate Gartner Hype Cycle position for optical networking technologies using the Norton-Bass diffusion model. Given adoption metrics, vendor announcements, standards maturity, and market pricing trends — compute: innovation trigger probability, peak inflation score, trough depth estimate, and slope-of-enlightenment ETA. Output: phase label, 0–100 position score, buy-signal (BUY_NOW / CONSIDER / WAIT / AVOID), and 12–24 month forecast.`;
|
||||
|
||||
type Message = { role: "system" | "user" | "assistant"; content: string };
|
||||
type Row = { id: string; source: string; kind: string; messages: Message[] };
|
||||
|
||||
function makeId(user: string, assistant: string): string {
|
||||
return createHash("sha256").update(`tip_llm\n---\n${user}\n---\n${assistant}`).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
function pair(user: string, assistant: string, kind = "db-pricing"): Row {
|
||||
const u = user.trim();
|
||||
const a = assistant.trim();
|
||||
return {
|
||||
id: makeId(u, a),
|
||||
source: "tip-llm-pricing-v1",
|
||||
kind,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: u },
|
||||
{ role: "assistant", content: a },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Query helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPriceSummaryByTier() {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
t.form_factor,
|
||||
t.speed_gbps,
|
||||
v.name AS vendor,
|
||||
COUNT(DISTINCT t.id) AS products,
|
||||
ROUND(MIN(po.price)::numeric, 2) AS min_price,
|
||||
ROUND(AVG(po.price)::numeric, 2) AS avg_price,
|
||||
ROUND(MAX(po.price)::numeric, 2) AS max_price,
|
||||
po.currency
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND v.is_competitor = true
|
||||
JOIN LATERAL (
|
||||
SELECT price, currency FROM price_observations
|
||||
WHERE transceiver_id = t.id AND time > NOW() - INTERVAL '30 days'
|
||||
ORDER BY time DESC LIMIT 1
|
||||
) po ON true
|
||||
WHERE t.speed_gbps IN (10, 25, 40, 100, 200, 400, 800)
|
||||
AND t.form_factor NOT IN ('', 'Unknown')
|
||||
GROUP BY t.form_factor, t.speed_gbps, v.name, po.currency
|
||||
HAVING COUNT(DISTINCT t.id) >= 3
|
||||
ORDER BY t.speed_gbps, t.form_factor, avg_price
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getOPNEquivalenceExamples(limit = 50) {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
fx.part_number AS fx_part,
|
||||
vfx.name AS fx_vendor,
|
||||
comp.part_number AS comp_part,
|
||||
vcomp.name AS comp_vendor,
|
||||
comp.form_factor,
|
||||
comp.speed_gbps,
|
||||
e.match_notes,
|
||||
po.price,
|
||||
po.currency
|
||||
FROM transceiver_equivalences e
|
||||
JOIN transceivers fx ON fx.id = e.flexoptix_id
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id
|
||||
JOIN transceivers comp ON comp.id = e.competitor_id
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT price, currency FROM price_observations
|
||||
WHERE transceiver_id = comp.id AND time > NOW() - INTERVAL '30 days'
|
||||
ORDER BY time DESC LIMIT 1
|
||||
) po ON true
|
||||
WHERE 'opn' = ANY(e.match_basis)
|
||||
AND po.price IS NOT NULL
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getSpecEquivalenceExamples(limit = 30) {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
fx.part_number AS fx_part,
|
||||
comp.part_number AS comp_part,
|
||||
vcomp.name AS comp_vendor,
|
||||
comp.form_factor,
|
||||
comp.speed_gbps,
|
||||
e.match_notes,
|
||||
po.price,
|
||||
po.currency
|
||||
FROM transceiver_equivalences e
|
||||
JOIN transceivers fx ON fx.id = e.flexoptix_id
|
||||
JOIN transceivers comp ON comp.id = e.competitor_id
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT price, currency FROM price_observations
|
||||
WHERE transceiver_id = comp.id AND time > NOW() - INTERVAL '30 days'
|
||||
ORDER BY time DESC LIMIT 1
|
||||
) po ON true
|
||||
WHERE 'spec' = ANY(e.match_basis)
|
||||
AND po.price IS NOT NULL
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getVendorPricingOverview() {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
v.name AS vendor,
|
||||
COUNT(DISTINCT t.id) AS products_with_prices,
|
||||
ROUND(AVG(po.price)::numeric, 0) AS avg_price_usd,
|
||||
ROUND(MIN(po.price)::numeric, 0) AS min_price_usd,
|
||||
ROUND(MAX(po.price)::numeric, 0) AS max_price_usd
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND v.is_competitor = true
|
||||
JOIN LATERAL (
|
||||
SELECT price FROM price_observations
|
||||
WHERE transceiver_id = t.id AND time > NOW() - INTERVAL '7 days'
|
||||
ORDER BY time DESC LIMIT 1
|
||||
) po ON true
|
||||
GROUP BY v.name
|
||||
HAVING COUNT(DISTINCT t.id) >= 10
|
||||
ORDER BY products_with_prices DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getHighValueEquivalences(limit = 30) {
|
||||
// High-value = pairs where competitor price is substantially different from average
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
fx.part_number AS fx_part,
|
||||
comp.part_number AS comp_part,
|
||||
vcomp.name AS comp_vendor,
|
||||
comp.form_factor,
|
||||
comp.speed_gbps,
|
||||
comp.reach_meters,
|
||||
po.price,
|
||||
po.currency,
|
||||
e.confidence,
|
||||
e.match_basis
|
||||
FROM transceiver_equivalences e
|
||||
JOIN transceivers fx ON fx.id = e.flexoptix_id
|
||||
JOIN transceivers comp ON comp.id = e.competitor_id
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id
|
||||
JOIN LATERAL (
|
||||
SELECT price, currency FROM price_observations
|
||||
WHERE transceiver_id = comp.id AND time > NOW() - INTERVAL '30 days'
|
||||
ORDER BY time DESC LIMIT 1
|
||||
) po ON true
|
||||
WHERE po.price > 50
|
||||
ORDER BY po.price DESC
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function get400GPricingData() {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
t.part_number,
|
||||
v.name AS vendor,
|
||||
t.form_factor,
|
||||
t.speed_gbps,
|
||||
t.reach_meters,
|
||||
t.wavelengths,
|
||||
po.price,
|
||||
po.currency
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND v.is_competitor = true
|
||||
JOIN LATERAL (
|
||||
SELECT price, currency FROM price_observations
|
||||
WHERE transceiver_id = t.id
|
||||
ORDER BY time DESC LIMIT 1
|
||||
) po ON true
|
||||
WHERE t.speed_gbps >= 200
|
||||
AND po.price IS NOT NULL
|
||||
ORDER BY t.speed_gbps, t.form_factor, po.price
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function getCoverageStats() {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM transceivers) AS total_transceivers,
|
||||
(SELECT COUNT(*) FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND UPPER(v.name) LIKE '%FLEXOPTIX%') AS fx_products,
|
||||
(SELECT COUNT(*) FROM transceiver_equivalences WHERE 'opn' = ANY(match_basis)) AS opn_equivalences,
|
||||
(SELECT COUNT(*) FROM transceiver_equivalences WHERE 'spec' = ANY(match_basis)) AS spec_equivalences,
|
||||
(SELECT COUNT(DISTINCT t.id) FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id AND v.is_competitor = true
|
||||
JOIN LATERAL (SELECT 1 FROM price_observations po
|
||||
WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days' LIMIT 1) fresh ON true
|
||||
) AS fresh_prices_7d
|
||||
`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// ── Training pair generators ──────────────────────────────────────────────────
|
||||
|
||||
function generatePriceSummaryPairs(priceSummary: any[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
|
||||
// Group by speed tier
|
||||
const bySpeed: Record<number, any[]> = {};
|
||||
for (const r of priceSummary) {
|
||||
if (!bySpeed[r.speed_gbps]) bySpeed[r.speed_gbps] = [];
|
||||
bySpeed[r.speed_gbps].push(r);
|
||||
}
|
||||
|
||||
for (const [speed, vendors] of Object.entries(bySpeed)) {
|
||||
if (vendors.length < 2) continue;
|
||||
const formFactors = [...new Set(vendors.map((v: any) => v.form_factor))].join(", ");
|
||||
const vendorList = vendors.slice(0, 8).map((v: any) =>
|
||||
` - ${v.vendor} (${v.form_factor}): min $${v.min_price}, avg $${v.avg_price}, max $${v.max_price} USD across ${v.products} products`
|
||||
).join("\n");
|
||||
|
||||
rows.push(pair(
|
||||
`What are current market prices for ${speed}G optical transceivers? Give me a competitive pricing overview.`,
|
||||
`Based on TIP's live pricing database (data current as of ${new Date().toISOString().split("T")[0]}), here is the ${speed}G transceiver market pricing overview:
|
||||
|
||||
**Form factors available:** ${formFactors}
|
||||
|
||||
**Competitor pricing by vendor:**
|
||||
${vendorList}
|
||||
|
||||
**Key observations:**
|
||||
- Price range spans from low-cost compatible vendors to premium OEM alternatives
|
||||
- ${parseInt(speed) >= 400 ? "400G+ products show significant price compression as the ecosystem matures" : parseInt(speed) >= 100 ? "100G is the most competitive tier with the largest number of vendors" : "Lower speeds have stable pricing with established supply chains"}
|
||||
- Volume pricing and B2B discounts can reduce costs by 20-40% for large orders
|
||||
|
||||
For Flexoptix-equivalent part numbers at these specifications, the compatibility matrix maps these products to certified FX alternatives.`
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function generateOPNEquivalencePairs(equivalences: any[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
|
||||
// Group by FX part number
|
||||
const byFX: Record<string, any[]> = {};
|
||||
for (const e of equivalences) {
|
||||
if (!byFX[e.fx_part]) byFX[e.fx_part] = [];
|
||||
byFX[e.fx_part].push(e);
|
||||
}
|
||||
|
||||
for (const [fxPart, matches] of Object.entries(byFX)) {
|
||||
if (matches.length === 0) continue;
|
||||
const m = matches[0];
|
||||
const matchList = matches.map((match: any) =>
|
||||
` - ${match.comp_vendor} ${match.comp_part}: $${match.price} ${match.currency}`
|
||||
).join("\n");
|
||||
|
||||
rows.push(pair(
|
||||
`What competitor products are OPN-confirmed equivalents to Flexoptix ${fxPart}?`,
|
||||
`Based on the TIP manufacturer compatibility matrix, the following are OPN-confirmed (confidence: 1.0) equivalences for Flexoptix **${fxPart}** (${m.form_factor}, ${m.speed_gbps}G):
|
||||
|
||||
**Manufacturer-confirmed equivalences:**
|
||||
${matchList}
|
||||
|
||||
These matches are derived from the Flexoptix compatibility matrix which lists the original OEM part numbers that each FX product replaces. Confidence = 1.0 means this is manufacturer-confirmed, not spec-estimated.
|
||||
|
||||
${m.match_notes ? `\n**Notes:** ${m.match_notes}` : ""}
|
||||
|
||||
For procurement decisions, these prices reflect current market rates. Contact Flexoptix for volume pricing on the FX equivalent.`
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function generateSpecEquivalencePairs(equivalences: any[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
|
||||
// Group by form_factor + speed
|
||||
const groups: Record<string, any[]> = {};
|
||||
for (const e of equivalences) {
|
||||
const key = `${e.form_factor}-${e.speed_gbps}G`;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(e);
|
||||
}
|
||||
|
||||
for (const [key, matches] of Object.entries(groups)) {
|
||||
if (matches.length < 2) continue;
|
||||
const m = matches[0];
|
||||
const matchList = matches.slice(0, 6).map((match: any) =>
|
||||
` - ${match.comp_vendor} ${match.comp_part}: $${match.price} ${match.currency}`
|
||||
).join("\n");
|
||||
|
||||
rows.push(pair(
|
||||
`I'm looking for ${key} compatible transceivers. What are the spec-based equivalent options with pricing?`,
|
||||
`Based on TIP's spec-matching engine for **${key}** transceivers (confidence: 0.85, spec-matched):
|
||||
|
||||
**Available compatible products (current market prices):**
|
||||
${matchList}
|
||||
|
||||
**Matching criteria applied:**
|
||||
${m.match_notes || `Form factor: ${m.form_factor}, Speed: ${m.speed_gbps}G, Reach tier, Wavelength ±10nm`}
|
||||
|
||||
**Important notes:**
|
||||
- Spec matches have 0.85 confidence (vs 1.0 for OPN-confirmed matches)
|
||||
- Verify specific reach and wavelength requirements before ordering
|
||||
- For OPN-confirmed alternatives with the highest confidence, check if an FX part number maps to this spec
|
||||
|
||||
Flexoptix offers fully programmable transceivers that can often address multiple spec variants from a single SKU, reducing inventory complexity.`
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function generate400GPairs(products400g: any[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
if (products400g.length === 0) return rows;
|
||||
|
||||
const byFormFactor: Record<string, any[]> = {};
|
||||
for (const p of products400g) {
|
||||
if (!byFormFactor[p.form_factor]) byFormFactor[p.form_factor] = [];
|
||||
byFormFactor[p.form_factor].push(p);
|
||||
}
|
||||
|
||||
for (const [ff, products] of Object.entries(byFormFactor)) {
|
||||
if (products.length === 0) continue;
|
||||
const priceList = products.map((p: any) =>
|
||||
` - ${p.vendor} ${p.part_number} (${p.reach_meters}m${p.wavelengths ? " @ " + p.wavelengths + "nm" : ""}): $${p.price} ${p.currency}`
|
||||
).join("\n");
|
||||
const speeds = [...new Set(products.map((p: any) => p.speed_gbps))].sort().join("/");
|
||||
|
||||
rows.push(pair(
|
||||
`What is current market pricing for ${ff} ${speeds}G transceivers? I'm planning a data center upgrade.`,
|
||||
`Here is the current TIP pricing intelligence for **${ff} ${speeds}G** transceivers (data: ${new Date().toISOString().split("T")[0]}):
|
||||
|
||||
**Market pricing:**
|
||||
${priceList}
|
||||
|
||||
**Market context:**
|
||||
- ${ff === "QSFP-DD" ? "QSFP-DD 400G is the dominant 400G form factor for data center deployments, with 8x50G PAM4 electrical interface" : ff === "QSFP112" ? "QSFP112 uses 4x100G PAM4 lanes, preferred for high-density 400G where thermal budget is critical" : ff === "OSFP" ? "OSFP supports up to 800G and is preferred for AI/ML cluster spine deployments" : `${ff} is a key form factor in next-gen networking deployments`}
|
||||
- Price points vary significantly by reach: DR4/FR4 (≤2km) is lowest cost; LR4/ER4/ZR (10km+) commands premium
|
||||
- 400G pricing has compressed 30-40% over the past 18 months as manufacturing volumes increased
|
||||
|
||||
For Flexoptix QSFP-DD 400G equivalents, the D.xxx product family covers SR4, DR4, FR4, and LR4 variants with full compatibility guarantees.`
|
||||
));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function generateVendorOverviewPair(vendorData: any[]): Row {
|
||||
const vendorList = vendorData.slice(0, 12).map((v: any) =>
|
||||
` - **${v.vendor}**: ${v.products_with_prices} products, avg $${v.avg_price_usd} (range: $${v.min_price_usd}–$${v.max_price_usd})`
|
||||
).join("\n");
|
||||
|
||||
return pair(
|
||||
`Which compatible optical transceiver vendors does TIP track, and what are their pricing profiles?`,
|
||||
`TIP tracks real-time pricing across all major compatible transceiver vendors. Here is the current competitive landscape (data: ${new Date().toISOString().split("T")[0]}):
|
||||
|
||||
**Vendors with live pricing data:**
|
||||
${vendorList}
|
||||
|
||||
**Vendor tier summary:**
|
||||
- **Tier 1 (Broad catalog, competitive pricing):** fs.com, 10Gtek, Optcore, Fibertrade — large assortment, aggressive retail pricing, good for 10G/25G/100G commodity items
|
||||
- **Tier 2 (Specialized/niche):** IntelliPhy, ATGBICS, QSFPTEK — focused on specific form factors or regions
|
||||
- **B2B Quote-Only:** Eoptolink, Ascent Optics, GAO Tek — no public pricing, volume/contract based
|
||||
- **OEM/Premium:** Cisco, Juniper, Arista — original vendor pricing, highest cost, lock-in dependent
|
||||
|
||||
TIP updates prices continuously via automated scrapers. The compatibility matrix maps these competitor products to Flexoptix FX equivalents with confidence scores.`
|
||||
);
|
||||
}
|
||||
|
||||
function generateCoverageStatsPair(stats: any): Row {
|
||||
return pair(
|
||||
`What is the current scope and coverage of the Transceiver Intelligence Platform database?`,
|
||||
`The TIP database as of ${new Date().toISOString().split("T")[0]} contains:
|
||||
|
||||
**Catalog coverage:**
|
||||
- **${stats.total_transceivers.toLocaleString()} transceivers** total (all vendors)
|
||||
- **${stats.fx_products} Flexoptix products** — the reference catalog
|
||||
- Multiple competitor vendors tracked continuously
|
||||
|
||||
**Equivalence matching:**
|
||||
- **${parseInt(stats.opn_equivalences).toLocaleString()} OPN-confirmed equivalences** (confidence: 1.0) — manufacturer-verified
|
||||
- **${parseInt(stats.spec_equivalences)} spec-based equivalences** (confidence: 0.85) — algorithmically matched by form factor + speed + reach + wavelength
|
||||
- Coverage: ~88% of Flexoptix products have at least one confirmed competitor equivalent
|
||||
|
||||
**Pricing intelligence:**
|
||||
- **${parseInt(stats.fresh_prices_7d).toLocaleString()} competitor products with fresh pricing** (updated within 7 days)
|
||||
- Automated scrapers cover: fs.com, sfpcables.com (10Gtek), Optcore, Fibertrade, ATGBICS, IntelliPhy, and more
|
||||
- Prices updated continuously via pg-boss job scheduler (24/7 operation)
|
||||
|
||||
**Data quality:**
|
||||
- OPN matches use the official Flexoptix compatibility matrix — same source used by network engineers
|
||||
- Spec matches use: form_factor + speed_gbps + reach tier (SR/IR/LR/ER/ZR) + wavelength ±10nm
|
||||
- Safety cap: FX products matching >30 competitors are excluded (too generic, unreliable)`,
|
||||
"db-coverage"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log("Generating TIP_LLM pricing training data from DB...\n");
|
||||
|
||||
const [priceSummary, opnEquivalences, specEquivalences, vendorData, products400g, stats] = await Promise.all([
|
||||
getPriceSummaryByTier(),
|
||||
getOPNEquivalenceExamples(60),
|
||||
getSpecEquivalenceExamples(40),
|
||||
getVendorPricingOverview(),
|
||||
get400GPricingData(),
|
||||
getCoverageStats(),
|
||||
]);
|
||||
|
||||
console.log(`Price summary rows: ${priceSummary.length}`);
|
||||
console.log(`OPN equivalence examples: ${opnEquivalences.length}`);
|
||||
console.log(`Spec equivalence examples: ${specEquivalences.length}`);
|
||||
console.log(`Vendor overview rows: ${vendorData.length}`);
|
||||
console.log(`400G+ products: ${products400g.length}`);
|
||||
|
||||
const allPairs: Row[] = [
|
||||
...generatePriceSummaryPairs(priceSummary),
|
||||
...generateOPNEquivalencePairs(opnEquivalences),
|
||||
...generateSpecEquivalencePairs(specEquivalences),
|
||||
...generate400GPairs(products400g),
|
||||
generateVendorOverviewPair(vendorData),
|
||||
generateCoverageStatsPair(stats),
|
||||
];
|
||||
|
||||
// Deduplicate by id
|
||||
const seen = new Set<string>();
|
||||
const unique = allPairs.filter((r) => {
|
||||
if (seen.has(r.id)) return false;
|
||||
seen.add(r.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`\nGenerated ${unique.length} unique training pairs`);
|
||||
|
||||
const outDir = join(process.cwd(), "training-data");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outPath = join(outDir, "tip-llm-pricing-v1.jsonl");
|
||||
writeFileSync(outPath, unique.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
||||
|
||||
console.log(`\nOutput: ${outPath}`);
|
||||
console.log(`Training pairs: ${unique.length}`);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
pool.end();
|
||||
process.exit(1);
|
||||
});
|
||||
@ -34,7 +34,7 @@ def query(sql):
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db",
|
||||
"-t", "-A", "-F", "|", "-c", sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
return [line.split("|") for line in r.stdout.strip().split("\n") if line.strip()]
|
||||
|
||||
@ -43,7 +43,7 @@ def run_sql(sql):
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db",
|
||||
"-c", sql],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
|
||||
log(f"{time.strftime('%Y-%m-%d %H:%M:%S')}: MEGA ENRICHMENT START")
|
||||
@ -515,7 +515,7 @@ log("Applying SQL...")
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-f", SQL_OUT],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
|
||||
errors = [l for l in r.stderr.split("\n") if "ERROR" in l]
|
||||
@ -547,7 +547,7 @@ for q in [
|
||||
r = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "5433", "-U", "tip", "-d", "transceiver_db", "-t", "-A", "-c", q],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PGPASSWORD": "***REDACTED***"}
|
||||
env={**os.environ}
|
||||
)
|
||||
log(r.stdout.strip())
|
||||
|
||||
|
||||
@ -5,12 +5,17 @@ import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
const dbPassword = process.env.POSTGRES_PASSWORD || process.env.TIP_DB_PASS;
|
||||
if (!dbPassword) {
|
||||
throw new Error("POSTGRES_PASSWORD (or TIP_DB_PASS) must be set — refusing a hardcoded default.");
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST || "localhost",
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5432"),
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
password: process.env.POSTGRES_PASSWORD || "tip_dev_2026",
|
||||
password: dbPassword,
|
||||
});
|
||||
|
||||
async function migrate(): Promise<void> {
|
||||
|
||||
@ -22,7 +22,7 @@ PI_NAME="${PI_NAME:-pi-scraper}" # override with PI_NAME=pi2 bash setup.s
|
||||
DB_HOST="${DB_HOST:-10.10.0.1}" # Erik WireGuard IP
|
||||
DB_PORT="${DB_PORT:-5433}"
|
||||
DB_USER="${DB_USER:-tip}"
|
||||
DB_PASS="${DB_PASS:-***REDACTED***}"
|
||||
DB_PASS="${DB_PASS:?set DB_PASS}"
|
||||
DB_NAME="${DB_NAME:-transceiver_db}"
|
||||
GITEA="http://192.168.178.196:3000/rene/transceiver-db.git"
|
||||
INSTALL_DIR="/opt/tip-scraper"
|
||||
|
||||
@ -40,13 +40,14 @@ if [[ ! -f "$REPO_DIR/packages/scraper/dist/scrapers/atgbics.js" ]]; then
|
||||
cd "$REPO_DIR/packages/scraper" && npm run build
|
||||
fi
|
||||
|
||||
# Load current DB password (not hardcoded — post-rotation secret lives in ~/.tip/.env)
|
||||
[ -f "$HOME/.tip/.env" ] && set -a && source "$HOME/.tip/.env" && set +a
|
||||
# Run scraper
|
||||
echo "Running ATGBICS scraper..."
|
||||
cd "$REPO_DIR"
|
||||
POSTGRES_HOST=127.0.0.1 \
|
||||
POSTGRES_PORT="${TUNNEL_PORT}" \
|
||||
POSTGRES_USER=tip \
|
||||
POSTGRES_PASSWORD=***REDACTED*** \
|
||||
POSTGRES_DB=transceiver_db \
|
||||
node packages/scraper/dist/scrapers/atgbics.js 2>&1 | tee "$LOG"
|
||||
|
||||
|
||||
@ -180,9 +180,10 @@ def main():
|
||||
parser.add_argument("--db-url", default=None, help="PostgreSQL connection URL (overrides env)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine DB URL
|
||||
db_url = args.db_url or os.environ.get("LLM_GATEWAY_DB_URL") or \
|
||||
"postgresql://llm:llm_secure_2026@217.154.82.179:5432/llm_gateway"
|
||||
# Determine DB URL (never hardcode credentials — require env or --db-url)
|
||||
db_url = args.db_url or os.environ.get("LLM_GATEWAY_DB_URL")
|
||||
if not db_url:
|
||||
sys.exit("ERROR: set LLM_GATEWAY_DB_URL env var or pass --db-url")
|
||||
|
||||
# Find training data directory
|
||||
script_dir = Path(__file__).parent
|
||||
|
||||
@ -30,7 +30,7 @@ systemctl enable postgresql
|
||||
|
||||
# Create DB and user
|
||||
sudo -u postgres psql <<SQL
|
||||
CREATE USER tip WITH PASSWORD '${POSTGRES_PASSWORD:-***REDACTED***}';
|
||||
CREATE USER tip WITH PASSWORD '${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}';
|
||||
CREATE DATABASE transceiver_db OWNER tip;
|
||||
GRANT ALL PRIVILEGES ON DATABASE transceiver_db TO tip;
|
||||
\c transceiver_db
|
||||
|
||||
@ -34,6 +34,7 @@ const files: Record<Lane, string[]> = {
|
||||
"market-business-analysis-part5.jsonl",
|
||||
"market-business-analysis-part6.jsonl",
|
||||
"training-data/tip-llm-capabilities-v1.jsonl",
|
||||
"training-data/tip-llm-pricing-v1.jsonl",
|
||||
],
|
||||
blog_llm: [
|
||||
"master-training-dataset.jsonl",
|
||||
|
||||
@ -20,7 +20,7 @@ const pool = new Pool({
|
||||
port: parseInt(process.env.POSTGRES_PORT || "5433"),
|
||||
database: process.env.POSTGRES_DB || "transceiver_db",
|
||||
user: process.env.POSTGRES_USER || "tip",
|
||||
password: process.env.POSTGRES_PASSWORD || "***REDACTED***",
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
max: 3,
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
-- 010: Add image_url, product_page_url, datasheet_url columns and populate vendor URLs
|
||||
-- Run on Erik: PGPASSWORD='***REDACTED***' psql -h localhost -p 5433 -U tip -d transceiver_db -f sql/010-vendor-urls.sql
|
||||
-- Run on Erik: PGPASSWORD="$PGPASSWORD" psql -h localhost -p 5433 -U tip -d transceiver_db -f sql/010-vendor-urls.sql
|
||||
|
||||
-- Add columns (idempotent)
|
||||
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS image_url TEXT;
|
||||
|
||||
@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ieee_wavelength_lookup (
|
||||
fiber_type TEXT NOT NULL, -- 'SMF', 'MMF', 'DAC', 'AOC'
|
||||
reach_min_m INTEGER NOT NULL,
|
||||
reach_max_m INTEGER NOT NULL,
|
||||
wavelength_tx_nm INTEGER NOT NULL,
|
||||
wavelength_tx_nm INTEGER, -- NULL for Copper/RJ45 (no optical wavelength)
|
||||
wavelength_rx_nm INTEGER, -- NULL = gleich wie TX (kein BiDi)
|
||||
connector_type TEXT NOT NULL,
|
||||
ieee_standard TEXT, -- z.B. '802.3ae', 'SFF-8431'
|
||||
|
||||
172
sql/113-infer-connector-type.sql
Normal file
172
sql/113-infer-connector-type.sql
Normal file
@ -0,0 +1,172 @@
|
||||
-- Migration 113: Connector Type Inference
|
||||
-- Füllt fehlende connector_type aus zwei Quellen:
|
||||
-- 1. IEEE/MSA Lookup-Tabelle (exakt, nach reach range)
|
||||
-- 2. Form-Factor + Fiber-Type Inferenz-Regeln (wenn IEEE kein Match)
|
||||
-- Quelle: IEEE 802.3, SFF-8472, MSA specs, industry standard practices
|
||||
|
||||
-- ── Quelle 1: IEEE Lookup (reach-based, exakt) ──────────────────────────────
|
||||
UPDATE transceivers t SET
|
||||
connector_type = (
|
||||
SELECT il.connector_type
|
||||
FROM ieee_wavelength_lookup il
|
||||
WHERE UPPER(il.form_factor) = UPPER(t.form_factor)
|
||||
AND il.speed_gbps = ROUND(t.speed_gbps::NUMERIC, 2)
|
||||
AND UPPER(il.fiber_type) = UPPER(t.fiber_type)
|
||||
AND il.reach_min_m <= t.reach_meters
|
||||
AND il.reach_max_m >= t.reach_meters
|
||||
ORDER BY il.reach_max_m ASC -- Prefer tightest range match
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE t.connector_type IS NULL
|
||||
AND t.form_factor IS NOT NULL
|
||||
AND t.speed_gbps IS NOT NULL
|
||||
AND t.fiber_type IS NOT NULL
|
||||
AND t.reach_meters IS NOT NULL
|
||||
AND t.reach_meters > 0;
|
||||
|
||||
DO $$
|
||||
DECLARE v INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v FROM transceivers WHERE connector_type IS NOT NULL
|
||||
AND connector_type = (
|
||||
SELECT il.connector_type FROM ieee_wavelength_lookup il
|
||||
WHERE UPPER(il.form_factor) = UPPER(transceivers.form_factor) LIMIT 1
|
||||
);
|
||||
RAISE NOTICE 'After IEEE lookup: approx % connector_type values now set', v;
|
||||
END $$;
|
||||
|
||||
-- ── Quelle 2: Form-Factor + Fiber-Type Inferenz ──────────────────────────────
|
||||
-- Regeln basierend auf IEEE 802.3 und MSA Spezifikationen:
|
||||
-- SFP/SFP+/SFP28/XFP + SMF/MMF → LC (dual fiber, standard single-mode)
|
||||
-- QSFP+ + MMF → MPO-12 (SR4 = 4x parallel fiber)
|
||||
-- QSFP+ + SMF, reach ≤ 2km → MPO-12 (PSM4 = parallel SMF)
|
||||
-- QSFP+ + SMF, reach > 2km → LC (LR4 = CWDM4 on 2 fibers)
|
||||
-- QSFP28 + MMF → MPO-12 (SR4)
|
||||
-- QSFP28 + SMF, reach ≤ 2km → MPO-12 (DR/PSM4)
|
||||
-- QSFP28 + SMF, reach > 2km → LC (LR4/CWDM4)
|
||||
-- QSFP56 + MMF → MPO-16 (SR4 on 200G)
|
||||
-- QSFP56 + SMF → LC (FR4/LR4)
|
||||
-- QSFP-DD/QSFP-DD800 + MMF → MPO-16 (SR8)
|
||||
-- QSFP-DD/QSFP-DD800 + SMF, reach ≤ 2km → MPO-12 (DR4/DR8)
|
||||
-- QSFP-DD/QSFP-DD800 + SMF, reach > 2km → LC (FR4/LR4)
|
||||
-- OSFP + MMF → MPO-16 (SR8)
|
||||
-- OSFP + SMF, reach ≤ 2km → MPO-12 (DR8)
|
||||
-- OSFP + SMF, reach > 2km → LC (FR4/LR4)
|
||||
-- any + Copper → RJ45
|
||||
-- any + DAC → NULL (native electrical, no fiber connector)
|
||||
-- any + AOC → LC (optical fan-out)
|
||||
|
||||
UPDATE transceivers SET
|
||||
connector_type = CASE
|
||||
-- Copper BASE-T
|
||||
WHEN UPPER(fiber_type) IN ('COPPER', 'COPPER/RJ45') THEN 'RJ45'
|
||||
|
||||
-- DAC = Direct Attach Copper, no optical connector
|
||||
WHEN UPPER(fiber_type) = 'DAC' THEN 'DAC'
|
||||
|
||||
-- AOC = Active Optical Cable, LC fan-out connectors
|
||||
WHEN UPPER(fiber_type) = 'AOC' THEN 'LC'
|
||||
|
||||
-- Single-lane form factors: always LC for optical
|
||||
WHEN UPPER(form_factor) IN ('SFP', 'SFP+', 'SFP28', 'XFP', 'SFP56')
|
||||
AND UPPER(fiber_type) IN ('SMF', 'MMF') THEN 'LC'
|
||||
|
||||
-- QSFP+ (40G)
|
||||
WHEN UPPER(form_factor) = 'QSFP+'
|
||||
AND UPPER(fiber_type) = 'MMF' THEN 'MPO-12'
|
||||
WHEN UPPER(form_factor) = 'QSFP+'
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND reach_meters IS NOT NULL AND reach_meters <= 2000 THEN 'MPO-12'
|
||||
WHEN UPPER(form_factor) = 'QSFP+'
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND (reach_meters IS NULL OR reach_meters > 2000) THEN 'LC'
|
||||
|
||||
-- QSFP28 (100G)
|
||||
WHEN UPPER(form_factor) = 'QSFP28'
|
||||
AND UPPER(fiber_type) = 'MMF' THEN 'MPO-12'
|
||||
WHEN UPPER(form_factor) = 'QSFP28'
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND reach_meters IS NOT NULL AND reach_meters <= 2000 THEN 'MPO-12'
|
||||
WHEN UPPER(form_factor) = 'QSFP28'
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND (reach_meters IS NULL OR reach_meters > 2000) THEN 'LC'
|
||||
|
||||
-- QSFP56 (200G)
|
||||
WHEN UPPER(form_factor) = 'QSFP56'
|
||||
AND UPPER(fiber_type) = 'MMF' THEN 'MPO-16'
|
||||
WHEN UPPER(form_factor) = 'QSFP56'
|
||||
AND UPPER(fiber_type) = 'SMF' THEN 'LC'
|
||||
|
||||
-- QSFP-DD / QSFP-DD800 (400G/800G)
|
||||
WHEN UPPER(form_factor) IN ('QSFP-DD', 'QSFP-DD800')
|
||||
AND UPPER(fiber_type) = 'MMF' THEN 'MPO-16'
|
||||
WHEN UPPER(form_factor) IN ('QSFP-DD', 'QSFP-DD800')
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND reach_meters IS NOT NULL AND reach_meters <= 2000 THEN 'MPO-12'
|
||||
WHEN UPPER(form_factor) IN ('QSFP-DD', 'QSFP-DD800')
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND (reach_meters IS NULL OR reach_meters > 2000) THEN 'LC'
|
||||
|
||||
-- OSFP (800G+)
|
||||
WHEN UPPER(form_factor) = 'OSFP'
|
||||
AND UPPER(fiber_type) = 'MMF' THEN 'MPO-16'
|
||||
WHEN UPPER(form_factor) = 'OSFP'
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND reach_meters IS NOT NULL AND reach_meters <= 2000 THEN 'MPO-12'
|
||||
WHEN UPPER(form_factor) = 'OSFP'
|
||||
AND UPPER(fiber_type) = 'SMF'
|
||||
AND (reach_meters IS NULL OR reach_meters > 2000) THEN 'LC'
|
||||
|
||||
-- CFP/CFP2/CFP4 (100G coherent)
|
||||
WHEN UPPER(form_factor) IN ('CFP', 'CFP2', 'CFP4') THEN 'LC'
|
||||
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE connector_type IS NULL
|
||||
AND form_factor IS NOT NULL
|
||||
AND fiber_type IS NOT NULL;
|
||||
|
||||
-- ── Completeness neu berechnen ───────────────────────────────────────────────
|
||||
UPDATE transceivers SET
|
||||
data_completeness = calc_data_completeness(
|
||||
form_factor, speed_gbps, fiber_type,
|
||||
reach_meters, wavelength_tx_nm, connector_type
|
||||
),
|
||||
enrichment_needed = (
|
||||
form_factor IS NULL OR speed_gbps IS NULL OR
|
||||
fiber_type IS NULL OR reach_meters IS NULL OR
|
||||
wavelength_tx_nm IS NULL OR connector_type IS NULL
|
||||
),
|
||||
enrichment_fields = ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN form_factor IS NULL THEN 'form_factor' END,
|
||||
CASE WHEN speed_gbps IS NULL THEN 'speed_gbps' END,
|
||||
CASE WHEN fiber_type IS NULL THEN 'fiber_type' END,
|
||||
CASE WHEN reach_meters IS NULL OR reach_meters = 0 THEN 'reach_meters' END,
|
||||
CASE WHEN wavelength_tx_nm IS NULL THEN 'wavelength_tx_nm' END,
|
||||
CASE WHEN connector_type IS NULL THEN 'connector_type' END
|
||||
], NULL);
|
||||
|
||||
-- ── Statistik ────────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
total_cnt INTEGER;
|
||||
complete_cnt INTEGER;
|
||||
missing_conn INTEGER;
|
||||
missing_wl INTEGER;
|
||||
fx_complete INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_cnt FROM transceivers;
|
||||
SELECT COUNT(*) INTO complete_cnt FROM transceivers WHERE enrichment_needed = FALSE;
|
||||
SELECT COUNT(*) INTO missing_conn FROM transceivers WHERE connector_type IS NULL;
|
||||
SELECT COUNT(*) INTO missing_wl FROM transceivers WHERE wavelength_tx_nm IS NULL;
|
||||
SELECT COUNT(*) INTO fx_complete
|
||||
FROM transceivers t JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%' AND enrichment_needed = FALSE;
|
||||
|
||||
RAISE NOTICE 'Migration 113 complete:';
|
||||
RAISE NOTICE ' Total transceivers: %', total_cnt;
|
||||
RAISE NOTICE ' Fully complete: %', complete_cnt;
|
||||
RAISE NOTICE ' Still missing connector: %', missing_conn;
|
||||
RAISE NOTICE ' Still missing wavelength: %', missing_wl;
|
||||
RAISE NOTICE ' Flexoptix fully complete: %', fx_complete;
|
||||
END $$;
|
||||
214
sql/114-extend-ieee-lookup-and-clear-pending.sql
Normal file
214
sql/114-extend-ieee-lookup-and-clear-pending.sql
Normal file
@ -0,0 +1,214 @@
|
||||
-- Migration 114: Extend IEEE/MSA Lookup (400G/800G/1.6T) + Clear Pending Queue
|
||||
-- Part A: Add missing 400G/800G/1.6T standards to ieee_wavelength_lookup
|
||||
-- Part B: Wavelength fallback for products with known form/fiber/reach
|
||||
-- Part C: Reject remaining pending records (replaced by deterministic matcher)
|
||||
|
||||
-- ── Part A: IEEE/MSA Lookup Erweiterung ─────────────────────────────────────
|
||||
-- Sources: IEEE 802.3cd (200G), 802.3bs (400G), 802.3df (800G), 802.3dj (1.6T draft)
|
||||
-- 400G-FR4 MSA, 400G-LR4-10 MSA, OSFP MSA, OpenZR+ MSA
|
||||
|
||||
INSERT INTO ieee_wavelength_lookup
|
||||
(form_factor, speed_gbps, fiber_type, reach_min_m, reach_max_m, wavelength_tx_nm, wavelength_rx_nm, connector_type, ieee_standard, notes)
|
||||
VALUES
|
||||
-- ── QSFP+ 40G additional reaches ─────────────────────────────────────────────
|
||||
('QSFP+', 40, 'SMF', 0, 150, 1310, NULL, 'MPO-12', '802.3ba', '40GBASE-PSM4 short'),
|
||||
('QSFP+', 40, 'SMF', 0, 1400, 1310, NULL, 'LC', '802.3ba', '40GBASE-LR4 1.4km'),
|
||||
-- ── QSFP28 100G additional ───────────────────────────────────────────────────
|
||||
('QSFP28', 100, 'SMF', 0, 80000, 1550, NULL, 'LC', '802.3ba', '100GBASE-ZR4'),
|
||||
('QSFP28', 100, 'SMF', 0, 120000, 1550, NULL, 'LC', 'OpenZR+', '100G OpenZR+ 120km'),
|
||||
-- ── QSFP56 200G ──────────────────────────────────────────────────────────────
|
||||
('QSFP56', 200, 'DAC', 0, 5, NULL, NULL, 'QSFP56','802.3cd', '200G DAC'),
|
||||
('QSFP56', 200, 'AOC', 0, 100, 850, NULL, 'MPO-16','802.3cd', '200G AOC SR4'),
|
||||
-- ── QSFP-DD 400G additional ──────────────────────────────────────────────────
|
||||
('QSFP-DD', 400, 'MMF', 0, 100, 850, NULL, 'MPO-16','802.3bs', '400GBASE-SR8'),
|
||||
('QSFP-DD', 400, 'SMF', 0, 500, 1310, NULL, 'MPO-12','802.3bs', '400GBASE-DR4'),
|
||||
('QSFP-DD', 400, 'SMF', 0, 2000, 1310, NULL, 'LC', '802.3bs', '400GBASE-FR4'),
|
||||
('QSFP-DD', 400, 'SMF', 0, 10000, 1310, NULL, 'LC', '802.3bs', '400GBASE-LR4'),
|
||||
('QSFP-DD', 400, 'SMF', 0, 80000, 1550, NULL, 'LC', '400ZR-MSA','400G ZR 80km'),
|
||||
('QSFP-DD', 400, 'SMF', 0, 120000, 1550, NULL, 'LC', 'OpenZR+', '400G OpenZR+ 120km'),
|
||||
('QSFP-DD', 400, 'AOC', 0, 100, 850, NULL, 'MPO-16','802.3bs', '400G AOC SR8'),
|
||||
-- ── QSFP-DD800 800G ──────────────────────────────────────────────────────────
|
||||
('QSFP-DD800', 800, 'MMF', 0, 100, 850, NULL, 'MPO-16','802.3df', '800GBASE-SR8'),
|
||||
('QSFP-DD800', 800, 'SMF', 0, 500, 1310, NULL, 'MPO-12','802.3df', '800GBASE-DR8'),
|
||||
('QSFP-DD800', 800, 'SMF', 0, 2000, 1310, NULL, 'LC', '802.3df', '800GBASE-FR4 2x400G'),
|
||||
('QSFP-DD800', 800, 'SMF', 0,10000, 1310, NULL, 'LC', '802.3df', '800GBASE-LR4'),
|
||||
('QSFP-DD800', 800, 'SMF', 0,80000, 1550, NULL, 'LC', 'OpenZR+', '800G OpenZR+ 80km'),
|
||||
('QSFP-DD800', 800, 'DAC', 0, 5, NULL, NULL, 'QSFP-DD800','802.3df','800G DAC'),
|
||||
-- ── OSFP 400G ────────────────────────────────────────────────────────────────
|
||||
('OSFP', 400, 'MMF', 0, 100, 850, NULL, 'MPO-16', 'OSFP-MSA', '400GBASE-SR8 OSFP'),
|
||||
('OSFP', 400, 'SMF', 0, 500, 1310, NULL, 'MPO-12', 'OSFP-MSA', '400GBASE-DR4 OSFP'),
|
||||
('OSFP', 400, 'SMF', 0, 2000, 1310, NULL, 'LC', 'OSFP-MSA', '400GBASE-FR4 OSFP'),
|
||||
('OSFP', 400, 'SMF', 0, 10000, 1310, NULL, 'LC', 'OSFP-MSA', '400GBASE-LR4 OSFP'),
|
||||
('OSFP', 400, 'SMF', 0, 80000, 1550, NULL, 'LC', 'OpenZR+', '400G ZR OSFP 80km'),
|
||||
('OSFP', 400, 'SMF', 0, 120000, 1550, NULL, 'LC', 'OpenZR+', '400G OpenZR+ OSFP 120km'),
|
||||
-- ── OSFP 800G ────────────────────────────────────────────────────────────────
|
||||
('OSFP', 800, 'MMF', 0, 30, 850, NULL, 'MPO-16', '802.3df', '800GBASE-SR8 30m'),
|
||||
('OSFP', 800, 'MMF', 0, 100, 850, NULL, 'MPO-16', '802.3df', '800GBASE-SR8'),
|
||||
('OSFP', 800, 'SMF', 0, 500, 1310, NULL, 'MPO-12', '802.3df', '800GBASE-DR8 OSFP'),
|
||||
('OSFP', 800, 'SMF', 0, 2000, 1310, NULL, 'LC', '802.3df', '800GBASE-FR4 OSFP'),
|
||||
('OSFP', 800, 'SMF', 0, 10000, 1310, NULL, 'LC', '802.3df', '800GBASE-LR4 OSFP'),
|
||||
('OSFP', 800, 'SMF', 0, 80000, 1550, NULL, 'LC', 'OpenZR+', '800G ZR OSFP 80km'),
|
||||
-- ── OSFP 1.6T (IEEE 802.3dj draft) ──────────────────────────────────────────
|
||||
('OSFP', 1600, 'SMF', 0, 500, 1310, NULL, 'MPO-16', '802.3dj', '1.6TBASE-DR16 OSFP'),
|
||||
('OSFP', 1600, 'SMF', 0, 2000, 1310, NULL, 'LC', '802.3dj', '1.6TBASE-FR4 OSFP'),
|
||||
('OSFP', 1600, 'SMF', 0, 10000, 1310, NULL, 'LC', '802.3dj', '1.6TBASE-LR4 OSFP'),
|
||||
('OSFP112', 800, 'SMF', 0, 10000, 1310, NULL, 'LC', '802.3df', '800GBASE-LR4 OSFP112'),
|
||||
('OSFP112', 800, 'SMF', 0, 80000, 1550, NULL, 'LC', 'OpenZR+', '800G ZR OSFP112 80km'),
|
||||
('OSFP112', 800, 'SMF', 0, 120000,1550, NULL, 'LC', 'OpenZR+', '800G OpenZR+ OSFP112 120km'),
|
||||
-- ── CFP2 100G coherent ───────────────────────────────────────────────────────
|
||||
('CFP2', 100, 'SMF', 0, 10000, 1310, NULL, 'LC', 'OIF-100G', '100GBASE-LR4 CFP2'),
|
||||
('CFP2', 100, 'SMF', 0, 80000, 1550, NULL, 'LC', 'OIF-100G', '100G ZR CFP2 80km'),
|
||||
('CFP2', 100, 'SMF', 0, 120000, 1550, NULL, 'LC', 'OpenZR+', '100G OpenZR+ CFP2'),
|
||||
-- ── SFP+ / SFP 1G non-standard reaches ───────────────────────────────────────
|
||||
('SFP', 1, 'SMF', 0, 20000, 1310, NULL, 'LC', '802.3z', '1000BASE-LH 20km'),
|
||||
('SFP', 1, 'SMF', 0, 60000, 1310, NULL, 'LC', '802.3z', '1000BASE-LH 60km'),
|
||||
('SFP', 1, 'SMF', 0, 80000, 1550, NULL, 'LC', '802.3z', '1000BASE-ZX 80km'),
|
||||
('SFP', 1, 'SMF', 0,100000, 1550, NULL, 'LC', '802.3z', '1000BASE-ZX 100km'),
|
||||
-- ── SFP+ 10G non-standard reaches ────────────────────────────────────────────
|
||||
('SFP+', 10, 'SMF', 0, 20000, 1310, NULL, 'LC', '802.3ae', '10GBASE-LR 20km variant'),
|
||||
('SFP+', 10, 'SMF', 0, 60000, 1550, NULL, 'LC', '802.3ae', '10GBASE-ZR 60km'),
|
||||
('SFP+', 10, 'SMF', 0, 80000, 1550, NULL, 'LC', '802.3ae', '10GBASE-ZR 80km'),
|
||||
('SFP+', 10, 'SMF', 0,100000, 1550, NULL, 'LC', '802.3ae', '10GBASE-ZR 100km'),
|
||||
-- ── XFP 10G ──────────────────────────────────────────────────────────────────
|
||||
('XFP', 10, 'MMF', 0, 300, 850, NULL, 'LC', '802.3ae', '10GBASE-SR XFP'),
|
||||
('XFP', 10, 'SMF', 0, 10000, 1310, NULL, 'LC', '802.3ae', '10GBASE-LR XFP'),
|
||||
('XFP', 10, 'SMF', 0, 40000, 1310, NULL, 'LC', '802.3ae', '10GBASE-ER XFP'),
|
||||
('XFP', 10, 'SMF', 0, 80000, 1550, NULL, 'LC', '802.3ae', '10GBASE-ZR XFP')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Re-run IEEE lookup for wavelength after new entries ──────────────────────
|
||||
UPDATE transceivers t SET
|
||||
wavelength_tx_nm = (
|
||||
SELECT il.wavelength_tx_nm
|
||||
FROM ieee_wavelength_lookup il
|
||||
WHERE UPPER(il.form_factor) = UPPER(t.form_factor)
|
||||
AND il.speed_gbps = ROUND(t.speed_gbps::NUMERIC, 2)
|
||||
AND UPPER(il.fiber_type) = UPPER(t.fiber_type)
|
||||
AND il.reach_min_m <= t.reach_meters
|
||||
AND il.reach_max_m >= t.reach_meters
|
||||
AND il.wavelength_tx_nm IS NOT NULL
|
||||
ORDER BY il.reach_max_m ASC
|
||||
LIMIT 1
|
||||
),
|
||||
wavelength_rx_nm = COALESCE(
|
||||
wavelength_rx_nm,
|
||||
(
|
||||
SELECT il.wavelength_rx_nm
|
||||
FROM ieee_wavelength_lookup il
|
||||
WHERE UPPER(il.form_factor) = UPPER(t.form_factor)
|
||||
AND il.speed_gbps = ROUND(t.speed_gbps::NUMERIC, 2)
|
||||
AND UPPER(il.fiber_type) = UPPER(t.fiber_type)
|
||||
AND il.reach_min_m <= t.reach_meters
|
||||
AND il.reach_max_m >= t.reach_meters
|
||||
ORDER BY il.reach_max_m ASC
|
||||
LIMIT 1
|
||||
)
|
||||
),
|
||||
connector_type = COALESCE(
|
||||
connector_type,
|
||||
(
|
||||
SELECT il.connector_type
|
||||
FROM ieee_wavelength_lookup il
|
||||
WHERE UPPER(il.form_factor) = UPPER(t.form_factor)
|
||||
AND il.speed_gbps = ROUND(t.speed_gbps::NUMERIC, 2)
|
||||
AND UPPER(il.fiber_type) = UPPER(t.fiber_type)
|
||||
AND il.reach_min_m <= t.reach_meters
|
||||
AND il.reach_max_m >= t.reach_meters
|
||||
ORDER BY il.reach_max_m ASC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
WHERE t.wavelength_tx_nm IS NULL
|
||||
AND t.form_factor IS NOT NULL
|
||||
AND t.speed_gbps IS NOT NULL
|
||||
AND t.fiber_type IS NOT NULL
|
||||
AND t.fiber_type NOT IN ('Copper', 'DAC', 'AOC', 'COPPER')
|
||||
AND t.reach_meters IS NOT NULL
|
||||
AND t.reach_meters > 0;
|
||||
|
||||
-- ── Part B: Fallback wavelength by fiber_type for remaining ──────────────────
|
||||
-- Conservative rule: SMF products with reach > 80km → 1550nm (ZR/coherent)
|
||||
-- All other SMF → 1310nm (covers ER/LR/DR/FR/LH etc.)
|
||||
-- All MMF → 850nm (SR variants)
|
||||
-- Products with DAC fiber_type: no optical wavelength (leave NULL)
|
||||
|
||||
UPDATE transceivers SET
|
||||
wavelength_tx_nm = CASE
|
||||
-- Long-reach SMF: reach > 80km → 1550nm (ZR, coherent)
|
||||
WHEN UPPER(fiber_type) = 'SMF' AND reach_meters > 80000 THEN 1550
|
||||
-- Standard SMF: 1310nm (LR/ER/DR/FR/LH etc.)
|
||||
WHEN UPPER(fiber_type) = 'SMF' AND reach_meters > 0 THEN 1310
|
||||
-- Short MMF: 850nm (SR variants)
|
||||
WHEN UPPER(fiber_type) = 'MMF' AND reach_meters > 0 THEN 850
|
||||
ELSE wavelength_tx_nm
|
||||
END
|
||||
WHERE wavelength_tx_nm IS NULL
|
||||
AND fiber_type IS NOT NULL
|
||||
AND UPPER(fiber_type) IN ('SMF', 'MMF')
|
||||
AND reach_meters IS NOT NULL
|
||||
AND reach_meters > 0
|
||||
AND form_factor IS NOT NULL
|
||||
AND UPPER(form_factor) NOT IN ('LC', 'SC', 'DAC', 'TRANSCEIVER', 'PLUGGABLE', 'VARIES');
|
||||
|
||||
-- ── Completeness final update ─────────────────────────────────────────────────
|
||||
UPDATE transceivers SET
|
||||
data_completeness = calc_data_completeness(
|
||||
form_factor, speed_gbps, fiber_type,
|
||||
reach_meters, wavelength_tx_nm, connector_type
|
||||
),
|
||||
enrichment_needed = (
|
||||
form_factor IS NULL OR speed_gbps IS NULL OR
|
||||
fiber_type IS NULL OR reach_meters IS NULL OR
|
||||
wavelength_tx_nm IS NULL OR connector_type IS NULL
|
||||
),
|
||||
enrichment_fields = ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN form_factor IS NULL THEN 'form_factor' END,
|
||||
CASE WHEN speed_gbps IS NULL THEN 'speed_gbps' END,
|
||||
CASE WHEN fiber_type IS NULL THEN 'fiber_type' END,
|
||||
CASE WHEN reach_meters IS NULL OR reach_meters = 0 THEN 'reach_meters' END,
|
||||
CASE WHEN wavelength_tx_nm IS NULL THEN 'wavelength_tx_nm' END,
|
||||
CASE WHEN connector_type IS NULL THEN 'connector_type' END
|
||||
], NULL);
|
||||
|
||||
-- ── Part C: Clear pending queue ───────────────────────────────────────────────
|
||||
-- All pending records from confidence-based matcher are superseded.
|
||||
-- Deterministic matcher (maintenance:find-equivalences) will re-generate
|
||||
-- correct matches at confidence=1.0 for products with complete data.
|
||||
UPDATE transceiver_equivalences
|
||||
SET status = 'rejected',
|
||||
reject_reason = 'Superseded by deterministic matcher — confidence-based pending removed in migration 114',
|
||||
reviewed_at = NOW(),
|
||||
reviewed_by = 'system:migration-114'
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── Final Statistics ─────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
total_cnt INTEGER;
|
||||
complete_cnt INTEGER;
|
||||
missing_conn INTEGER;
|
||||
missing_wl INTEGER;
|
||||
fx_complete INTEGER;
|
||||
fx_total INTEGER;
|
||||
pending_cnt INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO total_cnt FROM transceivers;
|
||||
SELECT COUNT(*) INTO complete_cnt FROM transceivers WHERE enrichment_needed = FALSE;
|
||||
SELECT COUNT(*) INTO missing_conn FROM transceivers WHERE connector_type IS NULL;
|
||||
SELECT COUNT(*) INTO missing_wl FROM transceivers WHERE wavelength_tx_nm IS NULL;
|
||||
SELECT COUNT(*) INTO pending_cnt FROM transceiver_equivalences WHERE status = 'pending';
|
||||
SELECT COUNT(*) INTO fx_total
|
||||
FROM transceivers t JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%';
|
||||
SELECT COUNT(*) INTO fx_complete
|
||||
FROM transceivers t JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%' AND enrichment_needed = FALSE;
|
||||
|
||||
RAISE NOTICE 'Migration 114 complete:';
|
||||
RAISE NOTICE ' Total transceivers: %', total_cnt;
|
||||
RAISE NOTICE ' Fully complete: %', complete_cnt;
|
||||
RAISE NOTICE ' Still missing connector: %', missing_conn;
|
||||
RAISE NOTICE ' Still missing wavelength: %', missing_wl;
|
||||
RAISE NOTICE ' Flexoptix fully complete: % / %', fx_complete, fx_total;
|
||||
RAISE NOTICE ' Pending queue: % (target: 0)', pending_cnt;
|
||||
END $$;
|
||||
74
sql/115-flexoptix-product-details.sql
Normal file
74
sql/115-flexoptix-product-details.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- Migration 115: Flexoptix Product Detail Columns
|
||||
-- Adds columns to store full product detail data from the Flexoptix API
|
||||
-- (specifications array, compatibility matrix, laser type, receiver type, etc.)
|
||||
-- so we can build rich datasheets and deepen the TIP comparison data.
|
||||
|
||||
-- ── New columns ──────────────────────────────────────────────────────────────
|
||||
|
||||
-- Raw specs blob: full [{label, value}, ...] array from API (specifications=1)
|
||||
-- Useful for datasheet generation and ad-hoc queries without re-fetching
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS fx_specifications JSONB;
|
||||
|
||||
-- Full compatibility list from API: [{sku, compatible_to_vendor, original_part_number}, ...]
|
||||
-- More granular than vendor_compat (which has pattern-based matching)
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS fx_compatibilities JSONB;
|
||||
|
||||
-- Structured spec fields parsed from fx_specifications
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS compliance_code TEXT; -- "LX SGMII", "SR4 100GBASE", "LR4", etc.
|
||||
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS laser_type TEXT; -- "FP", "DFB", "VCSEL", "EML", "CW-SiPh"
|
||||
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS receiver_type TEXT; -- "PIN", "APD", "Coherent"
|
||||
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS supported_protocols TEXT[]; -- ["1GigE", "Fast Ethernet", "10GBase-SR", ...]
|
||||
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS extinction_ratio_db NUMERIC(6,2); -- dB
|
||||
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS cdr_support BOOLEAN; -- false = "none", true = integrated CDR
|
||||
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS inbuilt_fec BOOLEAN; -- false = "No", true = integrated FEC
|
||||
|
||||
-- Tracking: when the full per-SKU detail sync last completed for this product
|
||||
ALTER TABLE transceivers
|
||||
ADD COLUMN IF NOT EXISTS detail_synced_at TIMESTAMPTZ;
|
||||
|
||||
-- ── Indexes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
-- GIN index for JSONB compatibility search (e.g. "which FX products are
|
||||
-- compatible with Cisco Nexus 9000 where OPN starts with N9K-?")
|
||||
CREATE INDEX IF NOT EXISTS idx_transceivers_fx_compatibilities
|
||||
ON transceivers USING GIN (fx_compatibilities)
|
||||
WHERE fx_compatibilities IS NOT NULL;
|
||||
|
||||
-- Index for detail sync queue (find unseen or stale products quickly)
|
||||
-- NB: partial index with NOW() is not allowed (non-immutable); use plain index instead
|
||||
CREATE INDEX IF NOT EXISTS idx_transceivers_detail_synced_at
|
||||
ON transceivers (detail_synced_at NULLS FIRST);
|
||||
|
||||
-- ── Statistics ───────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
fx_cnt INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO fx_cnt
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE UPPER(v.name) LIKE '%FLEXOPTIX%';
|
||||
|
||||
RAISE NOTICE 'Migration 115 complete.';
|
||||
RAISE NOTICE ' Total FX products: %', fx_cnt;
|
||||
RAISE NOTICE ' New columns added: fx_specifications, fx_compatibilities,';
|
||||
RAISE NOTICE ' compliance_code, laser_type, receiver_type,';
|
||||
RAISE NOTICE ' supported_protocols, extinction_ratio_db,';
|
||||
RAISE NOTICE ' cdr_support, inbuilt_fec, detail_synced_at';
|
||||
RAISE NOTICE ' Run enrich:flexoptix-details to populate.';
|
||||
END $$;
|
||||
85
sql/116-opn-equivalence-matcher.sql
Normal file
85
sql/116-opn-equivalence-matcher.sql
Normal file
@ -0,0 +1,85 @@
|
||||
-- Migration 116: OPN-Based Equivalence Matcher
|
||||
-- Uses the manufacturer-provided compatibility matrix (fx_compatibilities) to
|
||||
-- create high-confidence equivalences between Flexoptix products and their
|
||||
-- exact OEM counterparts in competitor catalogs.
|
||||
--
|
||||
-- Source of truth: FX API `fx_compatibilities` field — the vendor explicitly
|
||||
-- states "this FX product replaces [vendor] [part_number]".
|
||||
--
|
||||
-- Match quality: confidence=1.0, match_basis='{opn}' (OEM Part Number)
|
||||
-- These are better than spec-based matches because they are manufacturer-confirmed.
|
||||
--
|
||||
-- Rules:
|
||||
-- - Only inserts NEW pairs (skips existing approved, auto_approved, rejected)
|
||||
-- - Skips MSA Standard and Flexoptix entries (not real competitors)
|
||||
-- - Case-insensitive part_number match
|
||||
-- - Target must be a competitor vendor (is_competitor = true)
|
||||
|
||||
-- ── Insert new OPN-based equivalences ────────────────────────────────────────
|
||||
|
||||
INSERT INTO transceiver_equivalences (
|
||||
flexoptix_id,
|
||||
competitor_id,
|
||||
confidence,
|
||||
status,
|
||||
match_basis,
|
||||
match_notes,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT DISTINCT
|
||||
fx.id AS flexoptix_id,
|
||||
comp.id AS competitor_id,
|
||||
1.0 AS confidence,
|
||||
'auto_approved' AS status,
|
||||
ARRAY['opn'] AS match_basis,
|
||||
'Manufacturer-confirmed: FX compatibility matrix lists ' ||
|
||||
COALESCE(compat->>'compatible_to_vendor', '?') || ' OPN ' ||
|
||||
COALESCE(compat->>'original_part_number', '?') AS match_notes,
|
||||
NOW() AS created_at,
|
||||
NOW() AS updated_at
|
||||
FROM transceivers fx
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id AND UPPER(vfx.name) LIKE '%FLEXOPTIX%'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(fx.fx_compatibilities) AS compat
|
||||
JOIN transceivers comp
|
||||
ON UPPER(comp.part_number) = UPPER(compat->>'original_part_number')
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id AND vcomp.is_competitor = true
|
||||
WHERE fx.fx_compatibilities IS NOT NULL
|
||||
AND compat->>'original_part_number' IS NOT NULL
|
||||
AND length(trim(compat->>'original_part_number')) >= 4 -- ignore very short/empty OPNs
|
||||
AND compat->>'compatible_to_vendor' NOT IN ('MSA Standard (Default)', 'Flexoptix')
|
||||
-- Skip pairs that already have ANY equivalence (approved, auto_approved, rejected)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.competitor_id = comp.id
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Statistics ────────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
new_cnt INTEGER;
|
||||
fx_covered INTEGER;
|
||||
comp_covered INTEGER;
|
||||
total_approved INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO new_cnt
|
||||
FROM transceiver_equivalences WHERE 'opn' = ANY(match_basis);
|
||||
|
||||
SELECT COUNT(DISTINCT flexoptix_id) INTO fx_covered
|
||||
FROM transceiver_equivalences WHERE 'opn' = ANY(match_basis);
|
||||
|
||||
SELECT COUNT(DISTINCT competitor_id) INTO comp_covered
|
||||
FROM transceiver_equivalences WHERE 'opn' = ANY(match_basis);
|
||||
|
||||
SELECT COUNT(*) INTO total_approved
|
||||
FROM transceiver_equivalences WHERE status = 'auto_approved';
|
||||
|
||||
RAISE NOTICE 'Migration 116 complete: OPN-Based Equivalence Matcher';
|
||||
RAISE NOTICE ' New OPN equivalences inserted: %', new_cnt;
|
||||
RAISE NOTICE ' FX products covered: %', fx_covered;
|
||||
RAISE NOTICE ' Competitor products matched: %', comp_covered;
|
||||
RAISE NOTICE ' Total auto_approved: %', total_approved;
|
||||
END $$;
|
||||
139
sql/117-spec-equivalence-matcher.sql
Normal file
139
sql/117-spec-equivalence-matcher.sql
Normal file
@ -0,0 +1,139 @@
|
||||
-- Migration 117: Spec-Based Equivalence Matcher
|
||||
-- Matches FX products with competitor products by technical specification
|
||||
-- when no OPN-based equivalence already exists.
|
||||
--
|
||||
-- Match criteria (ALL must apply):
|
||||
-- 1. Same form_factor (exact)
|
||||
-- 2. Same speed_gbps (exact)
|
||||
-- 3. Same reach tier (SR/IR/LR/ER/ZR — based on reach_meters)
|
||||
-- 4. Same primary wavelength (within ±10nm, extracted from wavelengths field)
|
||||
-- OR both have no wavelength data (broadband / non-WDM products)
|
||||
-- 5. Target must be a competitor vendor (is_competitor = true)
|
||||
-- 6. Max 30 competitor matches per FX product (too many = too generic)
|
||||
--
|
||||
-- Match quality:
|
||||
-- confidence = 0.85 (high but below OPN-confirmed 1.0)
|
||||
-- match_basis = '{spec}'
|
||||
-- status = 'auto_approved'
|
||||
--
|
||||
-- Rules:
|
||||
-- - Skips pairs that already have ANY equivalence (approved, auto_approved, rejected)
|
||||
-- - Skips FX products that already have an OPN-based equivalence
|
||||
-- (OPN match is preferred; spec is only a fallback)
|
||||
-- - Minimum reach_meters = 10 on both sides (avoids reach=0 garbage data)
|
||||
-- - Reach tier comparison handles DAC/AOC (SR ≤ 300m)
|
||||
|
||||
-- ── Helper: extract primary wavelength in nm from text field ─────────────────
|
||||
-- Handles: "1310nm", "850nm", "1310/1550nm", "1270nm-1610nm", NULL
|
||||
CREATE OR REPLACE FUNCTION tip_extract_wavelength_nm(wl text)
|
||||
RETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT (regexp_match(wl, '(\d{3,4})\s*nm'))[1]::integer
|
||||
$$;
|
||||
|
||||
-- ── Helper: reach tier label ─────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION tip_reach_tier(reach integer)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT CASE
|
||||
WHEN reach <= 300 THEN 'SR' -- ≤300m (SR, VSR, DAC, AOC)
|
||||
WHEN reach <= 2000 THEN 'IR' -- ≤2km (LX, LH intermediate)
|
||||
WHEN reach <= 10000 THEN 'LR' -- ≤10km (LR, LX, standard LH)
|
||||
WHEN reach <= 40000 THEN 'ER' -- ≤40km (ER, extended reach)
|
||||
ELSE 'ZR' -- >40km (ZR, ZR+, coherent)
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── Insert spec-based equivalences ──────────────────────────────────────────
|
||||
|
||||
INSERT INTO transceiver_equivalences (
|
||||
flexoptix_id,
|
||||
competitor_id,
|
||||
confidence,
|
||||
status,
|
||||
match_basis,
|
||||
match_notes,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT DISTINCT
|
||||
fx.id AS flexoptix_id,
|
||||
comp.id AS competitor_id,
|
||||
0.85 AS confidence,
|
||||
'auto_approved' AS status,
|
||||
ARRAY['spec'] AS match_basis,
|
||||
'Spec match: ' || fx.form_factor || ' ' || fx.speed_gbps || 'G ' ||
|
||||
tip_reach_tier(fx.reach_meters) ||
|
||||
CASE WHEN tip_extract_wavelength_nm(fx.wavelengths) IS NOT NULL
|
||||
THEN ' @' || tip_extract_wavelength_nm(fx.wavelengths) || 'nm'
|
||||
ELSE '' END AS match_notes,
|
||||
NOW() AS created_at,
|
||||
NOW() AS updated_at
|
||||
FROM transceivers fx
|
||||
JOIN vendors vfx ON vfx.id = fx.vendor_id AND UPPER(vfx.name) LIKE '%FLEXOPTIX%'
|
||||
JOIN transceivers comp
|
||||
ON comp.form_factor = fx.form_factor
|
||||
AND comp.speed_gbps = fx.speed_gbps
|
||||
AND comp.reach_meters >= 10 -- no garbage reach=0
|
||||
AND tip_reach_tier(comp.reach_meters) = tip_reach_tier(fx.reach_meters)
|
||||
-- Wavelength: both must match within ±10nm, OR both have no wavelength
|
||||
AND (
|
||||
(tip_extract_wavelength_nm(fx.wavelengths) IS NULL
|
||||
AND tip_extract_wavelength_nm(comp.wavelengths) IS NULL)
|
||||
OR
|
||||
ABS( COALESCE(tip_extract_wavelength_nm(comp.wavelengths), 0)
|
||||
- COALESCE(tip_extract_wavelength_nm(fx.wavelengths), 0) ) <= 10
|
||||
)
|
||||
JOIN vendors vcomp ON vcomp.id = comp.vendor_id AND vcomp.is_competitor = true
|
||||
WHERE fx.reach_meters >= 10 -- no garbage reach=0 on FX side
|
||||
AND fx.speed_gbps > 0
|
||||
-- FX product has no OPN-based equivalence at all (spec is fallback only)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND 'opn' = ANY(e.match_basis)
|
||||
)
|
||||
-- Skip pairs that already have ANY equivalence
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transceiver_equivalences e
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.competitor_id = comp.id
|
||||
)
|
||||
-- Safety cap: skip FX product if it would match > 30 competitors
|
||||
-- (indicates too-generic spec — needs stricter criteria)
|
||||
AND (
|
||||
SELECT COUNT(DISTINCT c2.id)
|
||||
FROM transceivers c2
|
||||
JOIN vendors vc2 ON vc2.id = c2.vendor_id AND vc2.is_competitor = true
|
||||
WHERE c2.form_factor = fx.form_factor
|
||||
AND c2.speed_gbps = fx.speed_gbps
|
||||
AND c2.reach_meters >= 10
|
||||
AND tip_reach_tier(c2.reach_meters) = tip_reach_tier(fx.reach_meters)
|
||||
AND (
|
||||
(tip_extract_wavelength_nm(fx.wavelengths) IS NULL
|
||||
AND tip_extract_wavelength_nm(c2.wavelengths) IS NULL)
|
||||
OR ABS( COALESCE(tip_extract_wavelength_nm(c2.wavelengths), 0)
|
||||
- COALESCE(tip_extract_wavelength_nm(fx.wavelengths), 0) ) <= 10
|
||||
)
|
||||
) <= 30
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Statistics ───────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
new_cnt INTEGER;
|
||||
fx_covered INTEGER;
|
||||
comp_covered INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO new_cnt
|
||||
FROM transceiver_equivalences WHERE 'spec' = ANY(match_basis);
|
||||
|
||||
SELECT COUNT(DISTINCT flexoptix_id) INTO fx_covered
|
||||
FROM transceiver_equivalences WHERE 'spec' = ANY(match_basis);
|
||||
|
||||
SELECT COUNT(DISTINCT competitor_id) INTO comp_covered
|
||||
FROM transceiver_equivalences WHERE 'spec' = ANY(match_basis);
|
||||
|
||||
RAISE NOTICE 'Migration 117 complete: Spec-Based Equivalence Matcher';
|
||||
RAISE NOTICE ' Spec equivalences total: %', new_cnt;
|
||||
RAISE NOTICE ' FX products newly covered: %', fx_covered;
|
||||
RAISE NOTICE ' Competitor products matched: %', comp_covered;
|
||||
END $$;
|
||||
84
sql/118-stock-velocity.sql
Normal file
84
sql/118-stock-velocity.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- ══════════════════════════════════════════════════════════════════════════════
|
||||
-- 118 — Stock Velocity & Sell-Through Analysis
|
||||
--
|
||||
-- Evaluates implied Abverkauf (sell-through) from time-series stock_observations:
|
||||
-- • Negative stock delta → implied units sold (sell event)
|
||||
-- • Positive stock delta after backorder → Zulauf (incoming replenishment)
|
||||
-- • FS.com units_sold counter delta → high-confidence sell signal
|
||||
--
|
||||
-- Stores per-product velocity results in stock_velocity for API / dashboard use.
|
||||
-- ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Main results table ────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS stock_velocity (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
transceiver_id UUID NOT NULL REFERENCES transceivers(id) ON DELETE CASCADE,
|
||||
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||
computed_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
|
||||
-- Observation window
|
||||
window_start TIMESTAMPTZ NOT NULL,
|
||||
window_end TIMESTAMPTZ NOT NULL,
|
||||
obs_count INTEGER NOT NULL,
|
||||
|
||||
-- Sell-through metrics
|
||||
avg_daily_sell_rate NUMERIC(12, 2), -- units/day (implied)
|
||||
peak_daily_sell_rate NUMERIC(12, 2), -- highest single-interval rate
|
||||
total_sell_events INTEGER DEFAULT 0,
|
||||
total_units_sold_implied INTEGER DEFAULT 0,
|
||||
|
||||
-- FS.com direct counter (more reliable when available)
|
||||
units_sold_counter_delta BIGINT, -- delta in FS.com units_sold between first/last obs
|
||||
units_sold_daily_rate NUMERIC(12, 2), -- counter_delta / window_days
|
||||
|
||||
-- Zulauf (incoming stock / replenishment)
|
||||
total_zulauf_events INTEGER DEFAULT 0,
|
||||
total_units_zulauf INTEGER DEFAULT 0,
|
||||
last_zulauf_at TIMESTAMPTZ,
|
||||
next_expected_delivery DATE, -- backorder_estimated_date from latest obs
|
||||
|
||||
-- Current stock state (from latest observation)
|
||||
current_qty INTEGER,
|
||||
current_backorder_qty INTEGER,
|
||||
current_price_net NUMERIC(10, 2),
|
||||
|
||||
-- Sell-through prediction
|
||||
estimated_stockout_days NUMERIC(8, 1), -- NULL if no velocity or stock = 0
|
||||
estimated_stockout_date DATE,
|
||||
|
||||
-- Signal quality
|
||||
velocity_confidence TEXT CHECK (velocity_confidence IN ('high', 'medium', 'low', 'insufficient')),
|
||||
-- high = ≥14 observations with meaningful deltas
|
||||
-- medium = ≥5 observations
|
||||
-- low = 2–4 observations
|
||||
-- insufficient = only 1 observation or no change detected
|
||||
|
||||
UNIQUE (transceiver_id, vendor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_velocity_vendor ON stock_velocity (vendor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_velocity_computed ON stock_velocity (computed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_velocity_stockout ON stock_velocity (estimated_stockout_date)
|
||||
WHERE estimated_stockout_date IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_velocity_confidence ON stock_velocity (velocity_confidence);
|
||||
|
||||
COMMENT ON TABLE stock_velocity IS
|
||||
'Computed sell-through velocity per transceiver per vendor, derived from '
|
||||
'time-series stock_observations. Refreshed by analyze:stock:velocity job.';
|
||||
|
||||
-- ── Sell event log (raw events for trend analysis) ────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS stock_velocity_events (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
transceiver_id UUID NOT NULL REFERENCES transceivers(id) ON DELETE CASCADE,
|
||||
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||
event_at TIMESTAMPTZ NOT NULL,
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('sold', 'zulauf', 'unchanged', 'data_gap')),
|
||||
units_delta INTEGER, -- negative = sold, positive = arrived
|
||||
daily_rate NUMERIC(10, 2), -- implied rate for this interval
|
||||
qty_before INTEGER,
|
||||
qty_after INTEGER,
|
||||
hours_elapsed NUMERIC(8, 2)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_velocity_events_tx ON stock_velocity_events (transceiver_id, vendor_id, event_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_velocity_events_type ON stock_velocity_events (event_type, event_at);
|
||||
144
sql/119-product-type-classification.sql
Normal file
144
sql/119-product-type-classification.sql
Normal file
@ -0,0 +1,144 @@
|
||||
-- Migration 119: product_type classification (module vs cable vs switch/NIC/accessory)
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Problem: TIP ingests DAC/AOC/AEC cables, switches and NICs as `transceivers`
|
||||
-- rows (one per cable length). `form_factor` is unreliable (a 400G DAC can be
|
||||
-- tagged 'SFP+'). Every "assortment breadth" metric is therefore cable-inflated,
|
||||
-- and consumers (Lupo) had to re-derive a text heuristic on every query.
|
||||
--
|
||||
-- Fix: a deterministic `product_type` enum, computed at ingest by a BEFORE
|
||||
-- INSERT/UPDATE trigger from the free-text part_number + category + reach.
|
||||
-- Consumers filter `product_type = 'module'` for a true module count.
|
||||
--
|
||||
-- Taxonomy: module | dac | aoc | aec | switch | nic | accessory
|
||||
-- Classification is keyword-first (an explicit 'AOC'/'DAC' token beats the
|
||||
-- optical-token guard, because an Active Optical Cable legitimately carries
|
||||
-- nm/MMF/SR tokens). BASE-T / RJ45 electrical transceivers are protected as
|
||||
-- modules before the copper/DAC rule so genuine RJ45 optics are never demoted.
|
||||
--
|
||||
-- Idempotent. Applied manually via `docker exec -i tip-postgres psql` on Erik
|
||||
-- (the _migrations tracker is only used by scripts/migrate.ts; recent migrations
|
||||
-- are applied by hand — we still record this file there for correctness).
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 1. Column + CHECK constraint --------------------------------------------------
|
||||
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS product_type TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'transceivers_product_type_check'
|
||||
) THEN
|
||||
ALTER TABLE transceivers ADD CONSTRAINT transceivers_product_type_check
|
||||
CHECK (product_type IS NULL OR product_type IN
|
||||
('module','dac','aoc','aec','switch','nic','accessory'));
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON COLUMN transceivers.product_type IS
|
||||
'Deterministic product class set by trigger tip_transceivers_biu(): '
|
||||
'module=optical/electrical pluggable, dac/aoc/aec=cables, switch/nic=infrastructure, '
|
||||
'accessory=loopback/attenuator/amplifier/mux/etc. Consumers filter product_type=''module''.';
|
||||
|
||||
-- 2. Deterministic classifier ---------------------------------------------------
|
||||
-- IMMUTABLE: depends only on its inputs, so it is safe in triggers and indexes.
|
||||
CREATE OR REPLACE FUNCTION tip_classify_product_type(
|
||||
p_part_number TEXT,
|
||||
p_category TEXT,
|
||||
p_reach_label TEXT
|
||||
) RETURNS TEXT
|
||||
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT CASE
|
||||
-- 1. NIC / adapter cards
|
||||
WHEN COALESCE(p_part_number,'') ~* '(connectx|supernic|bluefield|smartnic|adapter card|\mmcx[0-9])'
|
||||
THEN 'nic'
|
||||
-- 2. Switches / network infrastructure (N-port matches "64-Port", not "Twin-Port")
|
||||
WHEN COALESCE(p_part_number,'') ~* '(\mswitch\M|switches|\mquantum\M|\mspectrum\M|tomahawk|\mqm9[0-9]{3}|q3[0-9]{3}-ra|[0-9]+-port|\mdgx\M|\mgpu\M)'
|
||||
THEN 'switch'
|
||||
-- 3. Hard accessory keywords (never a module even if the title says "transceiver"/"copper")
|
||||
WHEN COALESCE(p_part_number,'') ~* '(loopback|\mlb\M|attenuator|amplifier|\medfa\M|\moadm\M|demux|multiplexer|\mmux\M|transponder|transport[ -]system|microscope|cleaning|cassette|\mtester\M|media converter)'
|
||||
THEN 'accessory'
|
||||
-- 4. AEC — active electrical cable (Mellanox MCA copper-active family)
|
||||
WHEN COALESCE(p_part_number,'') ~* '(\maec\M|active electrical|\mmca[0-9])'
|
||||
THEN 'aec'
|
||||
-- 5. AOC — active optical cable (Mellanox MFA/MFS active fibre families)
|
||||
WHEN COALESCE(p_part_number,'') ~* '(aoc|active optical|\mmfa|\mmfs[0-9])'
|
||||
THEN 'aoc'
|
||||
-- 6. BASE-T / RJ45 electrical transceiver = MODULE (guard before the copper/DAC rule)
|
||||
WHEN COALESCE(p_part_number,'') ~* '(base-?t|[0-9]+gbase-?t|rj-?45|8p8c)'
|
||||
THEN 'module'
|
||||
-- 7. DAC / passive copper / twinax (Mellanox MCP passive-copper family)
|
||||
WHEN COALESCE(p_part_number,'') ~* '(dac|twinax|direct attach|copper|passive|splitter|breakout|\mmcp|\mcab-)'
|
||||
THEN 'dac'
|
||||
-- 8. Category-driven cable signals (scraper set a cable category but a bland title)
|
||||
WHEN COALESCE(p_category,'') ~* 'AOC' THEN 'aoc'
|
||||
WHEN COALESCE(p_category,'') ~* '(DAC|Copper|Cable)' THEN 'dac'
|
||||
-- 9. Bare "<n>m (<n>ft) ..." length title — a cable, unless it carries an optical token
|
||||
WHEN COALESCE(p_part_number,'') ~* '^[0-9.]+ *m( *\([0-9.]+ ?ft\))? '
|
||||
AND COALESCE(p_part_number,'') !~* '(sr[0-9]|dr[0-9]|fr[0-9]|lr[0-9]|er[0-9]|zr|base|[0-9]+nm|mmf|smf|km|cwdm|dwdm|bidi|lambda)'
|
||||
THEN 'dac'
|
||||
-- 10. Short reach <=10 m with no optical token — a copper DAC
|
||||
WHEN COALESCE(p_reach_label,'') ~* '^[0-9.]+ *m$'
|
||||
AND COALESCE(NULLIF(regexp_replace(p_reach_label,'[^0-9.].*$',''),'')::float, 999) <= 10
|
||||
AND COALESCE(p_part_number,'') !~* '(sr[0-9]|dr[0-9]|fr[0-9]|lr[0-9]|er[0-9]|zr|base|[0-9]+nm|mmf|smf|km|cwdm|dwdm|bidi|lambda)'
|
||||
THEN 'dac'
|
||||
-- 11. Category-driven accessory residue
|
||||
WHEN COALESCE(p_category,'') IN ('Accessory','Adapter / Converter','Switch / Media Converter',
|
||||
'Switch / Network Infrastructure','NIC / Adapter','Mux / Passive Optical',
|
||||
'Product Family','Loopback / Test Module')
|
||||
THEN 'accessory'
|
||||
-- 12. Default: a genuine optical/electrical module
|
||||
ELSE 'module'
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 3. Trigger — classify every row on write ------------------------------------
|
||||
-- Recomputes only when a classification input changed (or product_type is NULL),
|
||||
-- so ordinary price/verification updates are untouched. Deterministic + self-healing.
|
||||
CREATE OR REPLACE FUNCTION tip_transceivers_biu() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT'
|
||||
OR NEW.product_type IS NULL
|
||||
OR NEW.part_number IS DISTINCT FROM OLD.part_number
|
||||
OR NEW.category IS DISTINCT FROM OLD.category
|
||||
OR NEW.reach_label IS DISTINCT FROM OLD.reach_label
|
||||
THEN
|
||||
NEW.product_type := tip_classify_product_type(NEW.part_number, NEW.category, NEW.reach_label);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_transceivers_biu ON transceivers;
|
||||
CREATE TRIGGER trg_transceivers_biu
|
||||
BEFORE INSERT OR UPDATE ON transceivers
|
||||
FOR EACH ROW EXECUTE FUNCTION tip_transceivers_biu();
|
||||
|
||||
-- 4. Backfill every existing row ------------------------------------------------
|
||||
UPDATE transceivers
|
||||
SET product_type = tip_classify_product_type(part_number, category, reach_label)
|
||||
WHERE product_type IS DISTINCT FROM
|
||||
tip_classify_product_type(part_number, category, reach_label);
|
||||
|
||||
-- 5. Indexes for the common consumer filter ------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_transceivers_product_type
|
||||
ON transceivers (product_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_transceivers_ptype_speed
|
||||
ON transceivers (product_type, speed_gbps);
|
||||
|
||||
-- 6. Record in the migration tracker (idempotent) ------------------------------
|
||||
INSERT INTO _migrations (filename)
|
||||
VALUES ('119-product-type-classification.sql')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
|
||||
-- 7. Report --------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE rec RECORD;
|
||||
BEGIN
|
||||
RAISE NOTICE '=== product_type distribution ===';
|
||||
FOR rec IN SELECT product_type, count(*) c FROM transceivers GROUP BY 1 ORDER BY 2 DESC LOOP
|
||||
RAISE NOTICE ' %: %', rpad(COALESCE(rec.product_type,'(null)'), 10), rec.c;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
124
sql/120-form-factor-recovery.sql
Normal file
124
sql/120-form-factor-recovery.sql
Normal file
@ -0,0 +1,124 @@
|
||||
-- Migration 120: conservative form_factor recovery (provably-wrong rows only)
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Problem: some scrapers default form_factor to 'SFP'/'SFP+', so a 400G QSFP-DD
|
||||
-- DAC ends up tagged 'SFP+'. We only ever correct a form_factor that is
|
||||
-- PROVABLY wrong — i.e. its max electrical speed (form_factors.max_speed_gbps)
|
||||
-- is below the row's actual speed_gbps — and only when the part_number carries
|
||||
-- an explicit form-factor token that CAN carry that speed. Opaque part numbers
|
||||
-- (e.g. 'EOLS-1303-40-D') are left untouched; we never guess a form factor.
|
||||
--
|
||||
-- The scraped original is preserved in form_factor_raw for audit/rollback.
|
||||
-- Ingest-time self-healing is added to the tip_transceivers_biu() trigger.
|
||||
-- Idempotent.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 1. Preserve the scraped original ---------------------------------------------
|
||||
ALTER TABLE transceivers ADD COLUMN IF NOT EXISTS form_factor_raw TEXT;
|
||||
COMMENT ON COLUMN transceivers.form_factor_raw IS
|
||||
'Form factor exactly as scraped, before tip_recover_form_factor() correction. Audit/rollback only.';
|
||||
|
||||
UPDATE transceivers SET form_factor_raw = form_factor
|
||||
WHERE form_factor_raw IS NULL;
|
||||
|
||||
-- 2. Explicit form-factor token extraction (most-specific first) ----------------
|
||||
CREATE OR REPLACE FUNCTION tip_extract_ff_token(p_part_number TEXT) RETURNS TEXT
|
||||
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT CASE
|
||||
WHEN p_part_number ~* 'qsfp-?dd-?800|qdd-?800' THEN 'QSFP-DD800'
|
||||
-- Q56DD / QSFP56-DD is the 400G double-density QSFP connector (Dell naming) → QSFP-DD
|
||||
WHEN p_part_number ~* 'q56dd|qsfp56-?dd' THEN 'QSFP-DD'
|
||||
WHEN p_part_number ~* 'osfp-?xd' THEN 'OSFP-XD'
|
||||
WHEN p_part_number ~* 'osfp224' THEN 'OSFP224'
|
||||
WHEN p_part_number ~* 'osfp112' THEN 'OSFP112'
|
||||
WHEN p_part_number ~* '\mosfp\M' THEN 'OSFP'
|
||||
WHEN p_part_number ~* 'qsfp112' THEN 'QSFP112'
|
||||
WHEN p_part_number ~* 'qsfp-?dd|\mqdd\M|qsfpdd' THEN 'QSFP-DD'
|
||||
WHEN p_part_number ~* 'qsfp56|\mq56\M' THEN 'QSFP56'
|
||||
WHEN p_part_number ~* 'qsfp28|\mq28\M' THEN 'QSFP28'
|
||||
WHEN p_part_number ~* 'qsfp\+|qsfp-?plus' THEN 'QSFP+'
|
||||
WHEN p_part_number ~* 'sfp-?dd|sfpdd' THEN 'SFP-DD'
|
||||
WHEN p_part_number ~* 'sfp112' THEN 'SFP112'
|
||||
WHEN p_part_number ~* 'sfp56' THEN 'SFP56'
|
||||
WHEN p_part_number ~* 'sfp28' THEN 'SFP28'
|
||||
WHEN p_part_number ~* 'sfp\+|sfp-?plus' THEN 'SFP+'
|
||||
WHEN p_part_number ~* 'cfp2-?dco' THEN 'CFP2-DCO'
|
||||
WHEN p_part_number ~* 'cfp2' THEN 'CFP2'
|
||||
WHEN p_part_number ~* 'cfp8' THEN 'CFP8'
|
||||
WHEN p_part_number ~* 'cfp4' THEN 'CFP4'
|
||||
WHEN p_part_number ~* '\mcfp\M' THEN 'CFP'
|
||||
WHEN p_part_number ~* '\mxfp\M' THEN 'XFP'
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 3. Recovery: return corrected FF only when the current one is PROVABLY wrong ---
|
||||
CREATE OR REPLACE FUNCTION tip_recover_form_factor(
|
||||
p_part_number TEXT, p_form_factor TEXT, p_speed_gbps NUMERIC
|
||||
) RETURNS TEXT
|
||||
LANGUAGE plpgsql STABLE PARALLEL SAFE AS $$
|
||||
DECLARE
|
||||
v_token TEXT := tip_extract_ff_token(p_part_number);
|
||||
v_cur_max NUMERIC;
|
||||
v_tok_max NUMERIC;
|
||||
BEGIN
|
||||
IF v_token IS NULL OR p_form_factor IS NULL OR p_speed_gbps IS NULL THEN
|
||||
RETURN p_form_factor;
|
||||
END IF;
|
||||
IF upper(v_token) = upper(p_form_factor) THEN
|
||||
RETURN p_form_factor; -- already correct
|
||||
END IF;
|
||||
SELECT max_speed_gbps INTO v_cur_max FROM form_factors WHERE upper(name) = upper(p_form_factor);
|
||||
SELECT max_speed_gbps INTO v_tok_max FROM form_factors WHERE name = v_token;
|
||||
-- current provably wrong for the speed, and the token can actually carry it
|
||||
IF v_cur_max IS NOT NULL AND v_tok_max IS NOT NULL
|
||||
AND p_speed_gbps > v_cur_max AND v_tok_max >= p_speed_gbps THEN
|
||||
RETURN v_token;
|
||||
END IF;
|
||||
RETURN p_form_factor; -- not confident → leave as-is
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 4. Backfill the confidently-recoverable rows ---------------------------------
|
||||
UPDATE transceivers
|
||||
SET form_factor = tip_recover_form_factor(part_number, form_factor, speed_gbps)
|
||||
WHERE form_factor IS DISTINCT FROM tip_recover_form_factor(part_number, form_factor, speed_gbps);
|
||||
|
||||
-- 5. Extend the ingest trigger with FF self-healing ----------------------------
|
||||
CREATE OR REPLACE FUNCTION tip_transceivers_biu() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- product_type: recompute only when a classification input changed
|
||||
IF TG_OP = 'INSERT'
|
||||
OR NEW.product_type IS NULL
|
||||
OR NEW.part_number IS DISTINCT FROM OLD.part_number
|
||||
OR NEW.category IS DISTINCT FROM OLD.category
|
||||
OR NEW.reach_label IS DISTINCT FROM OLD.reach_label
|
||||
THEN
|
||||
NEW.product_type := tip_classify_product_type(NEW.part_number, NEW.category, NEW.reach_label);
|
||||
END IF;
|
||||
|
||||
-- form_factor: preserve the scraped original, then correct only provably-wrong values
|
||||
NEW.form_factor_raw := COALESCE(NEW.form_factor_raw, NEW.form_factor);
|
||||
NEW.form_factor := tip_recover_form_factor(NEW.part_number, NEW.form_factor, NEW.speed_gbps);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- (trigger trg_transceivers_biu already bound in migration 119 — replacing the
|
||||
-- function body above is sufficient.)
|
||||
|
||||
INSERT INTO _migrations (filename)
|
||||
VALUES ('120-form-factor-recovery.sql')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
|
||||
-- 6. Report --------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE v_fixed INT; v_left INT;
|
||||
BEGIN
|
||||
SELECT count(*) INTO v_fixed FROM transceivers WHERE form_factor IS DISTINCT FROM form_factor_raw;
|
||||
SELECT count(*) INTO v_left FROM transceivers t JOIN form_factors f ON upper(f.name)=upper(t.form_factor)
|
||||
WHERE t.speed_gbps > f.max_speed_gbps;
|
||||
RAISE NOTICE 'form_factor corrected on % rows; % provably-wrong rows left untouched (opaque, not guessed).', v_fixed, v_left;
|
||||
END;
|
||||
$$;
|
||||
35
sql/121-lead-time-not-collected-doc.sql
Normal file
35
sql/121-lead-time-not-collected-doc.sql
Normal file
@ -0,0 +1,35 @@
|
||||
-- Migration 121: document lead_time_days as NOT COLLECTED
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Verified 2026-07-04: lead_time_days is 0 % populated on every time-series table
|
||||
-- price_observations : 1,378,984 rows → 0 filled
|
||||
-- stock_observations : 85,406 rows → 0 filled
|
||||
-- stock_snapshots : 0 rows (LLM-crawler path is dormant)
|
||||
--
|
||||
-- The plumbing exists (crawler-llm stock-schema captures lead_time_days and
|
||||
-- writes it to stock_snapshots; upsertPriceObservation accepts a leadTimeDays
|
||||
-- param) but the ACTIVE ingestion path never supplies a value, and there is no
|
||||
-- evidence any tracked vendor page exposes a machine-readable lead time.
|
||||
--
|
||||
-- Rather than fabricate a default, this migration documents the column as
|
||||
-- not-collected so no consumer invents one. Lupo already renders "k.A." / "n/a"
|
||||
-- and blends only the two signals it actually measures (availability, breadth).
|
||||
-- Populating this field is a SCRAPER-side task (extend the active crawlers to
|
||||
-- read a delivery/lead-time badge) — out of scope for a schema migration.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
COMMENT ON COLUMN price_observations.lead_time_days IS
|
||||
'NOT COLLECTED (verified 2026-07-04): 0 % populated. The active scraper path never '
|
||||
'supplies a lead time. Do NOT default this — treat NULL as "unknown / not captured". '
|
||||
'Populating it requires scraper work (read a delivery-time badge on the vendor page).';
|
||||
|
||||
COMMENT ON COLUMN stock_observations.lead_time_days IS
|
||||
'NOT COLLECTED (verified 2026-07-04): 0 % populated. Treat NULL as "unknown". See '
|
||||
'price_observations.lead_time_days.';
|
||||
|
||||
COMMENT ON COLUMN stock_snapshots.lead_time_days IS
|
||||
'Captured by the crawler-llm path (stock-schema.lead_time_days) but that path is '
|
||||
'currently dormant (0 rows). Treat NULL as "unknown / not captured".';
|
||||
|
||||
INSERT INTO _migrations (filename)
|
||||
VALUES ('121-lead-time-not-collected-doc.sql')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
54
sql/122-fs-com-delivery-dates-and-units-sold-gate.sql
Normal file
54
sql/122-fs-com-delivery-dates-and-units-sold-gate.sql
Normal file
@ -0,0 +1,54 @@
|
||||
-- Migration 122: FS.com delivery dates + lead_time now collected; purge units_sold garbage
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Follow-up to migration 121, which documented lead_time_days as NOT COLLECTED and
|
||||
-- flagged that populating it was a scraper-side task. That task is now done:
|
||||
--
|
||||
-- * fs-com.ts (via fs-com-parse.ts) parses the bare word-date FS.com renders on
|
||||
-- each warehouse line ("… im DE-Lager, 8. Juli 2026 Lieferbar") into
|
||||
-- warehouse_de_delivery_date / warehouse_global_delivery_date /
|
||||
-- backorder_estimated_date, and derives lead_time_days = min(date − today).
|
||||
-- * upsertStockObservation now writes lead_time_days.
|
||||
--
|
||||
-- Historical rows stay NULL until the next FS.com run re-scrapes each SKU; NULL
|
||||
-- therefore still means "unknown / not yet captured", not "zero lead time".
|
||||
--
|
||||
-- Second fix: units_sold was ~10× inflated for decimal-abbreviated values
|
||||
-- ("4.8K"→48000, "1.4M"→14000000) because the K/M parser stripped the decimal
|
||||
-- point. The parser is fixed AND ingest now drops values above a sanity cap.
|
||||
-- This migration removes the clearly-impossible historical values (the multi-
|
||||
-- million "outliers") so no consumer ingests them before the re-scrape. Sub-cap
|
||||
-- values that may still be mildly inflated self-correct on the next FS.com run
|
||||
-- (a changed value writes a fresh observation; consumers read the latest).
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 1. Purge impossible units_sold (matches the ingest gate default, FS_MAX_UNITS_SOLD=2,000,000).
|
||||
UPDATE stock_observations
|
||||
SET units_sold = NULL
|
||||
WHERE units_sold IS NOT NULL
|
||||
AND units_sold > 2000000;
|
||||
|
||||
-- 2. Re-document the columns as collected-by-scraper (reversing migration 121's note).
|
||||
COMMENT ON COLUMN stock_observations.warehouse_de_delivery_date IS
|
||||
'FS.com DE-Lager availability date (collected 2026-07-04+ by fs-com-parse). '
|
||||
'NULL = warehouse in stock now, vendor did not quote a date, or SKU not yet re-scraped.';
|
||||
|
||||
COMMENT ON COLUMN stock_observations.warehouse_global_delivery_date IS
|
||||
'FS.com Global-Lager availability date (collected 2026-07-04+ by fs-com-parse). '
|
||||
'NULL semantics as warehouse_de_delivery_date.';
|
||||
|
||||
COMMENT ON COLUMN stock_observations.backorder_estimated_date IS
|
||||
'FS.com Nachlieferung (backorder) estimated date (collected 2026-07-04+ by fs-com-parse). '
|
||||
'NULL = no backorder date quoted or SKU not yet re-scraped.';
|
||||
|
||||
COMMENT ON COLUMN stock_observations.lead_time_days IS
|
||||
'Soonest availability in days = min(delivery_date − today) across warehouses that quote a '
|
||||
'date. Collected 2026-07-04+ by the FS.com scraper; other vendors do not expose a lead time. '
|
||||
'Treat NULL as "unknown / not captured" (do NOT default to 0).';
|
||||
|
||||
COMMENT ON COLUMN stock_observations.units_sold IS
|
||||
'FS.com "verkauft" lifetime sell-through counter. Parser de-abbreviates K/M correctly and '
|
||||
'ingest drops values > 2,000,000 as implausible. FS.com only; NULL elsewhere.';
|
||||
|
||||
INSERT INTO _migrations (filename)
|
||||
VALUES ('122-fs-com-delivery-dates-and-units-sold-gate.sql')
|
||||
ON CONFLICT (filename) DO NOTHING;
|
||||
165
sql/123-data-quality-monitoring.sql
Normal file
165
sql/123-data-quality-monitoring.sql
Normal file
@ -0,0 +1,165 @@
|
||||
-- 123-data-quality-monitoring.sql
|
||||
-- Data-quality monitoring layer (read-only) from the TIP data-quality audit 2026-07-05.
|
||||
--
|
||||
-- NON-DESTRUCTIVE: creates four SELECT-only views. No table, column, row or
|
||||
-- constraint changes. Safe to apply on a live DB and idempotent (re-runnable).
|
||||
--
|
||||
-- v_scraper_health freshness + breadth per (source vendor, observation table)
|
||||
-- v_stock_completeness % of stock fields actually extracted, per source vendor
|
||||
-- v_coverage catalog composition + reach_label completeness per manufacturer
|
||||
-- v_data_quality_alerts one row per OPEN data-quality issue -> the task list
|
||||
-- (goal: this view returns zero rows once everything is fixed)
|
||||
--
|
||||
-- Freshness thresholds (calibrated against observed scraper cadence 2026-07-06):
|
||||
-- fresh = newest row within 7 days
|
||||
-- stale = newest row within 30 days
|
||||
-- dead = newest row older than 30 days (regression: had data, then stopped)
|
||||
|
||||
-- Drop in reverse-dependency order so the file is fully re-runnable.
|
||||
DROP VIEW IF EXISTS v_data_quality_alerts;
|
||||
DROP VIEW IF EXISTS v_scraper_health;
|
||||
DROP VIEW IF EXISTS v_stock_completeness;
|
||||
DROP VIEW IF EXISTS v_coverage;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- v_scraper_health: is each scraper still delivering, and how broad is it?
|
||||
-- One row per (source vendor, observation table). Separating the tables is
|
||||
-- essential: e.g. ATGBICS price is fresh while ATGBICS stock is dead.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW v_scraper_health AS
|
||||
WITH obs AS (
|
||||
SELECT source_vendor_id, 'stock_observations'::text AS source_table,
|
||||
"time", transceiver_id
|
||||
FROM stock_observations
|
||||
UNION ALL
|
||||
SELECT source_vendor_id, 'price_observations'::text AS source_table,
|
||||
"time", transceiver_id
|
||||
FROM price_observations
|
||||
)
|
||||
SELECT v.name AS vendor,
|
||||
v.slug AS vendor_slug,
|
||||
o.source_table,
|
||||
count(*) AS total_rows,
|
||||
count(*) FILTER (WHERE o."time" >= now() - interval '30 days') AS rows_30d,
|
||||
count(DISTINCT o.transceiver_id) AS distinct_transceivers,
|
||||
max(o."time")::date AS last_observation,
|
||||
(now()::date - max(o."time")::date) AS days_since_last,
|
||||
CASE
|
||||
WHEN max(o."time") >= now() - interval '7 days' THEN 'fresh'
|
||||
WHEN max(o."time") >= now() - interval '30 days' THEN 'stale'
|
||||
ELSE 'dead'
|
||||
END AS status
|
||||
FROM obs o
|
||||
JOIN vendors v ON v.id = o.source_vendor_id
|
||||
GROUP BY v.id, v.name, v.slug, o.source_table;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- v_stock_completeness: rows are being written, but are the fields populated?
|
||||
-- Catches "scraper runs but extraction is a no-op" (e.g. QSFPTEK: 75k rows,
|
||||
-- 0% units_sold / warehouse / delivery).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW v_stock_completeness AS
|
||||
SELECT v.name AS vendor,
|
||||
v.slug AS vendor_slug,
|
||||
count(*) AS stock_rows,
|
||||
max(s."time")::date AS last_obs,
|
||||
round(100.0 * count(s.units_sold) / count(*), 1) AS pct_units_sold,
|
||||
round(100.0 * count(*) FILTER (
|
||||
WHERE s.warehouse_de_qty IS NOT NULL
|
||||
OR s.warehouse_global_qty IS NOT NULL) / count(*), 1) AS pct_warehouse_qty,
|
||||
round(100.0 * count(*) FILTER (
|
||||
WHERE s.warehouse_de_delivery_date IS NOT NULL
|
||||
OR s.warehouse_global_delivery_date IS NOT NULL
|
||||
OR s.backorder_estimated_date IS NOT NULL) / count(*), 1) AS pct_delivery_date,
|
||||
round(100.0 * count(s.lead_time_days) / count(*), 1) AS pct_lead_time
|
||||
FROM stock_observations s
|
||||
JOIN vendors v ON v.id = s.source_vendor_id
|
||||
GROUP BY v.id, v.name, v.slug;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- v_coverage: catalog composition and reach_label completeness per manufacturer
|
||||
-- (transceivers.vendor_id = the brand, distinct from the scraping source vendor).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW v_coverage AS
|
||||
SELECT v.name AS vendor,
|
||||
v.slug AS vendor_slug,
|
||||
count(*) AS transceivers,
|
||||
count(*) FILTER (WHERE t.product_type = 'module') AS modules,
|
||||
count(*) FILTER (WHERE t.product_type IN ('dac','aoc','aec')) AS cables,
|
||||
count(*) FILTER (WHERE t.reach_label IS NULL) AS reach_label_null,
|
||||
round(100.0 * count(*) FILTER (WHERE t.reach_label IS NULL) / count(*), 1) AS pct_reach_null
|
||||
FROM transceivers t
|
||||
JOIN vendors v ON v.id = t.vendor_id
|
||||
GROUP BY v.id, v.name, v.slug;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- v_data_quality_alerts: the actionable task list. Each rule fires only while
|
||||
-- the underlying problem is open, so the view drains to empty as things get
|
||||
-- fixed. severity_rank orders critical(1) > high(2) > warning(3).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW v_data_quality_alerts AS
|
||||
WITH alerts AS (
|
||||
-- 1) Dead scraper: had data, stopped delivering > 30 days ago.
|
||||
SELECT 'critical'::text AS severity, 1 AS severity_rank,
|
||||
'scraper_dead'::text AS category,
|
||||
vendor || ' / ' || source_table AS entity,
|
||||
format('no new rows for %s days (last %s); %s rows total',
|
||||
days_since_last, last_observation, total_rows) AS detail
|
||||
FROM v_scraper_health
|
||||
WHERE status = 'dead'
|
||||
|
||||
UNION ALL
|
||||
-- 2) Stale scraper: 8-30 days since last row.
|
||||
SELECT 'warning', 3, 'scraper_stale',
|
||||
vendor || ' / ' || source_table,
|
||||
format('no new rows for %s days (last %s)', days_since_last, last_observation)
|
||||
FROM v_scraper_health
|
||||
WHERE status = 'stale'
|
||||
|
||||
UNION ALL
|
||||
-- 3) Stock rows written but extraction is empty (scraper otherwise alive).
|
||||
SELECT 'high', 2, 'stock_extraction_empty',
|
||||
vendor || ' / stock_observations',
|
||||
format('%s rows but units_sold=%s%%, warehouse=%s%%, delivery=%s%% populated',
|
||||
stock_rows, pct_units_sold, pct_warehouse_qty, pct_delivery_date)
|
||||
FROM v_stock_completeness
|
||||
WHERE stock_rows >= 500
|
||||
AND pct_units_sold = 0
|
||||
AND pct_warehouse_qty = 0
|
||||
AND last_obs >= now()::date - 30
|
||||
|
||||
UNION ALL
|
||||
-- 4) Fleet-wide delivery-date / lead-time gap -> lead_time_days not derivable.
|
||||
SELECT 'high', 2, 'delivery_date_gap',
|
||||
'ALL stock scrapers',
|
||||
format('delivery_date on %s%% of stock rows, lead_time on %s%% -> lead_time not derivable',
|
||||
round(100.0 * count(*) FILTER (
|
||||
WHERE warehouse_de_delivery_date IS NOT NULL
|
||||
OR warehouse_global_delivery_date IS NOT NULL) / nullif(count(*),0), 2),
|
||||
round(100.0 * count(lead_time_days) / nullif(count(*),0), 2))
|
||||
FROM stock_observations
|
||||
HAVING round(100.0 * count(*) FILTER (
|
||||
WHERE warehouse_de_delivery_date IS NOT NULL
|
||||
OR warehouse_global_delivery_date IS NOT NULL) / nullif(count(*),0), 2) < 5
|
||||
|
||||
UNION ALL
|
||||
-- 5) reach_label incomplete on a sizeable catalog.
|
||||
SELECT 'warning', 3, 'reach_label_incomplete',
|
||||
vendor,
|
||||
format('%s%% of %s transceivers have NULL reach_label', pct_reach_null, transceivers)
|
||||
FROM v_coverage
|
||||
WHERE transceivers >= 100 AND pct_reach_null >= 20
|
||||
|
||||
UNION ALL
|
||||
-- 6) Future-dated news -> ingest guard candidate.
|
||||
SELECT 'warning', 3, 'future_dated_news',
|
||||
'news_articles',
|
||||
format('%s article(s) dated in the future (max %s) — ingest guard recommended',
|
||||
count(*), max(published_at)::date)
|
||||
FROM news_articles
|
||||
WHERE published_at > now() + interval '2 days'
|
||||
HAVING count(*) > 0
|
||||
)
|
||||
SELECT severity, severity_rank, category, entity, detail
|
||||
FROM alerts
|
||||
ORDER BY severity_rank, category, entity;
|
||||
119
sql/124-refine-stock-monitoring.sql
Normal file
119
sql/124-refine-stock-monitoring.sql
Normal file
@ -0,0 +1,119 @@
|
||||
-- 124-refine-stock-monitoring.sql
|
||||
-- Correction to 123 after reading the ingestion pipeline (packages/scraper).
|
||||
--
|
||||
-- Finding: the 2026-07-05 audit concluded "QSFPTEK 75k stock rows all empty /
|
||||
-- extracts nothing". That is WRONG. QSFPTEK populates quantity_available (100%),
|
||||
-- in_stock (100%) and stock_vendor_ts (100%). It legitimately has NO units_sold
|
||||
-- ("verkauft") counter and NO DE/global warehouse split — those are FS.com-only.
|
||||
-- Every scraper captures the stock signal its source exposes; extraction is not
|
||||
-- broken on any vendor. Only the FS.com-specific delivery-date / lead-time gap and
|
||||
-- the dead/stale scrapers are real.
|
||||
--
|
||||
-- This migration therefore:
|
||||
-- 1) adds pct_qty_available + pct_vendor_ts to v_stock_completeness (proves the
|
||||
-- per-vendor signal is present) and keeps the FS.com-shaped columns for context;
|
||||
-- 2) rekeys the stock_extraction_empty alert to the UNIVERSAL quantitative signal
|
||||
-- (quantity_available OR warehouse qty), so it no longer false-positives vendors
|
||||
-- with a simpler stock model and the alert list can genuinely drain to empty.
|
||||
--
|
||||
-- NON-DESTRUCTIVE: read-only view redefinitions only. Idempotent.
|
||||
|
||||
DROP VIEW IF EXISTS v_data_quality_alerts;
|
||||
DROP VIEW IF EXISTS v_stock_completeness;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- v_stock_completeness (refined): universal signal first, FS.com-specific after.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW v_stock_completeness AS
|
||||
SELECT v.name AS vendor,
|
||||
v.slug AS vendor_slug,
|
||||
count(*) AS stock_rows,
|
||||
max(s."time")::date AS last_obs,
|
||||
-- universal quantitative stock signal (any of these = extraction working)
|
||||
round(100.0 * count(s.quantity_available) / count(*), 1) AS pct_qty_available,
|
||||
round(100.0 * count(*) FILTER (
|
||||
WHERE s.warehouse_de_qty IS NOT NULL
|
||||
OR s.warehouse_global_qty IS NOT NULL) / count(*), 1) AS pct_warehouse_qty,
|
||||
round(100.0 * count(s.stock_vendor_ts) / count(*), 1) AS pct_vendor_ts,
|
||||
-- FS.com-specific enrichments (vendor-model dependent, not universal)
|
||||
round(100.0 * count(s.units_sold) / count(*), 1) AS pct_units_sold,
|
||||
round(100.0 * count(*) FILTER (
|
||||
WHERE s.warehouse_de_delivery_date IS NOT NULL
|
||||
OR s.warehouse_global_delivery_date IS NOT NULL
|
||||
OR s.backorder_estimated_date IS NOT NULL) / count(*), 1) AS pct_delivery_date,
|
||||
round(100.0 * count(s.lead_time_days) / count(*), 1) AS pct_lead_time
|
||||
FROM stock_observations s
|
||||
JOIN vendors v ON v.id = s.source_vendor_id
|
||||
GROUP BY v.id, v.name, v.slug;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- v_data_quality_alerts (refined): stock_extraction_empty now keys on the
|
||||
-- universal quantitative signal instead of FS.com-only fields.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE VIEW v_data_quality_alerts AS
|
||||
WITH alerts AS (
|
||||
-- 1) Dead scraper: had data, stopped delivering > 30 days ago.
|
||||
SELECT 'critical'::text AS severity, 1 AS severity_rank,
|
||||
'scraper_dead'::text AS category,
|
||||
vendor || ' / ' || source_table AS entity,
|
||||
format('no new rows for %s days (last %s); %s rows total',
|
||||
days_since_last, last_observation, total_rows) AS detail
|
||||
FROM v_scraper_health
|
||||
WHERE status = 'dead'
|
||||
|
||||
UNION ALL
|
||||
-- 2) Stale scraper: 8-30 days since last row.
|
||||
SELECT 'warning', 3, 'scraper_stale',
|
||||
vendor || ' / ' || source_table,
|
||||
format('no new rows for %s days (last %s)', days_since_last, last_observation)
|
||||
FROM v_scraper_health
|
||||
WHERE status = 'stale'
|
||||
|
||||
UNION ALL
|
||||
-- 3) Rows written but NO quantitative stock signal at all (qty + warehouse both empty)
|
||||
-- on an otherwise-alive scraper. This is the real "extraction is a no-op" case.
|
||||
SELECT 'high', 2, 'stock_extraction_empty',
|
||||
vendor || ' / stock_observations',
|
||||
format('%s rows but quantity_available=%s%% and warehouse_qty=%s%% populated',
|
||||
stock_rows, pct_qty_available, pct_warehouse_qty)
|
||||
FROM v_stock_completeness
|
||||
WHERE stock_rows >= 500
|
||||
AND pct_qty_available = 0
|
||||
AND pct_warehouse_qty = 0
|
||||
AND last_obs >= now()::date - 30
|
||||
|
||||
UNION ALL
|
||||
-- 4) Fleet-wide delivery-date / lead-time gap -> lead_time_days not derivable.
|
||||
SELECT 'high', 2, 'delivery_date_gap',
|
||||
'ALL stock scrapers',
|
||||
format('delivery_date on %s%% of stock rows, lead_time on %s%% -> lead_time not derivable',
|
||||
round(100.0 * count(*) FILTER (
|
||||
WHERE warehouse_de_delivery_date IS NOT NULL
|
||||
OR warehouse_global_delivery_date IS NOT NULL) / nullif(count(*),0), 2),
|
||||
round(100.0 * count(lead_time_days) / nullif(count(*),0), 2))
|
||||
FROM stock_observations
|
||||
HAVING round(100.0 * count(*) FILTER (
|
||||
WHERE warehouse_de_delivery_date IS NOT NULL
|
||||
OR warehouse_global_delivery_date IS NOT NULL) / nullif(count(*),0), 2) < 5
|
||||
|
||||
UNION ALL
|
||||
-- 5) reach_label incomplete on a sizeable catalog.
|
||||
SELECT 'warning', 3, 'reach_label_incomplete',
|
||||
vendor,
|
||||
format('%s%% of %s transceivers have NULL reach_label', pct_reach_null, transceivers)
|
||||
FROM v_coverage
|
||||
WHERE transceivers >= 100 AND pct_reach_null >= 20
|
||||
|
||||
UNION ALL
|
||||
-- 6) Future-dated news -> ingest guard candidate.
|
||||
SELECT 'warning', 3, 'future_dated_news',
|
||||
'news_articles',
|
||||
format('%s article(s) dated in the future (max %s) — ingest guard recommended',
|
||||
count(*), max(published_at)::date)
|
||||
FROM news_articles
|
||||
WHERE published_at > now() + interval '2 days'
|
||||
HAVING count(*) > 0
|
||||
)
|
||||
SELECT severity, severity_rank, category, entity, detail
|
||||
FROM alerts
|
||||
ORDER BY severity_rank, category, entity;
|
||||
237
sql/125-switch-quality.sql
Normal file
237
sql/125-switch-quality.sql
Normal file
@ -0,0 +1,237 @@
|
||||
-- sql/125-switch-quality.sql
|
||||
-- Switch / compatibility data-quality remediation + monitoring layer.
|
||||
--
|
||||
-- Audit 2026-07-06 (read-only) found systematic errors in the switch database:
|
||||
-- * 5.686+ physically-impossible compatibility rows (transceiver faster than the
|
||||
-- switch's fastest port). Root cause: the form-factor fallback in
|
||||
-- packages/scraper/src/scrapers/flexoptix-compat.ts matched by form factor ONLY,
|
||||
-- ignoring speed -> a 400G-OSFP port matched every OSFP transceiver incl. 1.6T.
|
||||
-- * 420/687 switches with NULL max_speed_gbps (ports_config present -> derivable).
|
||||
-- * 528 transceivers with speed_gbps <= 0 (blocks compat validation; they silently
|
||||
-- pass as "compatible" with everything).
|
||||
-- * coding-pattern coverage as low as ~13% at 100G (vendor_compat = [] for most).
|
||||
--
|
||||
-- DESIGN (why this is safe at the "no errors" bar):
|
||||
-- The naive ceiling (ports_config-derived max) is UNRELIABLE because some switches'
|
||||
-- ports_config is understated (e.g. Cisco C9500-32QC lists 40G but does 100G; NCS 540
|
||||
-- "Z" ports list 10G but are 25G-capable; A9K-20HG-FLEX lists 100G but does 400G).
|
||||
-- Flagging by that ceiling would create FALSE "incompatible" flags on curated rows.
|
||||
-- So the effective ceiling is evidence-based:
|
||||
-- effective_max = GREATEST(stored max_speed_gbps,
|
||||
-- ports_config-derived max,
|
||||
-- fastest curated-compatible transceiver for the switch)
|
||||
-- and ONLY algorithmic 'spec_match' fallback rows are ever auto-flagged; curated
|
||||
-- ('vendor_matrix'/'vendor_compat') rows are never touched (they instead RAISE the
|
||||
-- ceiling, protecting understated switches). Result: 0 curated false positives.
|
||||
--
|
||||
-- Idempotent + safe to re-run. Applied with psql --single-transaction.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 0) Ports-config -> max port speed (pure helper) + evidence-based switch ceiling
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
create or replace function fn_ports_max_gbps(cfg jsonb) returns numeric
|
||||
language sql immutable as $fn$
|
||||
select case when jsonb_typeof(cfg) = 'object'
|
||||
then (select max(substring(k from '([0-9]+)G')::numeric)
|
||||
from jsonb_object_keys(cfg) k)
|
||||
else null end
|
||||
$fn$;
|
||||
|
||||
comment on function fn_ports_max_gbps(jsonb) is
|
||||
'Max port speed (Gbps) parsed from a switch ports_config JSONB object, e.g. {"400G_QSFP-DD":4,"100G_QSFP28":2} -> 400. NULL if config is empty/non-object.';
|
||||
|
||||
create or replace view v_switch_effective_max as
|
||||
select s.id as switch_id,
|
||||
s.max_speed_gbps as stored_max_gbps,
|
||||
fn_ports_max_gbps(s.ports_config) as ports_max_gbps,
|
||||
(select max(t.speed_gbps)
|
||||
from compatibility c join transceivers t on t.id = c.transceiver_id
|
||||
where c.switch_id = s.id and c.status = 'compatible'
|
||||
and c.verification_method in ('vendor_matrix','vendor_compat')
|
||||
and t.speed_gbps > 0) as curated_max_gbps,
|
||||
greatest(
|
||||
coalesce(s.max_speed_gbps, 0),
|
||||
coalesce(fn_ports_max_gbps(s.ports_config), 0),
|
||||
coalesce((select max(t.speed_gbps)
|
||||
from compatibility c join transceivers t on t.id = c.transceiver_id
|
||||
where c.switch_id = s.id and c.status = 'compatible'
|
||||
and c.verification_method in ('vendor_matrix','vendor_compat')
|
||||
and t.speed_gbps > 0), 0)
|
||||
) as effective_max_gbps
|
||||
from switches s;
|
||||
|
||||
comment on view v_switch_effective_max is
|
||||
'Evidence-based max port speed per switch (greatest of stored max_speed_gbps, ports_config-derived, and fastest curated-compatible transceiver). Used to validate compatibility without false-flagging switches whose ports_config is understated.';
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- REMEDIATION A) Backfill max_speed_gbps from ports_config (additive, nulls only).
|
||||
-- Highest leverage: unblocks validation for switches that currently pass no check.
|
||||
-- Only fills NULLs; never overwrites a curated stored value (61/267 stored values
|
||||
-- disagree with ports-derived in both directions -> curated values are preserved).
|
||||
-- Switches with an empty {} ports_config stay NULL (need a datasheet -> see alerts).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
update switches s
|
||||
set max_speed_gbps = fn_ports_max_gbps(s.ports_config)
|
||||
where s.max_speed_gbps is null
|
||||
and fn_ports_max_gbps(s.ports_config) is not null
|
||||
and fn_ports_max_gbps(s.ports_config) > 0;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- REMEDIATION C) Re-derive speed_gbps for transceivers with speed <= 0, HIGH
|
||||
-- confidence only. Speed comes from the manufacturer's standardized speed token
|
||||
-- in the part number (e.g. SFP-10G-SR -> 10, QDD-2X400G -> 800, QSFP-4SFP25G -> 100),
|
||||
-- and is applied ONLY when it is <= the form factor's physical maximum. Parts whose
|
||||
-- token exceeds the form-factor cap (usually a wrong form_factor, e.g. QSFP-400G-AOC
|
||||
-- mislabelled 'SFP') or that have no speed token are LEFT flagged for datasheet work.
|
||||
-- No blind update: FastEthernet "100FX"/"100BASE" (no 'G') never matches.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
with cand as (
|
||||
select id,
|
||||
case
|
||||
when part_number ~* '([0-9]+)X([0-9]+)G'
|
||||
then (regexp_match(part_number,'([0-9]+)X([0-9]+)G','i'))[1]::numeric
|
||||
* (regexp_match(part_number,'([0-9]+)X([0-9]+)G','i'))[2]::numeric
|
||||
when part_number ~* '([0-9]+)SFP([0-9]+)G'
|
||||
then (regexp_match(part_number,'([0-9]+)SFP([0-9]+)G','i'))[1]::numeric
|
||||
* (regexp_match(part_number,'([0-9]+)SFP([0-9]+)G','i'))[2]::numeric
|
||||
when part_number ~* '([0-9]+)G'
|
||||
then (regexp_match(part_number,'([0-9]+)G','i'))[1]::numeric
|
||||
end as derived,
|
||||
case upper(coalesce(form_factor,''))
|
||||
when 'SFP' then 1 when 'SFP+' then 16 when 'SFP28' then 32 when 'SFP56' then 56
|
||||
when 'XFP' then 10 when 'QSFP+' then 40 when 'QSFP28' then 100 when 'QSFP56' then 200
|
||||
when 'QSFP-DD' then 800 when 'QSFP-DD800' then 800
|
||||
when 'OSFP' then 1600 when 'OSFP224' then 1600
|
||||
when 'CFP' then 100 when 'CFP2' then 400 when 'CFP4' then 100 when 'CPAK' then 100
|
||||
else 99999 end as ff_cap
|
||||
from transceivers
|
||||
where speed_gbps is null or speed_gbps <= 0)
|
||||
update transceivers t
|
||||
set speed_gbps = c.derived
|
||||
from cand c
|
||||
where t.id = c.id
|
||||
and c.derived is not null and c.derived > 0
|
||||
and c.derived <= c.ff_cap;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- REMEDIATION B) Flag physically-impossible compatibility rows.
|
||||
-- Only 'spec_match' (algorithmic form-factor fallback) rows are flagged; curated
|
||||
-- rows are never touched. status='incompatible' (constraint-allowed value) removes
|
||||
-- them from every consumer that filters status='compatible' (e.g. queries.ts:302).
|
||||
-- Runs AFTER A + C so it uses backfilled ceilings and corrected transceiver speeds.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
with em as materialized (
|
||||
select switch_id, effective_max_gbps from v_switch_effective_max)
|
||||
update compatibility c
|
||||
set status = 'incompatible',
|
||||
notes = case when c.notes is null or c.notes = ''
|
||||
then 'auto(125): transceiver speed exceeds switch max port speed'
|
||||
else c.notes || ' | auto(125): transceiver speed exceeds switch max port speed' end
|
||||
from em e, transceivers t
|
||||
where e.switch_id = c.switch_id
|
||||
and t.id = c.transceiver_id
|
||||
and c.status = 'compatible'
|
||||
and c.verification_method = 'spec_match'
|
||||
and e.effective_max_gbps > 0
|
||||
and t.speed_gbps is not null
|
||||
and t.speed_gbps > e.effective_max_gbps;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- MONITORING VIEWS (read-only). v_switch_quality_alerts is the task list.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- 1) Impossible rows still CLAIMING compatibility (drains to 0 after remediation B;
|
||||
-- lights up again if a future scrape inserts a new impossible spec_match row).
|
||||
create or replace view v_compat_speed_errors as
|
||||
select c.id as compat_id,
|
||||
s.model as switch,
|
||||
e.effective_max_gbps as switch_max_gbps,
|
||||
t.part_number as transceiver,
|
||||
t.speed_gbps as transceiver_gbps,
|
||||
round(t.speed_gbps / nullif(e.effective_max_gbps,0), 1) as over_ratio
|
||||
from compatibility c
|
||||
join v_switch_effective_max e on e.switch_id = c.switch_id
|
||||
join switches s on s.id = c.switch_id
|
||||
join transceivers t on t.id = c.transceiver_id
|
||||
where c.status = 'compatible'
|
||||
and c.verification_method = 'spec_match'
|
||||
and e.effective_max_gbps > 0
|
||||
and t.speed_gbps is not null
|
||||
and t.speed_gbps > e.effective_max_gbps;
|
||||
|
||||
-- 2) Understated switch port data: a CURATED source pairs a transceiver faster than the
|
||||
-- switch's stored/ports max. These are NOT errors to auto-fix -- they flag switches
|
||||
-- whose ports_config needs datasheet enrichment (or a wrong curated claim to review).
|
||||
create or replace view v_switch_understated_ports as
|
||||
select c.id as compat_id,
|
||||
v.name as manufacturer,
|
||||
s.model as switch,
|
||||
s.max_speed_gbps as stored_max_gbps,
|
||||
fn_ports_max_gbps(s.ports_config) as ports_max_gbps,
|
||||
t.part_number as transceiver,
|
||||
t.speed_gbps as transceiver_gbps,
|
||||
c.verification_method
|
||||
from compatibility c
|
||||
join switches s on s.id = c.switch_id
|
||||
join vendors v on v.id = s.vendor_id
|
||||
join transceivers t on t.id = c.transceiver_id
|
||||
where c.status = 'compatible'
|
||||
and c.verification_method in ('vendor_matrix','vendor_compat')
|
||||
and t.speed_gbps is not null
|
||||
and (coalesce(s.max_speed_gbps,0) > 0 or coalesce(fn_ports_max_gbps(s.ports_config),0) > 0)
|
||||
and t.speed_gbps > greatest(coalesce(s.max_speed_gbps,0), coalesce(fn_ports_max_gbps(s.ports_config),0));
|
||||
|
||||
-- 3) Coding-pattern coverage per speed tier (module only) -- data-SOURCE gap, pipeline task.
|
||||
create or replace view v_coding_coverage as
|
||||
select t.speed_gbps::numeric as tier,
|
||||
count(*)::int as modules,
|
||||
count(*) filter (where jsonb_array_length(coalesce(t.vendor_compat,'[]'::jsonb)) > 0)::int as with_coding,
|
||||
round(100.0 * count(*) filter (where jsonb_array_length(coalesce(t.vendor_compat,'[]'::jsonb)) > 0)
|
||||
/ count(*), 1) as coverage_pct
|
||||
from transceivers t
|
||||
where t.product_type = 'module' and t.speed_gbps >= 100
|
||||
group by 1 order by 1;
|
||||
|
||||
-- 4) Transceivers with an impossible speed (<=0). 100 Mbps (0.1) and FC/SONET rates
|
||||
-- (0.155/0.622/2.488/8/8.5/14/28/32) are legitimate and intentionally NOT flagged.
|
||||
create or replace view v_transceiver_speed_errors as
|
||||
select id, part_number, speed_gbps, form_factor
|
||||
from transceivers
|
||||
where speed_gbps is null or speed_gbps <= 0;
|
||||
|
||||
-- 5) Switches that cannot validate compatibility (missing max_speed / total_ports).
|
||||
create or replace view v_switch_completeness as
|
||||
select s.id, v.name as manufacturer, s.model, s.max_speed_gbps, s.total_ports,
|
||||
(jsonb_typeof(s.ports_config) = 'object' and s.ports_config <> '{}'::jsonb) as has_ports_config
|
||||
from switches s join vendors v on v.id = s.vendor_id
|
||||
where s.max_speed_gbps is null or s.total_ports is null;
|
||||
|
||||
-- 6) One-line alerts = the switch/coding data-quality task list.
|
||||
create or replace view v_switch_quality_alerts as
|
||||
select 'compatibility: ' || count(*) || ' physically-impossible spec_match rows (transceiver faster than switch)' as alert,
|
||||
'critical' as severity from v_compat_speed_errors
|
||||
union all
|
||||
select 'coding matrix: ' || tier || 'G only ' || coverage_pct || '% populated (' || (modules - with_coding) || ' modules without coding patterns)',
|
||||
case when coverage_pct < 25 then 'high' else 'warning' end
|
||||
from v_coding_coverage where coverage_pct < 70
|
||||
union all
|
||||
select 'transceivers: ' || count(*) || ' with speed_gbps <= 0 (need form_factor/datasheet)', 'high'
|
||||
from v_transceiver_speed_errors
|
||||
union all
|
||||
select 'switches: ' || count(*) || ' missing max_speed_gbps (empty ports_config -> needs datasheet)', 'warning'
|
||||
from v_switch_completeness where max_speed_gbps is null
|
||||
union all
|
||||
select 'switches: ' || count(*) || ' with understated port data (curated compat faster than ports_config)', 'warning'
|
||||
from v_switch_understated_ports;
|
||||
|
||||
-- Usage: select * from v_switch_quality_alerts order by severity;
|
||||
-- select * from v_compat_speed_errors order by over_ratio desc limit 50;
|
||||
-- select * from v_switch_understated_ports order by transceiver_gbps desc;
|
||||
|
||||
insert into _migrations (filename, applied_at)
|
||||
select '125-switch-quality.sql', now()
|
||||
where not exists (select 1 from _migrations where filename = '125-switch-quality.sql');
|
||||
98
sql/126-switch-quality-enforcement.sql
Normal file
98
sql/126-switch-quality-enforcement.sql
Normal file
@ -0,0 +1,98 @@
|
||||
-- sql/126-switch-quality-enforcement.sql
|
||||
-- Durable enforcement + on-demand validation for the switch/compatibility DB.
|
||||
-- Complements sql/125 (which cleaned the data): makes the "no physically-impossible
|
||||
-- compatibility rows" invariant structural, so it survives a scraper regression.
|
||||
--
|
||||
-- Design (same "no false positives" rule as 125):
|
||||
-- * The guard NEVER touches curated rows (vendor_matrix/vendor_compat/tested/datasheet)
|
||||
-- -- those are trusted and, on understated switches, are the source of truth.
|
||||
-- * For algorithmic rows (spec_match/community), if the transceiver is faster than the
|
||||
-- switch's fastest known port (stored max_speed_gbps OR ports_config-derived), the row
|
||||
-- is FORCED to status='incompatible' rather than dropped -- it stays recorded but is
|
||||
-- excluded by every consumer that filters status='compatible' (e.g. queries.ts:302).
|
||||
-- * Forcing (not RETURN NULL) means the guard never conflicts with the 125 flagging
|
||||
-- UPDATE and never silently loses data. Idempotent; safe to re-run.
|
||||
--
|
||||
-- Depends on sql/125 (fn_ports_max_gbps + the v_* views). Apply after 125.
|
||||
|
||||
-- ── Guard: keep physically-impossible algorithmic matches out of 'compatible' ──
|
||||
create or replace function compat_speed_guard() returns trigger
|
||||
language plpgsql as $$
|
||||
declare
|
||||
sw_ceiling numeric;
|
||||
tx_speed numeric;
|
||||
begin
|
||||
-- Trust curated/vendor/tested sources; they define reality on understated switches.
|
||||
if NEW.verification_method in ('vendor_matrix','vendor_compat','tested','datasheet') then
|
||||
return NEW;
|
||||
end if;
|
||||
if NEW.status is distinct from 'compatible' then
|
||||
return NEW; -- only guard rows claiming compatibility
|
||||
end if;
|
||||
|
||||
select greatest(coalesce(s.max_speed_gbps,0), coalesce(fn_ports_max_gbps(s.ports_config),0))
|
||||
into sw_ceiling
|
||||
from switches s where s.id = NEW.switch_id;
|
||||
|
||||
select t.speed_gbps into tx_speed
|
||||
from transceivers t where t.id = NEW.transceiver_id;
|
||||
|
||||
if sw_ceiling is not null and sw_ceiling > 0
|
||||
and tx_speed is not null and tx_speed > sw_ceiling then
|
||||
NEW.status := 'incompatible';
|
||||
if NEW.notes is null or NEW.notes = '' then
|
||||
NEW.notes := 'auto(126): transceiver speed exceeds switch max port speed';
|
||||
elsif position('auto(12' in NEW.notes) = 0 then
|
||||
NEW.notes := NEW.notes || ' | auto(126): transceiver speed exceeds switch max port speed';
|
||||
end if;
|
||||
end if;
|
||||
return NEW;
|
||||
end $$;
|
||||
|
||||
comment on function compat_speed_guard() is
|
||||
'BEFORE INSERT/UPDATE guard on compatibility: forces algorithmic (non-curated) rows whose transceiver is faster than the switch max port speed to status=incompatible. Never alters curated rows.';
|
||||
|
||||
drop trigger if exists trg_compat_speed_guard on compatibility;
|
||||
create trigger trg_compat_speed_guard
|
||||
before insert or update on compatibility
|
||||
for each row execute function compat_speed_guard();
|
||||
|
||||
-- ── Validator: prove switch-DB quality on demand (CI / cron) ──────────────────
|
||||
-- Hard invariants have enforced=true; data-source gaps (coverage, datasheet) are
|
||||
-- reported but enforced=false so they don't fail the gate before the data exists.
|
||||
-- CI gate: select bool_and(pass) from check_switch_quality() where enforced; -- must be true
|
||||
create or replace function check_switch_quality()
|
||||
returns table(check_name text, detail text, pass boolean, enforced boolean)
|
||||
language sql stable as $$
|
||||
select 'no_impossible_spec_match_compat',
|
||||
(select count(*) from v_compat_speed_errors)::text || ' impossible rows',
|
||||
(select count(*) from v_compat_speed_errors) = 0,
|
||||
true
|
||||
union all
|
||||
select 'coding_coverage_100G_ge_50pct',
|
||||
coalesce((select coverage_pct from v_coding_coverage where tier = 100), 0)::text || '%',
|
||||
coalesce((select coverage_pct from v_coding_coverage where tier = 100), 0) >= 50,
|
||||
false
|
||||
union all
|
||||
select 'transceivers_speed_positive',
|
||||
(select count(*) from v_transceiver_speed_errors)::text || ' with speed<=0',
|
||||
(select count(*) from v_transceiver_speed_errors) = 0,
|
||||
false
|
||||
union all
|
||||
select 'switches_have_max_speed',
|
||||
(select count(*) from v_switch_completeness where max_speed_gbps is null)::text || ' missing',
|
||||
(select count(*) from v_switch_completeness where max_speed_gbps is null) = 0,
|
||||
false
|
||||
union all
|
||||
select 'no_understated_switch_ports',
|
||||
(select count(*) from v_switch_understated_ports)::text || ' understated',
|
||||
(select count(*) from v_switch_understated_ports) = 0,
|
||||
false;
|
||||
$$;
|
||||
|
||||
comment on function check_switch_quality() is
|
||||
'Switch-DB quality proof. select bool_and(pass) from check_switch_quality() where enforced; must be true (no physically-impossible compatibility rows). enforced=false rows track datasheet/data-source gaps.';
|
||||
|
||||
insert into _migrations (filename, applied_at)
|
||||
select '126-switch-quality-enforcement.sql', now()
|
||||
where not exists (select 1 from _migrations where filename = '126-switch-quality-enforcement.sql');
|
||||
32
sql/127-news-future-date-guard.sql
Normal file
32
sql/127-news-future-date-guard.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- sql/127-news-future-date-guard.sql
|
||||
-- News published_at cannot be in the future: an article cannot be published after
|
||||
-- we scraped it. One future-dated row found 2026-07-06 (The Next Platform, pub
|
||||
-- 2026-07-15 but created/scraped 2026-06-19). One-time correction + ingest guard so
|
||||
-- it cannot recur (a CHECK with now() is not immutable/allowed -> BEFORE trigger).
|
||||
-- Idempotent; safe to re-run.
|
||||
|
||||
-- One-time: clamp any future published_at to the scrape date (created_at), else now().
|
||||
update news_articles
|
||||
set published_at = least(published_at, coalesce(created_at, now()))
|
||||
where published_at is not null and published_at > now();
|
||||
|
||||
create or replace function news_published_at_guard() returns trigger
|
||||
language plpgsql as $$
|
||||
begin
|
||||
if NEW.published_at is not null and NEW.published_at > now() then
|
||||
NEW.published_at := least(coalesce(NEW.created_at, now()), now());
|
||||
end if;
|
||||
return NEW;
|
||||
end $$;
|
||||
|
||||
comment on function news_published_at_guard() is
|
||||
'BEFORE INSERT/UPDATE on news_articles: clamps a future published_at to the scrape date (created_at) or now(). Prevents future-dated news polluting the feed.';
|
||||
|
||||
drop trigger if exists trg_news_published_at_guard on news_articles;
|
||||
create trigger trg_news_published_at_guard
|
||||
before insert or update on news_articles
|
||||
for each row execute function news_published_at_guard();
|
||||
|
||||
insert into _migrations (filename, applied_at)
|
||||
select '127-news-future-date-guard.sql', now()
|
||||
where not exists (select 1 from _migrations where filename = '127-news-future-date-guard.sql');
|
||||
75
sql/128-url-normalize-and-enrichment-worklist.sql
Normal file
75
sql/128-url-normalize-and-enrichment-worklist.sql
Normal file
@ -0,0 +1,75 @@
|
||||
-- sql/128-url-normalize-and-enrichment-worklist.sql
|
||||
-- Foundation for the datasheet-enrichment pipeline (Lever 3):
|
||||
-- (1) Fix malformed double-prefixed URLs (a scraper concatenated the base host in
|
||||
-- front of an already-absolute URL, e.g. "https://www.mouser.dehttps://www.mouser.de/…").
|
||||
-- 503 rows affected (details_source_url 373, product_page_url 83, image_url 47).
|
||||
-- These cannot be fetched, so they block any enrichment run.
|
||||
-- (2) A guard so the ingest bug cannot re-introduce them.
|
||||
-- (3) v_datasheet_enrichment_worklist — the pipeline's input: every transceiver/switch
|
||||
-- that needs a field filled from source, with its best source URL + type.
|
||||
-- Idempotent; safe to re-run. The regex only matches a second scheme that appears
|
||||
-- BEFORE the first path slash, so legitimate URLs with an encoded URL in the query string
|
||||
-- (…?url=https://…) are never touched.
|
||||
|
||||
create or replace function fn_strip_double_url_prefix(u text) returns text
|
||||
language sql immutable as $$
|
||||
select case when u ~ '^https?://[^/]*https?://'
|
||||
then regexp_replace(u, '^https?://[^/]*(https?://)', '\1')
|
||||
else u end
|
||||
$$;
|
||||
|
||||
comment on function fn_strip_double_url_prefix(text) is
|
||||
'Removes a duplicated leading scheme+host from a URL, e.g. https://a.comhttps://a.com/x -> https://a.com/x. Only when the 2nd scheme precedes the first path slash (so ?url=https://… is safe).';
|
||||
|
||||
-- (1) one-time cleanup
|
||||
update transceivers set
|
||||
details_source_url = fn_strip_double_url_prefix(details_source_url),
|
||||
product_page_url = fn_strip_double_url_prefix(product_page_url),
|
||||
image_url = fn_strip_double_url_prefix(image_url)
|
||||
where details_source_url ~ '^https?://[^/]*https?://'
|
||||
or product_page_url ~ '^https?://[^/]*https?://'
|
||||
or image_url ~ '^https?://[^/]*https?://';
|
||||
|
||||
-- (2) ingest guard
|
||||
create or replace function transceiver_url_normalize() returns trigger
|
||||
language plpgsql as $$
|
||||
begin
|
||||
NEW.details_source_url := fn_strip_double_url_prefix(NEW.details_source_url);
|
||||
NEW.product_page_url := fn_strip_double_url_prefix(NEW.product_page_url);
|
||||
NEW.image_url := fn_strip_double_url_prefix(NEW.image_url);
|
||||
NEW.datasheet_url := fn_strip_double_url_prefix(NEW.datasheet_url);
|
||||
return NEW;
|
||||
end $$;
|
||||
|
||||
drop trigger if exists trg_transceiver_url_normalize on transceivers;
|
||||
create trigger trg_transceiver_url_normalize
|
||||
before insert or update on transceivers
|
||||
for each row execute function transceiver_url_normalize();
|
||||
|
||||
-- (3) enrichment worklist — pipeline input
|
||||
create or replace view v_datasheet_enrichment_worklist as
|
||||
select 'switch'::text as entity, s.id as entity_id, v.name as vendor, s.model as item,
|
||||
'ports_config+max_speed'::text as need,
|
||||
coalesce(s.datasheet_url, s.product_page_url) as source_url,
|
||||
case when s.datasheet_url is not null then 'datasheet'
|
||||
when s.product_page_url is not null then 'product_page'
|
||||
else 'none' end as source_type
|
||||
from switches s join vendors v on v.id = s.vendor_id
|
||||
where (s.ports_config is null or s.ports_config = '{}'::jsonb) and s.max_speed_gbps is null
|
||||
union all
|
||||
select 'transceiver', t.id, v.name, t.part_number,
|
||||
'form_factor+speed',
|
||||
coalesce(t.details_source_url, t.datasheet_url, t.product_page_url),
|
||||
case when t.datasheet_url is not null then 'datasheet'
|
||||
when t.details_source_url is not null then 'details_page'
|
||||
when t.product_page_url is not null then 'product_page'
|
||||
else 'none' end
|
||||
from transceivers t join vendors v on v.id = t.vendor_id
|
||||
where t.speed_gbps is null or t.speed_gbps <= 0;
|
||||
|
||||
comment on view v_datasheet_enrichment_worklist is
|
||||
'Input for the datasheet-enrichment pipeline: entities whose ports_config/max_speed (switch) or form_factor/speed (transceiver) must be filled from source, with the best available source URL and its type. source_type=none means URL discovery is needed first.';
|
||||
|
||||
insert into _migrations (filename, applied_at)
|
||||
select '128-url-normalize-and-enrichment-worklist.sql', now()
|
||||
where not exists (select 1 from _migrations where filename = '128-url-normalize-and-enrichment-worklist.sql');
|
||||
183
sql/129-orchestrator-control-plane.sql
Normal file
183
sql/129-orchestrator-control-plane.sql
Normal file
@ -0,0 +1,183 @@
|
||||
-- sql/128-orchestrator-control-plane.sql
|
||||
-- The DATA plane for the TIP Autonomous Data-Quality Orchestrator ("tip-dqo").
|
||||
--
|
||||
-- TIP already has a world-class DETECTION layer (sql/123-126: v_scraper_health,
|
||||
-- v_data_quality_alerts, check_switch_quality) and a blind fixed-cadence dispatcher
|
||||
-- (scheduler.ts: boss.send() is never called from any health path). This migration
|
||||
-- adds the tables that let a controller close the loop DETECT -> PRIORITIZE ->
|
||||
-- DISPATCH -> VERIFY -> RECHECK against DATA freshness, with DB-ENFORCED guardrails
|
||||
-- (the ledger is the single source of truth for cooldown/budget/backoff/breaker, so
|
||||
-- the loop physically cannot hammer a source even under concurrent ticks).
|
||||
--
|
||||
-- Read-only against the existing quality views; additive only (no existing object is
|
||||
-- altered). Idempotent (IF NOT EXISTS / CREATE OR REPLACE / ON CONFLICT DO NOTHING).
|
||||
-- Applied by scripts/migrate.ts as the next numbered migration after 127.
|
||||
|
||||
-- ── 1) source_registry — the signal->runner map + per-source SLA + guardrail contract.
|
||||
-- One row per (vendor, source_table) the loop may act on, PLUS the '__global__'
|
||||
-- kill-switch / global-budget row. root_cause_class is LOAD-BEARING: only
|
||||
-- 'staleness' rows are ever auto-dispatched; 'code_fix'/'blocked' rows escalate to a
|
||||
-- human instead of looping retries (re-scraping cannot fix a silent write-path bug).
|
||||
create table if not exists source_registry (
|
||||
source_key text primary key, -- 'atgbics/stock_observations' | '__global__'
|
||||
vendor text, -- matches v_scraper_health.vendor
|
||||
source_table text, -- matches v_scraper_health.source_table
|
||||
queue_names text[] not null default '{}', -- pg-boss queues to dispatch
|
||||
value_weight numeric not null default 1, -- business priority multiplier
|
||||
warn_days int not null default 8,
|
||||
dead_days int not null default 30,
|
||||
cooldown_minutes int not null default 180, -- >= pricing cadence (2h) so no in-flight re-fire
|
||||
budget_per_day int not null default 4,
|
||||
max_backoff_hours int not null default 24,
|
||||
circuit_breaker_strikes int not null default 3,
|
||||
root_cause_class text not null default 'staleness'
|
||||
check (root_cause_class in ('staleness','code_fix','blocked')),
|
||||
profile text not null default 'erik-safe',
|
||||
enabled boolean not null default true,
|
||||
notes text,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ── 2) self_heal_dispatch — the append-only dispatch LEDGER. Single source of truth for
|
||||
-- every guardrail (cooldown/budget/backoff/breaker) and the plan/dispatch audit trail.
|
||||
-- strikes carries the running no-progress count as of this row's verification.
|
||||
create table if not exists self_heal_dispatch (
|
||||
id bigserial primary key,
|
||||
source_key text not null,
|
||||
queues text[] not null default '{}',
|
||||
reason text,
|
||||
mode text not null check (mode in ('plan','dispatch')),
|
||||
rows_before bigint,
|
||||
last_obs_before date,
|
||||
dispatched_at timestamptz not null default now(),
|
||||
checked_at timestamptz,
|
||||
rows_after bigint,
|
||||
last_obs_after date,
|
||||
outcome text not null default 'pending'
|
||||
check (outcome in ('pending','healed','ineffective','escalated','skipped')),
|
||||
strikes int not null default 0,
|
||||
run_id text
|
||||
);
|
||||
create index if not exists idx_self_heal_dispatch_source_time
|
||||
on self_heal_dispatch (source_key, dispatched_at desc);
|
||||
create index if not exists idx_self_heal_dispatch_pending
|
||||
on self_heal_dispatch (outcome, dispatched_at) where outcome = 'pending';
|
||||
|
||||
-- ── 3) scraper_run_log — the ran-but-wrote-0 detector. Lets VERIFY distinguish a silent
|
||||
-- write-path break (job 'completed', 0 rows written — the ATGBICS-stock signature)
|
||||
-- from a legitimately-no-new-rows run. Built here; scrapers can emit into it over time.
|
||||
-- VERIFY v1 falls back to v_scraper_health freshness when a source has no run_log rows.
|
||||
create table if not exists scraper_run_log (
|
||||
id bigserial primary key,
|
||||
vendor text,
|
||||
table_name text,
|
||||
run_at timestamptz not null default now(),
|
||||
rows_written int not null default 0,
|
||||
source text, -- who triggered it (e.g. 'dqo:self-heal', 'cron')
|
||||
run_id text
|
||||
);
|
||||
create index if not exists idx_scraper_run_log_vendor_time
|
||||
on scraper_run_log (vendor, table_name, run_at desc);
|
||||
|
||||
-- ── 4) acknowledged_exceptions — delta-gate suppression so day-1 known-open failures
|
||||
-- (528 speed<=0, ~3050 units_sold, 78% coding template garbage) NEVER page. The
|
||||
-- escalation ladder only fires for entities NOT covered by an active ack.
|
||||
create table if not exists acknowledged_exceptions (
|
||||
id bigserial primary key,
|
||||
check_name text, -- v_data_quality_alerts.category or a check_switch_quality name
|
||||
source_key text, -- optional: scope the ack to one source
|
||||
reason text not null,
|
||||
task_ref text, -- Gitea issue / spawned-task id
|
||||
ack_until timestamptz, -- null = indefinite
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- ── 5) v_dqo_targets — the single ranked SELECT the controller reads for DETECT+PRIORITIZE.
|
||||
-- Joins the live freshness signal (v_scraper_health, keyed off observation TABLES, not
|
||||
-- pg-boss job cadence) onto the registry, ranks dead-before-stale weighted by business
|
||||
-- value and how far past dead_days. Guardrail filtering (cooldown/budget/backoff/breaker)
|
||||
-- is applied in the controller against the ledger, not here, to keep the view simple.
|
||||
create or replace view v_dqo_targets as
|
||||
select
|
||||
sr.source_key,
|
||||
h.vendor,
|
||||
h.source_table,
|
||||
h.status,
|
||||
h.days_since_last,
|
||||
h.rows_30d,
|
||||
h.last_observation,
|
||||
sr.root_cause_class,
|
||||
sr.queue_names,
|
||||
sr.profile,
|
||||
sr.value_weight,
|
||||
sr.warn_days,
|
||||
sr.dead_days,
|
||||
sr.cooldown_minutes,
|
||||
sr.budget_per_day,
|
||||
sr.max_backoff_hours,
|
||||
sr.circuit_breaker_strikes,
|
||||
round(
|
||||
(case h.status when 'dead' then 3 when 'stale' then 2 else 1 end)::numeric
|
||||
* sr.value_weight
|
||||
* (1 + coalesce(h.days_since_last, 0)::numeric / nullif(sr.dead_days, 0))
|
||||
, 2) as score
|
||||
from v_scraper_health h
|
||||
join source_registry sr
|
||||
on sr.vendor = h.vendor and sr.source_table = h.source_table
|
||||
where h.status in ('stale', 'dead')
|
||||
and sr.enabled
|
||||
and sr.source_key <> '__global__'
|
||||
order by
|
||||
(case h.status when 'dead' then 3 when 'stale' then 2 else 1 end) desc,
|
||||
score desc;
|
||||
|
||||
-- ── 6) SEED — the global kill-switch/budget row + the known EXPECTED_VENDORS sources.
|
||||
-- root_cause_class is HAND-CURATED from the 2026-07-06 gap analysis, not guessed:
|
||||
-- ATGBICS/stock = code_fix (price is the single most-alive source yet stock writes
|
||||
-- 0 rows/54d = silent write-path break; escalate, never loop)
|
||||
-- Flexoptix/stock= staleness (price+catalog sync work; a re-scrape can plausibly heal it
|
||||
-- — the first source to arm out of plan-mode)
|
||||
-- Every other source defaults to 'staleness'. Extra (vendor, source_table) rows that do
|
||||
-- not match a live v_scraper_health row are harmless — the join just yields no target.
|
||||
insert into source_registry
|
||||
(source_key, vendor, source_table, queue_names, value_weight, root_cause_class, profile, notes)
|
||||
values
|
||||
-- global kill-switch + global daily dispatch ceiling (belt-and-suspenders over per-source budget)
|
||||
('__global__', null, null, '{}', 1, 'staleness', 'erik-safe',
|
||||
'Global control row: enabled=false disarms the entire loop; budget_per_day is the global daily dispatch ceiling.'),
|
||||
|
||||
-- stock sources (higher value_weight — stock freshness is core to sourcing)
|
||||
('atgbics/stock_observations', 'ATGBICS', 'stock_observations', '{scrape:pricing:atgbics}', 1.5, 'code_fix', 'erik-safe',
|
||||
'Silent stock write-path break: price fresh, stock 0 rows/54d. Escalate to a human; a re-scrape cannot fix a code bug.'),
|
||||
('flexoptix/stock_observations', 'Flexoptix', 'stock_observations', '{scrape:pricing:flexoptix}', 1.5, 'staleness', 'erik-safe',
|
||||
'First source to arm out of plan-mode: lowest-risk staleness, erik-safe queue.'),
|
||||
('fibermall/stock_observations', 'FiberMall', 'stock_observations', '{scrape:pricing:fibermall}', 1.5, 'staleness', 'erik-safe', null),
|
||||
('qsfptek/stock_observations', 'QSFPTEK', 'stock_observations', '{scrape:pricing:qsfptek}', 1.5, 'staleness', 'erik-safe',
|
||||
'The only currently-live stock writer — the reference for a working stock-write path.'),
|
||||
('naddod/stock_observations', 'NADDOD', 'stock_observations', '{scrape:pricing:naddod}', 1.5, 'staleness', 'erik-safe', null),
|
||||
('gbics/stock_observations', 'GBICS', 'stock_observations', '{scrape:pricing:gbics}', 1.5, 'staleness', 'erik-safe',
|
||||
'Queue not in ERIK_SAFE_QUEUES — needs pi-fetch/proxmox profile to actually dispatch.'),
|
||||
('sfpcables/stock_observations', 'SFPcables', 'stock_observations', '{scrape:pricing:sfpcables}', 1.5, 'staleness', 'erik-safe',
|
||||
'Queue not in ERIK_SAFE_QUEUES — needs pi-fetch/proxmox profile to actually dispatch.'),
|
||||
|
||||
-- price sources
|
||||
('atgbics/price_observations', 'ATGBICS', 'price_observations', '{scrape:pricing:atgbics}', 1.0, 'staleness', 'erik-safe', null),
|
||||
('flexoptix/price_observations', 'Flexoptix', 'price_observations', '{scrape:pricing:flexoptix}', 1.0, 'staleness', 'erik-safe', null),
|
||||
('fibermall/price_observations', 'FiberMall', 'price_observations', '{scrape:pricing:fibermall}', 1.0, 'staleness', 'erik-safe', null),
|
||||
('qsfptek/price_observations', 'QSFPTEK', 'price_observations', '{scrape:pricing:qsfptek}', 1.0, 'staleness', 'erik-safe', null),
|
||||
('naddod/price_observations', 'NADDOD', 'price_observations', '{scrape:pricing:naddod}', 1.0, 'staleness', 'erik-safe', null),
|
||||
('gbics/price_observations', 'GBICS', 'price_observations', '{scrape:pricing:gbics}', 1.0, 'staleness', 'erik-safe',
|
||||
'Queue not in ERIK_SAFE_QUEUES — needs pi-fetch/proxmox profile to actually dispatch.'),
|
||||
('sfpcables/price_observations', 'SFPcables', 'price_observations', '{scrape:pricing:sfpcables}', 1.0, 'staleness', 'erik-safe',
|
||||
'Queue not in ERIK_SAFE_QUEUES — needs pi-fetch/proxmox profile to actually dispatch.'),
|
||||
('10gtek/price_observations', '10Gtek', 'price_observations', '{scrape:pricing:10gtek}', 1.0, 'staleness', 'proxmox-heavy',
|
||||
'scrape:pricing:10gtek is a HEAVY queue — only the proxmox-heavy profile dispatches it.')
|
||||
on conflict (source_key) do nothing;
|
||||
|
||||
-- set the global daily dispatch ceiling on the __global__ row (soak default: 20/day)
|
||||
update source_registry set budget_per_day = 20 where source_key = '__global__';
|
||||
|
||||
-- Proof (read anytime):
|
||||
-- select * from v_dqo_targets; -- what the loop would act on right now
|
||||
-- select enabled from source_registry where source_key='__global__'; -- kill-switch state
|
||||
-- select source_key, outcome, mode, dispatched_at from self_heal_dispatch order by id desc limit 20;
|
||||
36
sql/130-adapter-pi-topology.sql
Normal file
36
sql/130-adapter-pi-topology.sql
Normal file
@ -0,0 +1,36 @@
|
||||
-- sql/130-adapter-pi-topology.sql
|
||||
-- Teach the TIP Adapter (tip-dqo, the data-quality orchestrator from sql/129) the REAL
|
||||
-- fetch topology, verified 2026-07-06: TIP data is fetched primarily by three residential
|
||||
-- Raspberry Pi 5 nodes — ContextX-Pi01/tashi(.204, 16GB), Pi02/portia(.212, 4GB),
|
||||
-- Pi03/ester(.211, 4GB) — all live pg-boss workers, all now Playwright-capable
|
||||
-- (chromium v1208, launch-verified). NUC(.115) is the scheduler; Erik(.127) holds DB+API.
|
||||
--
|
||||
-- The other session seeded every vendor pricing/stock source as profile='erik-safe'. That
|
||||
-- is wrong for the fetch fleet: Erik is a datacenter IP (blocked by FS.com/ATGBICS & co.),
|
||||
-- and 'erik-safe' only dispatches ERIK_SAFE_QUEUES — so gbics/sfpcables would NEVER dispatch
|
||||
-- (their own notes flagged this). The residential Pis are the correct fetchers.
|
||||
--
|
||||
-- Fix: route all vendor pricing/stock sources to profile='pi-fetch' (recognized by the
|
||||
-- orchestrator: queuesForProfile → all non-heavy/non-discovery queues; cap 10). This is
|
||||
-- CONFIG ONLY — no scraper fires (the loop stays in Plan-Mode until SELF_HEAL_PLAN_ONLY=false).
|
||||
-- 10gtek stays 'proxmox-heavy' (its queue is in HEAVY_QUEUES). __global__ stays 'erik-safe'
|
||||
-- (config row, excluded from v_dqo_targets). Idempotent.
|
||||
--
|
||||
-- OPEN (code-level, belongs to the tip-dqo owner — NOT changed here to avoid colliding with
|
||||
-- their in-flight go-live): the HEAVY Playwright queues (scrape:pricing:fs, prolabs, compat:*)
|
||||
-- are now runnable on the residential Pi cluster too (Pis have Playwright). queuesForProfile
|
||||
-- currently excludes HEAVY from pi-fetch, so heavy IP-blocked sources (FS.com) still can't
|
||||
-- route to the Pis. A code change (let pi-fetch — or a new pi-heavy profile — dispatch HEAVY
|
||||
-- to the residential cluster) would let the cluster take those over from the Mac runners.
|
||||
|
||||
update source_registry
|
||||
set profile = 'pi-fetch',
|
||||
notes = coalesce(notes || ' | ', '')
|
||||
|| 'topology-fix 130 (2026-07-06): residential Pi cluster is the fetcher, not Erik.',
|
||||
updated_at = now()
|
||||
where source_key <> '__global__'
|
||||
and profile = 'erik-safe';
|
||||
|
||||
insert into _migrations (filename, applied_at)
|
||||
select '130-adapter-pi-topology.sql', now()
|
||||
where not exists (select 1 from _migrations where filename = '130-adapter-pi-topology.sql');
|
||||
60
sql/131-wavelength-column-sync.sql
Normal file
60
sql/131-wavelength-column-sync.sql
Normal file
@ -0,0 +1,60 @@
|
||||
-- sql/131 — Wavelength column sync (root-cause fix for equivalence review backlog)
|
||||
--
|
||||
-- Root cause: the enricher populates the structured integer column
|
||||
-- transceivers.wavelength_tx_nm, but the equivalence spec-matcher reads the TEXT
|
||||
-- column transceivers.wavelengths via tip_extract_wavelength_nm(). 27,908 rows had
|
||||
-- wavelength_tx_nm set while wavelengths was NULL, so the matcher could not confirm
|
||||
-- wavelength on either side and dumped ~3,155 genuine equivalences into manual review.
|
||||
--
|
||||
-- Fix (no fabrication — copies existing structured data into the format the matcher
|
||||
-- expects, and keeps it in sync going forward):
|
||||
-- A) one-off backfill of transceivers.wavelengths from wavelength_tx_nm
|
||||
-- B) durable BEFORE-trigger so future ingests stay in sync
|
||||
-- C) promote pending -> auto_approved ONLY where the FULL spec-matcher criteria now
|
||||
-- agree (form_factor + speed + fiber_type + reach-tier + wavelength +/-10nm).
|
||||
-- Everything with a genuine reach-tier / fiber / wavelength difference stays pending.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- A) one-off backfill (existing structured data -> text column the matcher reads)
|
||||
UPDATE transceivers
|
||||
SET wavelengths = wavelength_tx_nm || 'nm'
|
||||
WHERE (wavelengths IS NULL OR wavelengths = '')
|
||||
AND wavelength_tx_nm IS NOT NULL;
|
||||
|
||||
-- B) durable sync trigger for future inserts/updates
|
||||
CREATE OR REPLACE FUNCTION tip_sync_wavelengths_from_tx() RETURNS trigger AS $fn$
|
||||
BEGIN
|
||||
IF (NEW.wavelengths IS NULL OR NEW.wavelengths = '')
|
||||
AND NEW.wavelength_tx_nm IS NOT NULL THEN
|
||||
NEW.wavelengths := NEW.wavelength_tx_nm || 'nm';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_sync_wavelengths ON transceivers;
|
||||
CREATE TRIGGER trg_sync_wavelengths
|
||||
BEFORE INSERT OR UPDATE ON transceivers
|
||||
FOR EACH ROW EXECUTE FUNCTION tip_sync_wavelengths_from_tx();
|
||||
|
||||
-- C) valid promotion: pending -> auto_approved where full spec now confirmed
|
||||
UPDATE transceiver_equivalences e
|
||||
SET status = 'auto_approved',
|
||||
confidence = GREATEST(e.confidence, 0.85),
|
||||
match_notes = COALESCE(e.match_notes,'') || ' | sql/131: full spec confirmed after wavelength column sync',
|
||||
updated_at = now()
|
||||
FROM transceivers fx, transceivers cx
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.competitor_id = cx.id
|
||||
AND e.status = 'pending'
|
||||
AND fx.form_factor = cx.form_factor
|
||||
AND fx.speed_gbps = cx.speed_gbps
|
||||
AND fx.fiber_type IS NOT DISTINCT FROM cx.fiber_type
|
||||
AND fx.reach_meters >= 10 AND cx.reach_meters >= 10
|
||||
AND tip_reach_tier(fx.reach_meters) = tip_reach_tier(cx.reach_meters)
|
||||
AND tip_extract_wavelength_nm(fx.wavelengths) IS NOT NULL
|
||||
AND tip_extract_wavelength_nm(cx.wavelengths) IS NOT NULL
|
||||
AND abs(tip_extract_wavelength_nm(fx.wavelengths) - tip_extract_wavelength_nm(cx.wavelengths)) <= 10;
|
||||
|
||||
COMMIT;
|
||||
74
sql/132-wavelength-authority-normalize.sql
Normal file
74
sql/132-wavelength-authority-normalize.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- sql/132 — Wavelength authority correction + format normalization
|
||||
--
|
||||
-- Follow-up to sql/131. Discovery: transceivers.wavelengths holds the part-derived
|
||||
-- wavelength as a BARE number ("1470") for ~16.8k rows, which tip_extract_wavelength_nm()
|
||||
-- cannot read (it requires the "nm" suffix). Meanwhile wavelength_tx_nm disagrees with
|
||||
-- that authoritative bare number 33% of the time, and 64% of those disagreements are a
|
||||
-- systematic tx_nm=1310 enricher default. So the bare-number column is authoritative;
|
||||
-- tx_nm is not. This migration prefers the authoritative source.
|
||||
--
|
||||
-- 1) normalize authoritative bare-number wavelengths -> "NNNnm" (format only, no value change)
|
||||
-- 2) clean "N/A" text -> NULL
|
||||
-- 3) correct the few CWDM rows sql/131 backfilled with a wrong tx_nm default, using the
|
||||
-- deterministic CWDM wavelength in the part number
|
||||
-- 4) re-promote pending -> auto_approved where the FULL spec gate now agrees on
|
||||
-- authoritative data (gate already protects against tx_nm noise: both sides must
|
||||
-- agree within +/-10nm, so a wrong 1310 default can never auto-approve a real 1470).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) normalize authoritative bare-number wavelengths (preserve value, add unit)
|
||||
UPDATE transceivers
|
||||
SET wavelengths = wavelengths || 'nm'
|
||||
WHERE wavelengths ~ '^\d{3,4}$';
|
||||
|
||||
-- 2) non-values -> NULL (N/A, -, empty, etc.)
|
||||
UPDATE transceivers
|
||||
SET wavelengths = NULL
|
||||
WHERE wavelengths IS NOT NULL
|
||||
AND tip_extract_wavelength_nm(wavelengths) IS NULL
|
||||
AND wavelengths !~ '\d';
|
||||
|
||||
-- 3) correct CWDM rows that sql/131 filled from a wrong tx_nm default:
|
||||
-- use the deterministic CWDM wavelength encoded in the part number
|
||||
UPDATE transceivers
|
||||
SET wavelengths = (regexp_match(part_number,'(12[6-9]0|13[0-9]0|14[0-9]0|15[0-9]0|16[01]0)'))[1] || 'nm'
|
||||
WHERE wavelengths = wavelength_tx_nm || 'nm'
|
||||
AND part_number ~ '(12[6-9]|13[0-9]|14[0-9]|15[0-9]|16[01])0'
|
||||
AND (regexp_match(part_number,'(12[6-9]0|13[0-9]0|14[0-9]0|15[0-9]0|16[01]0)'))[1]::int <> wavelength_tx_nm;
|
||||
|
||||
-- 3b) upgrade the sync trigger so future ingests self-normalize:
|
||||
-- prefer an authoritative bare-number wavelength (add "nm"), fall back to tx_nm only
|
||||
-- when no textual wavelength is present at all.
|
||||
CREATE OR REPLACE FUNCTION tip_sync_wavelengths_from_tx() RETURNS trigger AS $fn$
|
||||
BEGIN
|
||||
IF NEW.wavelengths ~ '^\d{3,4}$' THEN
|
||||
NEW.wavelengths := NEW.wavelengths || 'nm';
|
||||
ELSIF (NEW.wavelengths IS NULL OR NEW.wavelengths = '' OR tip_extract_wavelength_nm(NEW.wavelengths) IS NULL)
|
||||
AND NEW.wavelength_tx_nm IS NOT NULL THEN
|
||||
NEW.wavelengths := NEW.wavelength_tx_nm || 'nm';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
|
||||
-- 4) valid promotion on corrected/authoritative data (same full spec gate as spec-matcher)
|
||||
UPDATE transceiver_equivalences e
|
||||
SET status = 'auto_approved',
|
||||
confidence = GREATEST(e.confidence, 0.85),
|
||||
match_notes = COALESCE(e.match_notes,'') || ' | sql/132: full spec confirmed on authoritative wavelength',
|
||||
updated_at = now()
|
||||
FROM transceivers fx, transceivers cx
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.competitor_id = cx.id
|
||||
AND e.status = 'pending'
|
||||
AND fx.form_factor = cx.form_factor
|
||||
AND fx.speed_gbps = cx.speed_gbps
|
||||
AND fx.fiber_type IS NOT DISTINCT FROM cx.fiber_type
|
||||
AND fx.reach_meters >= 10 AND cx.reach_meters >= 10
|
||||
AND tip_reach_tier(fx.reach_meters) = tip_reach_tier(cx.reach_meters)
|
||||
AND tip_extract_wavelength_nm(fx.wavelengths) IS NOT NULL
|
||||
AND tip_extract_wavelength_nm(cx.wavelengths) IS NOT NULL
|
||||
AND abs(tip_extract_wavelength_nm(fx.wavelengths) - tip_extract_wavelength_nm(cx.wavelengths)) <= 10;
|
||||
|
||||
COMMIT;
|
||||
46
sql/133-reach-substitution-policy.sql
Normal file
46
sql/133-reach-substitution-policy.sql
Normal file
@ -0,0 +1,46 @@
|
||||
-- sql/133 — One-directional reach-tier substitution policy
|
||||
--
|
||||
-- Decision (Rene, 2026-07-07): a higher-reach optic may satisfy a lower-reach
|
||||
-- requirement (e.g. a 40km ER can serve where a 10km LR is asked for), never the
|
||||
-- reverse — the shorter-reach part cannot close the link budget on a longer run.
|
||||
-- ZR/coherent/amplified tiers are explicitly EXCLUDED from this automatic rule:
|
||||
-- those can overload a short-haul receiver without an attenuator, which is a real
|
||||
-- power-budget calculation, not a simple tier comparison. Those stay in manual review.
|
||||
--
|
||||
-- Result is tagged as its own status (compatible_superset), never merged into
|
||||
-- auto_approved/exact-spec matches, so downstream consumers (Fossy/Beo/Pulso, pricing)
|
||||
-- can distinguish "identical spec" from "safe higher-spec substitute" — they usually
|
||||
-- differ in price and are not a like-for-like swap.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- widen the status check (additive — existing values still valid)
|
||||
ALTER TABLE transceiver_equivalences DROP CONSTRAINT transceiver_equivalences_status_check;
|
||||
ALTER TABLE transceiver_equivalences ADD CONSTRAINT transceiver_equivalences_status_check
|
||||
CHECK (status::text = ANY (ARRAY['pending','approved','rejected','auto_approved','compatible_superset']::text[]));
|
||||
|
||||
UPDATE transceiver_equivalences e
|
||||
SET status = 'compatible_superset',
|
||||
confidence = GREATEST(e.confidence, 0.75),
|
||||
match_notes = COALESCE(e.match_notes,'') || ' | sql/133: ' ||
|
||||
CASE WHEN fx.reach_meters > cx.reach_meters
|
||||
THEN 'flexoptix ('||tip_reach_tier(fx.reach_meters)||') covers competitor requirement ('||tip_reach_tier(cx.reach_meters)||')'
|
||||
ELSE 'competitor ('||tip_reach_tier(cx.reach_meters)||') covers flexoptix requirement ('||tip_reach_tier(fx.reach_meters)||')'
|
||||
END || ' — reach-tier substitute, not identical spec',
|
||||
updated_at = now()
|
||||
FROM transceivers fx, transceivers cx
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.competitor_id = cx.id
|
||||
AND e.status = 'pending'
|
||||
AND fx.form_factor = cx.form_factor
|
||||
AND fx.speed_gbps = cx.speed_gbps
|
||||
AND fx.fiber_type IS NOT DISTINCT FROM cx.fiber_type
|
||||
AND fx.reach_meters >= 10 AND cx.reach_meters >= 10
|
||||
AND tip_extract_wavelength_nm(fx.wavelengths) IS NOT NULL
|
||||
AND tip_extract_wavelength_nm(cx.wavelengths) IS NOT NULL
|
||||
AND abs(tip_extract_wavelength_nm(fx.wavelengths) - tip_extract_wavelength_nm(cx.wavelengths)) <= 10
|
||||
AND tip_reach_tier(fx.reach_meters) <> tip_reach_tier(cx.reach_meters)
|
||||
AND tip_reach_tier(fx.reach_meters) <> 'ZR'
|
||||
AND tip_reach_tier(cx.reach_meters) <> 'ZR';
|
||||
|
||||
COMMIT;
|
||||
20
sql/134-transceiver-ff-speed-errors.sql
Normal file
20
sql/134-transceiver-ff-speed-errors.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- sql/134-transceiver-ff-speed-errors.sql
|
||||
-- LUPO-34: data-quality curation view for physically-impossible form_factor x speed
|
||||
-- combinations. An SFP-class module (SFP/SFP+/SFP28/XFP/GBIC/CSFP) tops out at 25-28G,
|
||||
-- so speed_gbps >= 50 on such a form factor is a misclassification (surfaced in Lupo's
|
||||
-- per-SKU price panel as flagged rows). Evidence-based (physics), NOT name-derived.
|
||||
-- Read-only; the exact corrected speed needs the vendor datasheet (data-owner task).
|
||||
-- SFP-DD / SFP112 legitimately carry >=100G and are intentionally NOT flagged.
|
||||
create or replace view v_transceiver_ff_speed_errors as
|
||||
select t.id, v.name as vendor, t.part_number, t.speed_gbps, t.form_factor
|
||||
from transceivers t
|
||||
left join vendors v on v.id = t.vendor_id
|
||||
where upper(coalesce(t.form_factor, '')) in ('SFP', 'SFP+', 'SFP28', 'XFP', 'GBIC', 'CSFP')
|
||||
and t.speed_gbps >= 50;
|
||||
|
||||
comment on view v_transceiver_ff_speed_errors is
|
||||
'LUPO-34: SFP-class parts with an impossible speed (>=50G). Data-quality curation list; exact correction needs the datasheet.';
|
||||
|
||||
-- Usage:
|
||||
-- select count(*) from v_transceiver_ff_speed_errors; -- total to curate
|
||||
-- select vendor, count(*) from v_transceiver_ff_speed_errors group by 1 order by 2 desc;
|
||||
14
sql/135-equivalences-quality-cleanup.sql
Normal file
14
sql/135-equivalences-quality-cleanup.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- Migration 135: Equivalence quality cleanup
|
||||
-- Demotes auto_approved rows that no longer meet quality gates:
|
||||
-- 1. FX product has no standard_name → can't verify match quality
|
||||
-- Applied 2026-07-11 after adding standard_name gate to catalog-reconcile.
|
||||
|
||||
UPDATE transceiver_equivalences e
|
||||
SET status = 'pending', updated_at = NOW()
|
||||
FROM transceivers fx
|
||||
WHERE e.flexoptix_id = fx.id
|
||||
AND e.status = 'auto_approved'
|
||||
AND fx.standard_name IS NULL;
|
||||
|
||||
-- Result: 60,278 demoted from auto_approved → pending
|
||||
-- Remaining auto_approved: 20,838 (all with standard_name)
|
||||
227
sql/135-modulation-fec-enrichment-and-speed-guard.sql
Normal file
227
sql/135-modulation-fec-enrichment-and-speed-guard.sql
Normal file
@ -0,0 +1,227 @@
|
||||
-- 135 Modulation/FEC enrichment + speed plausibility guard for transceivers.
|
||||
--
|
||||
-- Derived and adversarially verified via workflow wf_dc5bab06 (4 optical-standards
|
||||
-- lenses -> synthesis -> 4 skeptical reviewers). All CONFIRMED reviewer findings are
|
||||
-- folded in here: coherent >=100G speed floor (10G-ZR is NRZ, not DCO), form_factor
|
||||
-- DCO/ACO scan (CFP2-DCO evidence lives in form_factor), compact DAC/AOC/AEC suffix
|
||||
-- exclusion, bare-QSFP28-no-token ABSTAINS instead of guessing NRZ, CR4 dropped from
|
||||
-- the NRZ set, anchored MTP/MPO, NON-COHERENT guard, search_path pinned.
|
||||
--
|
||||
-- FILL-ONLY: never overwrites a non-null value, never touches data_confidence='vendor_verified'.
|
||||
-- Idempotent: safe to re-run; a fully enriched table yields 0 rows touched.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Speed plausibility. Flags only UNAMBIGUOUS junk: sub-0.09 fragments (incl. <=0;
|
||||
-- the lowest real optical rate is 100M=0.1) and >1600 (cable-part-number integers
|
||||
-- like 2.0e16). The 0.09..1600 band is deliberately kept intact: it holds legit
|
||||
-- non-Ethernet rates (FC line rates 2.125/4.25/8.5/14.025, SDI 3/6/12, CPRI 1.2288,
|
||||
-- OTN 10.7) that must not be discarded. NULL is treated as NOT implausible.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION tip_speed_is_implausible(v numeric)
|
||||
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
SELECT v IS NOT NULL AND (v < 0.09 OR v > 1600);
|
||||
$$;
|
||||
|
||||
-- Separate, isolated speed sanitizer. Distinct from tip_transceivers_biu() (which
|
||||
-- recomputes product_type/form_factor and never touches speed). Sanitize-only:
|
||||
-- NULLs junk speed on write, never raises, never blocks ingestion.
|
||||
CREATE OR REPLACE FUNCTION tip_speed_plausibility_biu()
|
||||
RETURNS trigger LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
BEGIN
|
||||
IF NEW.speed_gbps IS NOT NULL AND tip_speed_is_implausible(NEW.speed_gbps) THEN
|
||||
NEW.speed_gbps := NULL;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_speed_plausibility ON transceivers;
|
||||
CREATE TRIGGER trg_speed_plausibility
|
||||
BEFORE INSERT OR UPDATE ON transceivers
|
||||
FOR EACH ROW EXECUTE FUNCTION tip_speed_plausibility_biu();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Enrichment scope: real optics only. The part_number cable/trunk veto applies to
|
||||
-- BOTH the product_type='module' and product_type IS NULL branches, so a cable
|
||||
-- mis-classified 'module' upstream still cannot be enriched.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION tip_in_optics_scope(p_product_type text, p_form_factor text, p_part_number text)
|
||||
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
SELECT
|
||||
coalesce(p_product_type,'') NOT IN ('dac','aoc','aec','accessory','switch','nic')
|
||||
-- compact suffix forms (-AOC3M, -DAC1M, -AEC2M), Flexoptix cable schemes (M4.*, S2.*),
|
||||
-- loopbacks, and explicit cable keywords are never optics. MTP/MPO is deliberately
|
||||
-- NOT vetoed here: parallel optics (DR4/SR4/DR8) legitimately carry an -MPO12 connector
|
||||
-- suffix, and real fiber trunks are already caught by ^M[0-9]./^S[0-9]./CABLE.
|
||||
AND coalesce(p_part_number,'') !~* '([-_](DAC|AOC|AEC)([-_0-9]|$)|CAB[-_]|CABLE|FN-CABLE|LOOPBACK|[-_]LB[0-9]|^M[0-9]\.|^S[0-9]\.)'
|
||||
AND (
|
||||
p_product_type = 'module'
|
||||
OR (
|
||||
p_product_type IS NULL
|
||||
AND upper(coalesce(p_form_factor,'')) IN
|
||||
('QSFP28','QSFP56','QSFP-DD','QSFP-DD800','OSFP','OSFP800','CFP2','CFP2-DCO','CFP2-ACO','CFP8','SFP-DD','SFP112','DSFP')
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Coherent detection. Regex-derived triggers are floored at >=100G (100ZR is the
|
||||
-- lowest coherent pluggable; 10G/40G-ZR are NRZ reach optics). A pre-existing
|
||||
-- coherent=true is trusted and NOT speed-floored. NON-COHERENT is excluded.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION tip_is_coherent(p_std text, p_pn text, p_ff text, p_speed numeric, p_coherent boolean)
|
||||
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
SELECT
|
||||
p_coherent IS TRUE
|
||||
OR (
|
||||
p_speed >= 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')||' '||coalesce(p_ff,'')) !~* 'NON-?COHERENT'
|
||||
AND (
|
||||
(coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^A-Za-z0-9])(100|200|300|400|600|800|1600)?G?-?ZR(\+|P)?([^A-Za-z0-9]|$)'
|
||||
OR (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^A-Za-z0-9])DCO([^A-Za-z0-9]|$)'
|
||||
OR (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* 'OPEN-?ZR'
|
||||
OR (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^A-Za-z0-9])COHERENT([^A-Za-z0-9]|$)'
|
||||
OR (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(DP|PM)[-_]?(16QAM|8QAM|64QAM|QPSK)'
|
||||
-- CFP2-DCO/ACO carry the only coherent evidence in form_factor (standard_name ~1% populated).
|
||||
OR upper(coalesce(p_ff,'')) ~ '(DCO|ACO|COHERENT)'
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Modulation. NULL = ABSTAIN (unknown). Order: coherent -> PAM4-by-speed ->
|
||||
-- 100G single-lambda PAM4 -> 100G 4x25 NRZ -> abstain. Bare QSFP28 with no reach
|
||||
-- token abstains (never defaulted to NRZ). (?![0-9]) keeps SR4 from matching SR40
|
||||
-- and DR/FR from matching DR4/FR4.
|
||||
CREATE OR REPLACE FUNCTION tip_derive_modulation(p_std text, p_pn text, p_ff text, p_speed numeric, p_coherent boolean)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
SELECT CASE
|
||||
WHEN tip_is_coherent(p_std, p_pn, p_ff, p_speed, p_coherent) THEN CASE
|
||||
WHEN (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(DP|PM)[-_]?QPSK' THEN 'DP-QPSK'
|
||||
WHEN (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(DP|PM)[-_]?16QAM' THEN 'DP-16QAM'
|
||||
WHEN (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(DP|PM)[-_]?(8QAM|64QAM)' THEN 'Coherent'
|
||||
WHEN p_speed >= 400 THEN 'DP-16QAM' -- 400ZR/800ZR/DCO default
|
||||
WHEN p_speed = 100 THEN 'DP-QPSK' -- 100ZR is QPSK
|
||||
ELSE 'Coherent' -- 200G/300G constellation ambiguous -> generic
|
||||
END
|
||||
WHEN p_speed IN (200,400,800,1600) THEN 'PAM4'
|
||||
WHEN p_speed = 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(DR1?|FR1?|LR1|ER1|SR1|SR2|VR1?)(?![0-9])'
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(SR4|LR4|ER4L?|CWDM4|CLR4|PSM4|4WDM)(?![0-9])'
|
||||
THEN NULL -- conflicting single-lambda AND 4x25 tokens -> abstain
|
||||
WHEN p_speed = 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(DR1?|FR1?|LR1|ER1|SR1|SR2|VR1?)(?![0-9])'
|
||||
THEN 'PAM4'
|
||||
WHEN p_speed = 100
|
||||
AND upper(coalesce(p_ff,'')) IN ('SFP-DD','SFP112','DSFP')
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) !~* '(^|[^0-9A-Za-z])(SR4|LR4|ER4L?|CWDM4|CLR4|PSM4|4WDM)(?![0-9])'
|
||||
THEN 'PAM4'
|
||||
WHEN p_speed = 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(SR4|LR4|ER4L?|CWDM4|CLR4|PSM4|4WDM)(?![0-9])'
|
||||
THEN 'NRZ'
|
||||
ELSE NULL -- bare QSFP28 / no token / sub-100G / non-standard speed -> abstain
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- FEC type. Reads modulation. Abstains (NULL) for 100G NRZ LR4/ER4/CWDM4/CLR4/4WDM
|
||||
-- (no mandatory FEC); that assertion is carried by inbuilt_fec=false, not a fake fec_type.
|
||||
CREATE OR REPLACE FUNCTION tip_derive_fec_type(p_modulation text, p_std text, p_pn text, p_speed numeric)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
SELECT CASE
|
||||
WHEN p_modulation IN ('DP-16QAM','DP-QPSK','Coherent') THEN 'SD-FEC'
|
||||
WHEN p_modulation = 'PAM4' AND p_speed IN (100,200,400,800,1600) THEN 'RS-FEC (RS(544,514) KP4)'
|
||||
WHEN p_modulation = 'NRZ' AND p_speed = 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(SR4|PSM4)(?![0-9])' THEN 'RS-FEC (RS(528,514) CL91)'
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION tip_derive_inbuilt_fec(p_modulation text, p_std text, p_pn text, p_speed numeric)
|
||||
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
SELECT CASE
|
||||
WHEN p_modulation IN ('DP-16QAM','DP-QPSK','Coherent') THEN true
|
||||
WHEN p_modulation = 'PAM4' AND p_speed IN (100,200,400,800,1600) THEN true
|
||||
WHEN p_modulation = 'NRZ' AND p_speed = 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(SR4|PSM4)(?![0-9])' THEN true
|
||||
WHEN p_modulation = 'NRZ' AND p_speed = 100
|
||||
AND (coalesce(p_std,'')||' '||coalesce(p_pn,'')) ~* '(^|[^0-9A-Za-z])(LR4|ER4L?|CWDM4|CLR4|4WDM)(?![0-9])' THEN false
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Idempotent fill-only enrichment. Returns total per-statement rows touched
|
||||
-- (a row filled across all three steps counts up to 3x). Fully enriched -> 0.
|
||||
-- One SELECT call runs all three steps in a single transaction, so APPLY-3 reads
|
||||
-- the modulation APPLY-2 just set.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION fn_tip_enrich_modulation_fec()
|
||||
RETURNS integer LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, public AS $$
|
||||
DECLARE n1 int := 0; n2 int := 0; n3 int := 0;
|
||||
BEGIN
|
||||
-- 1) coherent flag (set true only; never flip an existing true->false)
|
||||
UPDATE transceivers t
|
||||
SET coherent = true,
|
||||
enriched_at = now(),
|
||||
enriched_fields = (SELECT array_agg(DISTINCT e) FROM unnest(coalesce(t.enriched_fields,'{}'::text[]) || ARRAY['coherent']) e),
|
||||
data_confidence = CASE WHEN coalesce(t.data_confidence,'') IN ('','scraped_unverified','unknown') THEN 'enriched_estimated' ELSE t.data_confidence END
|
||||
WHERE coalesce(t.data_confidence,'') <> 'vendor_verified'
|
||||
AND t.speed_gbps IS NOT NULL
|
||||
AND t.coherent IS DISTINCT FROM true
|
||||
AND tip_in_optics_scope(t.product_type, t.form_factor, t.part_number)
|
||||
AND tip_is_coherent(t.standard_name, t.part_number, t.form_factor, t.speed_gbps, t.coherent);
|
||||
GET DIAGNOSTICS n1 = ROW_COUNT;
|
||||
|
||||
-- 2) modulation (fill-only)
|
||||
UPDATE transceivers t
|
||||
SET modulation = m.val,
|
||||
enriched_at = now(),
|
||||
enriched_fields = (SELECT array_agg(DISTINCT e) FROM unnest(coalesce(t.enriched_fields,'{}'::text[]) || ARRAY['modulation']) e),
|
||||
data_confidence = CASE WHEN coalesce(t.data_confidence,'') IN ('','scraped_unverified','unknown') THEN 'enriched_estimated' ELSE t.data_confidence END
|
||||
FROM (
|
||||
SELECT ctid AS cid, tip_derive_modulation(standard_name, part_number, form_factor, speed_gbps, coherent) AS val
|
||||
FROM transceivers
|
||||
WHERE modulation IS NULL
|
||||
AND coalesce(data_confidence,'') <> 'vendor_verified'
|
||||
AND speed_gbps IS NOT NULL
|
||||
AND tip_in_optics_scope(product_type, form_factor, part_number)
|
||||
) m
|
||||
WHERE t.ctid = m.cid AND m.val IS NOT NULL;
|
||||
GET DIAGNOSTICS n2 = ROW_COUNT;
|
||||
|
||||
-- 3) fec_type + inbuilt_fec (reads modulation set in step 2)
|
||||
UPDATE transceivers t
|
||||
SET fec_type = CASE WHEN t.fec_type IS NULL AND f.fec IS NOT NULL THEN f.fec ELSE t.fec_type END,
|
||||
inbuilt_fec = CASE WHEN t.inbuilt_fec IS NULL AND f.inb IS NOT NULL THEN f.inb ELSE t.inbuilt_fec END,
|
||||
enriched_at = now(),
|
||||
enriched_fields = (SELECT array_agg(DISTINCT e) FROM unnest(
|
||||
coalesce(t.enriched_fields,'{}'::text[])
|
||||
|| CASE WHEN t.fec_type IS NULL AND f.fec IS NOT NULL THEN ARRAY['fec_type'] ELSE '{}'::text[] END
|
||||
|| CASE WHEN t.inbuilt_fec IS NULL AND f.inb IS NOT NULL THEN ARRAY['inbuilt_fec'] ELSE '{}'::text[] END) e),
|
||||
data_confidence = CASE WHEN coalesce(t.data_confidence,'') IN ('','scraped_unverified','unknown') THEN 'enriched_estimated' ELSE t.data_confidence END
|
||||
FROM (
|
||||
SELECT ctid AS cid,
|
||||
tip_derive_fec_type(modulation, standard_name, part_number, speed_gbps) AS fec,
|
||||
tip_derive_inbuilt_fec(modulation, standard_name, part_number, speed_gbps) AS inb
|
||||
FROM transceivers
|
||||
WHERE coalesce(data_confidence,'') <> 'vendor_verified'
|
||||
AND speed_gbps IS NOT NULL
|
||||
AND (fec_type IS NULL OR inbuilt_fec IS NULL)
|
||||
AND tip_in_optics_scope(product_type, form_factor, part_number)
|
||||
) f
|
||||
WHERE t.ctid = f.cid
|
||||
AND ((t.fec_type IS NULL AND f.fec IS NOT NULL) OR (t.inbuilt_fec IS NULL AND f.inb IS NOT NULL));
|
||||
GET DIAGNOSTICS n3 = ROW_COUNT;
|
||||
|
||||
RETURN n1 + n2 + n3;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Run enrichment: SELECT fn_tip_enrich_modulation_fec();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user