Compare commits

..

2 Commits

Author SHA1 Message Date
Rene Fichtmueller
7718356327 feat: side-by-side competitor comparison + fix 1.6T speed_gbps
- Fix OSFP-DR8-1.6T-FL and OSFP-2FR4-1.6T-FL: speed_gbps was 200, now 1600
  → FS.com 1.6T products now correctly match as comparables for Flexoptix O.1316T.C.05.M
- API: extend comparable price query to return comp_form_factor, comp_speed_gbps,
  comp_reach_meters, comp_reach_label, comp_fiber_type, comp_wavelengths
- Dashboard: replace plain comparable price row with side-by-side spec comparison card
  showing Flexoptix vs. competitor: Form Factor, Speed, Reach, Fiber, Wavelengths
  with color coding (green=match, orange=mismatch) and savings badge (−45% günstiger)
2026-04-18 21:51:41 +02:00
Rene Fichtmueller
2ebba07bb0 feat: add claude-code LLM provider + update dashboard to fo-blog-v5
- client.ts: add claude-code provider routing BLOG_LLM_PROVIDER=claude-code
  to claude-bridge (flat-rate, no API billing via Claude Code subscription)
- checkHealth() now pings /health on claude-bridge for real availability check
- Default OLLAMA_LLM_MODEL changed from qwen2.5:14b to fo-blog-v5
- Dashboard: add claude-code card (EMPFOHLEN), rename fo-blog-v3 → fo-blog-v5
- loadBlogLLMStatus() handles all 3 providers: claude-code/anthropic/ollama
- Grid expanded from 3 to 4 columns to accommodate new card
- ecosystem.config.js + .env on Erik: OLLAMA_LLM_MODEL=fo-blog-v5 confirmed
2026-04-18 20:45:14 +02:00
4 changed files with 235 additions and 36 deletions

View File

@ -3,6 +3,7 @@
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
Types: FEAT · FIX · UI · DATA · AI · INFRA Types: FEAT · FIX · UI · DATA · AI · INFRA
{"d":"2026-04-18","t":"AI","m":"Blog LLM: claude-code provider implemented in packages/api/src/llm/client.ts — routes BLOG_LLM_PROVIDER=claude-code to claude-bridge (http://localhost:3250/api/generate) on Erik using Claude Code flat-rate subscription. No API billing. checkHealth() pings /health endpoint. Dashboard updated: added claude-code card (EMPFOHLEN, AKTIV), fo-blog-v3-qwen7b card replaced with fo-blog-v5, loadBlogLLMStatus() now handles claude-code provider with correct badge/border highlighting. ecosystem.config.js + .env updated: OLLAMA_LLM_MODEL=fo-blog-v5, BLOG_LLM_PROVIDER=claude-code confirmed active via pm2 env."}
{"d":"2026-04-18","t":"FIX","m":"Cloudflare Tunnel DNS mass-update: after deleting phantom eo-pulse tunnel and creating main-prod (90c22eb0), 31 context-x.org + 7 fichtmueller.org DNS records still pointed to the deleted 641c39a5 tunnel → 530 on all services. Bulk-patched via Cloudflare API: all records now point to main-prod. Created missing admin.magatama.fichtmueller.org CNAME. TIP cloudflared-tip.service restart policy changed to Restart=always (was on-failure, so clean exits caused permanent outage). peercortex.org remains 530 — DNS is in a separate inaccessible Cloudflare account (NS: fattouche/elisabeth.ns.cloudflare.com); needs manual login."} {"d":"2026-04-18","t":"FIX","m":"Cloudflare Tunnel DNS mass-update: after deleting phantom eo-pulse tunnel and creating main-prod (90c22eb0), 31 context-x.org + 7 fichtmueller.org DNS records still pointed to the deleted 641c39a5 tunnel → 530 on all services. Bulk-patched via Cloudflare API: all records now point to main-prod. Created missing admin.magatama.fichtmueller.org CNAME. TIP cloudflared-tip.service restart policy changed to Restart=always (was on-failure, so clean exits caused permanent outage). peercortex.org remains 530 — DNS is in a separate inaccessible Cloudflare account (NS: fattouche/elisabeth.ns.cloudflare.com); needs manual login."}
{"d":"2026-04-18","t":"DATA","m":"Image backfill: GBICS og:image + QSFPTEK backfill scripts run on Erik — 226 new images added (671 → 897 total, 17.5% → 23.4% coverage). OSFP form factor: 0 → 68 images. QSFPTEK og:image URL bug fixed (double-hostname prefix stripped). OSFP-DR8-800G manually set to GBICS-compatible image (cdn11.bigcommerce.com DR8 product photo)."} {"d":"2026-04-18","t":"DATA","m":"Image backfill: GBICS og:image + QSFPTEK backfill scripts run on Erik — 226 new images added (671 → 897 total, 17.5% → 23.4% coverage). OSFP form factor: 0 → 68 images. QSFPTEK og:image URL bug fixed (double-hostname prefix stripped). OSFP-DR8-800G manually set to GBICS-compatible image (cdn11.bigcommerce.com DR8 product photo)."}
{"d":"2026-04-18","t":"FIX","m":"FS.com scraper: all 247 prices written as €79 (wrong) — root cause: 'Gratis Versand ab 79 € (ohne MwSt.)' free-shipping banner appears on every FS.com product page. PRICE_QUALIFIED bodyText regex matched this banner text before reaching the actual product price. Fix: (1) DOM-based price extraction added to page.evaluate — targets [class*='price-value']/[class*='product-price'] etc., skipping elements inside shipping/banner/footer parents; (2) bodyText qualified patterns now check 200-char context for versand/shipping/gratis keywords and skip matches that appear in shipping context; (3) waitForSelector for price elements added before evaluate; (4) deleted 247 invalid €79 observations from DB."} {"d":"2026-04-18","t":"FIX","m":"FS.com scraper: all 247 prices written as €79 (wrong) — root cause: 'Gratis Versand ab 79 € (ohne MwSt.)' free-shipping banner appears on every FS.com product page. PRICE_QUALIFIED bodyText regex matched this banner text before reaching the actual product price. Fix: (1) DOM-based price extraction added to page.evaluate — targets [class*='price-value']/[class*='product-price'] etc., skipping elements inside shipping/banner/footer parents; (2) bodyText qualified patterns now check 200-char context for versand/shipping/gratis keywords and skip matches that appear in shipping context; (3) waitForSelector for price elements added before evaluate; (4) deleted 247 invalid €79 observations from DB."}

View File

@ -1,20 +1,23 @@
/** /**
* LLM client for blog generation supports Ollama (local) and Anthropic Claude (API). * LLM client for blog generation supports Ollama (local), Anthropic Claude (API),
* and Claude-Code (flat-rate via claude-bridge on Erik).
* *
* Provider selection: * Provider selection:
* BLOG_LLM_PROVIDER=anthropic Claude Sonnet/Haiku via Anthropic API * BLOG_LLM_PROVIDER=claude-code Claude via claude-bridge (flat-rate, recommended)
* BLOG_LLM_PROVIDER=ollama qwen2.5 on local Ollama (default) * BLOG_LLM_PROVIDER=anthropic Claude Sonnet/Haiku via Anthropic API
* BLOG_LLM_PROVIDER=ollama fine-tuned fo-blog-v5 on local Ollama (default)
* *
* Claude is strongly recommended for blog generation qwen2.5:14b cannot * Claude-code is preferred: uses Claude Code subscription (flat-rate), no API costs.
* follow complex multi-constraint prompts (mode collapse). * Ollama fo-blog-v5 is the fallback for offline/local usage.
*/ */
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "qwen2.5:14b"; const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "fo-blog-v5";
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ""; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514"; const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514";
const BLOG_LLM_PROVIDER = process.env.BLOG_LLM_PROVIDER || "ollama"; const BLOG_LLM_PROVIDER = process.env.BLOG_LLM_PROVIDER || "ollama";
const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:3250";
interface LlmResponse { interface LlmResponse {
text: string; text: string;
@ -222,6 +225,48 @@ async function generateOllama(
}); });
} }
// ═══════════════════════════════════════════════════════
// CLAUDE-CODE PROVIDER (claude-bridge — flat-rate via Claude Code subscription)
// ═══════════════════════════════════════════════════════
async function generateClaudeBridge(
systemPrompt: string,
userPrompt: string,
options?: { temperature?: number; maxTokens?: number; timeoutMs?: number },
): Promise<LlmResponse> {
const startTime = Date.now();
// claude-bridge expects combined prompt — system + user joined with double newline
const fullPrompt = `${systemPrompt}\n\n${userPrompt}`;
const resp = await fetch(`${CLAUDE_BRIDGE_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: fullPrompt }),
signal: AbortSignal.timeout(options?.timeoutMs ?? 300000),
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`Claude bridge failed: ${resp.status} ${errText.slice(0, 200)}`);
}
const data = await resp.json() as { success: boolean; content?: string; error?: string };
if (!data.success || !data.content) {
throw new Error(`Claude bridge returned empty response: ${JSON.stringify(data)}`);
}
const duration = Date.now() - startTime;
console.log(`[LLM] Claude-bridge: ${data.content.length} chars, ${duration}ms`);
return {
text: data.content,
model: "claude-code",
totalDuration: duration * 1_000_000, // ns for compat with Ollama callers
evalCount: Math.ceil(data.content.length / 4), // approx tokens
};
}
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
// PUBLIC API — auto-routes to configured provider // PUBLIC API — auto-routes to configured provider
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
@ -231,6 +276,9 @@ export async function generate(
userPrompt: string, userPrompt: string,
options?: { temperature?: number; maxTokens?: number; timeoutMs?: number }, options?: { temperature?: number; maxTokens?: number; timeoutMs?: number },
): Promise<LlmResponse> { ): Promise<LlmResponse> {
if (BLOG_LLM_PROVIDER === "claude-code") {
return generateClaudeBridge(systemPrompt, userPrompt, options);
}
if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) {
return generateClaude(systemPrompt, userPrompt, options); return generateClaude(systemPrompt, userPrompt, options);
} }
@ -281,6 +329,16 @@ export async function chat(
/** Check if configured LLM provider is available */ /** Check if configured LLM provider is available */
export async function checkHealth(): Promise<{ ok: boolean; model: string; provider: string; error?: string }> { export async function checkHealth(): Promise<{ ok: boolean; model: string; provider: string; error?: string }> {
if (BLOG_LLM_PROVIDER === "claude-code") {
try {
const resp = await fetch(`${CLAUDE_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) return { ok: false, model: "claude-code", provider: "claude-code", error: `HTTP ${resp.status}` };
return { ok: true, model: "claude-code", provider: "claude-code" };
} catch (err) {
return { ok: false, model: "claude-code", provider: "claude-code", error: (err as Error).message };
}
}
if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) {
// Key presence check only — live API call causes 429 when pipeline is running // Key presence check only — live API call causes 429 when pipeline is running
return { ok: true, model: ANTHROPIC_MODEL, provider: "anthropic" }; return { ok: true, model: ANTHROPIC_MODEL, provider: "anthropic" };

View File

@ -77,7 +77,10 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
`SELECT DISTINCT ON (po.source_vendor_id) `SELECT DISTINCT ON (po.source_vendor_id)
po.price, po.currency, po.url, po.time, po.price, po.currency, po.url, po.time,
sv.name AS vendor_name, sv.type AS vendor_type, sv.name AS vendor_name, sv.type AS vendor_type,
t2.part_number, t2.standard_name, t2.id AS comparable_id t2.part_number, t2.standard_name, t2.id AS comparable_id,
t2.form_factor AS comp_form_factor, t2.speed_gbps AS comp_speed_gbps,
t2.reach_meters AS comp_reach_meters, t2.reach_label AS comp_reach_label,
t2.fiber_type AS comp_fiber_type, t2.wavelengths AS comp_wavelengths
FROM transceivers t1 FROM transceivers t1
JOIN transceivers t2 ON ( JOIN transceivers t2 ON (
t2.form_factor = t1.form_factor t2.form_factor = t1.form_factor
@ -117,6 +120,13 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
is_same_product: false, // different SKU, same spec class is_same_product: false, // different SKU, same spec class
comparable_part: row.part_number || row.standard_name, comparable_part: row.part_number || row.standard_name,
comparable_id: row.comparable_id, comparable_id: row.comparable_id,
// Spec details for side-by-side comparison in dashboard
comp_form_factor: row.comp_form_factor,
comp_speed_gbps: row.comp_speed_gbps ? parseFloat(row.comp_speed_gbps) : null,
comp_reach_meters: row.comp_reach_meters,
comp_reach_label: row.comp_reach_label,
comp_fiber_type: row.comp_fiber_type,
comp_wavelengths: row.comp_wavelengths,
})); }));
const allPrices = [...prices, ...comparablePrices]; const allPrices = [...prices, ...comparablePrices];

View File

@ -1243,23 +1243,48 @@
</div> </div>
<!-- Model cards --> <!-- Model cards -->
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.85rem"> <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.85rem">
<!-- Claude (recommended) --> <!-- Claude-Code (active — flat-rate via claude-bridge) -->
<div id="blog-model-card-cc" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🤖 claude-code</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">claude-bridge / Erik</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
<span style="font-size:0.62rem;padding:2px 6px;border-radius:3px;background:var(--accent);color:#fff;font-weight:700;white-space:nowrap">★ EMPFOHLEN</span>
<span id="blog-model-cc-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
</div>
</div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Kein Mode Collapse</div>
<div style="color:#1a7a3a;font-weight:500">✓ Flat-rate (kein API-Billing)</div>
<div style="color:#1a7a3a;font-weight:500">✓ Claude Code Subscription</div>
</div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=claude-code<br>CLAUDE_BRIDGE_URL=http://localhost:3250</code>
</div>
<div id="blog-model-cc-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div>
<!-- Claude API -->
<div id="blog-model-card-claude" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)"> <div id="blog-model-card-claude" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem"> <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div> <div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🧠 claude-sonnet-4-6</div> <div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🧠 claude-sonnet-4-6</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Anthropic API</div> <div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Anthropic API</div>
</div> </div>
<span style="font-size:0.62rem;padding:2px 6px;border-radius:3px;background:var(--accent);color:#fff;font-weight:700;white-space:nowrap">★ EMPFOHLEN</span> <span id="blog-model-claude-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
</div> </div>
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem"> <div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</div> <div><span style="color:var(--accent)">★★★★★</span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div> <div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Komplexe Multi-Constraint Prompts</div> <div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Komplexe Multi-Constraint Prompts</div>
<div style="color:#1a7a3a;font-weight:500">✓ Kein Mode Collapse</div> <div style="color:#1a7a3a;font-weight:500">✓ Kein Mode Collapse</div>
<div style="color:#1a7a3a;font-weight:500">✓ 4096 Token Output</div> <div style="color:#b45309;font-weight:500">⚠ API-Kosten pro Artikel</div>
</div> </div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem"> <div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=anthropic<br>ANTHROPIC_MODEL=claude-sonnet-4-6</code> <code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=anthropic<br>ANTHROPIC_MODEL=claude-sonnet-4-6</code>
@ -1267,11 +1292,11 @@
<div id="blog-model-claude-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div> <div id="blog-model-claude-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div> </div>
<!-- Fine-tuned local --> <!-- Fine-tuned local fo-blog-v5 -->
<div id="blog-model-card-fo" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)"> <div id="blog-model-card-fo" style="border-radius:8px;padding:0.85rem;border:2px solid var(--border);background:var(--surface)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem"> <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:0.6rem">
<div> <div>
<div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v3-qwen7b</div> <div style="font-size:0.8rem;font-weight:700;color:var(--text-bright)">🎯 fo-blog-v5</div>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div> <div style="font-size:0.68rem;color:var(--text-dim);margin-top:2px">Ollama / Mac Studio</div>
</div> </div>
<span id="blog-model-fo-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span> <span id="blog-model-fo-active" style="display:none;font-size:0.62rem;padding:2px 6px;border-radius:3px;background:#1a7a3a;color:#fff;font-weight:700">● AKTIV</span>
@ -1279,12 +1304,12 @@
<div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem"> <div style="font-size:0.72rem;color:var(--text);line-height:1.8;margin-bottom:0.6rem">
<div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Blog-Qualität</div> <div><span style="color:var(--accent)">★★★★</span><span style="color:var(--text-dim)"></span> Blog-Qualität</div>
<div><span style="color:var(--accent)">★★★★★</span> Geschwindigkeit</div> <div><span style="color:var(--accent)">★★★★★</span> Geschwindigkeit</div>
<div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Fine-tuned auf TIP-Stil</div> <div style="margin-top:0.35rem;color:#1a7a3a;font-weight:500">✓ Fine-tuned auf TIP-Stil (v5)</div>
<div style="color:#1a7a3a;font-weight:500">✓ Lokal / kein API-Kosten</div> <div style="color:#1a7a3a;font-weight:500">✓ Lokal / keine API-Kosten</div>
<div style="color:#b45309;font-weight:500">⚠ Gelegentlicher Mode Collapse</div> <div style="color:#b45309;font-weight:500">⚠ Gelegentlicher Mode Collapse</div>
</div> </div>
<div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem"> <div style="background:#1e1e1e;border-radius:5px;padding:0.5rem 0.65rem;margin-bottom:0.5rem">
<code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=fo-blog-v3-qwen7b</code> <code style="font-size:0.65rem;color:#f8f8f2;line-height:1.6;word-break:break-all;display:block">BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=fo-blog-v5</code>
</div> </div>
<div id="blog-model-fo-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div> <div id="blog-model-fo-status" style="font-size:0.7rem;color:var(--text-dim)">checking…</div>
</div> </div>
@ -1316,7 +1341,7 @@
<div style="margin-top:0.85rem;padding:0.6rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.72rem;color:var(--text)"> <div style="margin-top:0.85rem;padding:0.6rem 0.85rem;background:var(--surface2);border-radius:6px;font-size:0.72rem;color:var(--text)">
<strong>Modell wechseln:</strong> SSH → Erik → <strong>Modell wechseln:</strong> SSH → Erik →
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">nano /opt/tip/ecosystem.config.js</code> <code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">nano /opt/tip/ecosystem.config.js</code>
→ BLOG_LLM_PROVIDER + ANTHROPIC_API_KEY → → BLOG_LLM_PROVIDER + CLAUDE_BRIDGE_URL / ANTHROPIC_API_KEY →
<code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">pm2 restart tip-api --update-env</code> <code style="background:#1e1e1e;color:#f8f8f2;padding:1px 5px;border-radius:3px">pm2 restart tip-api --update-env</code>
</div> </div>
</div><!-- end LLM panel --> </div><!-- end LLM panel -->
@ -3474,12 +3499,91 @@ async function openTxDetail(id) {
directPrices.forEach(function(p) { h += renderPriceRow(p); }); directPrices.forEach(function(p) { h += renderPriceRow(p); });
if (comparPrices.length > 0) {
h += '<div style="font-size:0.7rem;color:#888;margin:0.5rem 0 0.25rem;padding-top:0.4rem;border-top:1px solid var(--border)">Vergleichbare Produkte anderer Hersteller (gleiche Spezifikation)</div>';
comparPrices.forEach(function(p) { h += renderPriceRow(p); });
}
h += '</div>'; h += '</div>';
// Comparable products → Side-by-Side spec comparison cards
if (comparPrices.length > 0) {
h += '<div class="panel-section" style="margin-top:0.8rem">Vergleichbare Wettbewerber-Produkte</div>';
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;
}
var compEur = null;
var ca = parseFloat(p.price), cc = (p.currency||'USD').toUpperCase();
compEur = cc === 'EUR' ? ca : cc === 'USD' ? ca * 0.92 : ca;
var savBadge = '';
if (myEur && compEur && myEur > 0 && compEur > 0) {
var diff = myEur - compEur;
var pct = Math.round(Math.abs(diff) / myEur * 100);
if (diff > 0) {
savBadge = '<span style="background:rgba(22,163,74,0.15);color:#16a34a;font-size:0.72rem;font-weight:700;padding:2px 7px;border-radius:4px;border:1px solid rgba(22,163,74,0.35)">'
+ '' + pct + '% günstiger</span>';
} else if (diff < 0) {
savBadge = '<span style="background:rgba(220,38,38,0.1);color:#dc2626;font-size:0.72rem;font-weight:700;padding:2px 7px;border-radius:4px;border:1px solid rgba(220,38,38,0.25)">'
+ '+' + pct + '% teurer</span>';
}
}
// Spec comparison helper — highlight match/mismatch
function specRow(label, myVal, compVal) {
var match = myVal && compVal && String(myVal).toLowerCase() === String(compVal).toLowerCase();
var compColor = !myVal || !compVal ? '#aaa' : match ? '#4ade80' : '#fb923c';
return '<tr><td style="color:#888;font-size:0.7rem;padding:2px 6px 2px 0;white-space:nowrap">' + label + '</td>'
+ '<td style="color:#ccc;font-size:0.7rem;padding:2px 8px 2px 0">' + esc(myVal || '—') + '</td>'
+ '<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;
h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:0.6rem;overflow:hidden">';
// Header: vendor + part + price + savings badge
h += '<div style="display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.75rem;background:rgba(255,255,255,0.03);border-bottom:1px solid var(--border)">';
h += '<div>';
h += '<span style="font-size:0.78rem;font-weight:700;color:var(--accent)">' + esc(p.vendor_name) + '</span>';
h += '<span style="font-size:0.7rem;color:#888;margin-left:0.5rem">' + esc(p.comparable_part || '—') + '</span>';
h += '</div>';
h += '<div style="display:flex;align-items:center;gap:0.4rem">';
var priceDisplayEur = compEur ? ('EUR\u00a0' + compEur.toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2})) : '';
h += '<span style="font-size:0.82rem;font-weight:700;color:var(--text)">' + priceDisplayEur + '</span>';
if (p.url) h += '<a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.7rem;text-decoration:none"></a>';
h += '</div>';
h += '</div>';
// Savings badge row
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>';
h += '</div>';
}
// Spec comparison table: Flexoptix (links) vs. Wettbewerber (rechts)
h += '<div style="padding:0.5rem 0.75rem">';
h += '<table style="width:100%;border-collapse:collapse">';
h += '<thead><tr>';
h += '<th style="font-size:0.67rem;color:#555;text-align:left;padding-bottom:4px;padding-right:8px"></th>';
h += '<th style="font-size:0.67rem;color:#888;text-align:left;padding-bottom:4px;padding-right:8px">Flexoptix</th>';
h += '<th style="font-size:0.67rem;color:#888;text-align:left;padding-bottom:4px">' + esc(p.vendor_name) + '</th>';
h += '</tr></thead><tbody>';
h += specRow('Form Factor', t.form_factor, p.comp_form_factor);
h += specRow('Speed', mySpeed, compSpeed);
h += specRow('Reach', t.reach_label || (t.reach_meters ? t.reach_meters + 'm' : null), p.comp_reach_label || (p.comp_reach_meters ? p.comp_reach_meters + 'm' : null));
h += specRow('Fiber', t.fiber_type, p.comp_fiber_type);
if (t.wavelengths || p.comp_wavelengths) h += specRow('Wavelengths', t.wavelengths, p.comp_wavelengths);
h += '</tbody></table>';
h += '<div style="font-size:0.65rem;color:#555;margin-top:0.35rem">🕐 Stand: ' + fmtDate(p.observed_at) + (p.is_verified ? ' &nbsp;·&nbsp; <span style="color:#2d6a4f">✓ Verified</span>' : '') + '</div>';
h += '</div>';
h += '</div>'; // card end
});
}
} }
// No competitor prices → show "Kein Markt" info block with last scan date // No competitor prices → show "Kein Markt" info block with last scan date
if (!cVer && t.last_competitor_scan) { if (!cVer && t.last_competitor_scan) {
@ -4934,7 +5038,8 @@ async function loadBlogLLMStatus() {
} }
if (activeModel) activeModel.textContent = llm.model || '—'; if (activeModel) activeModel.textContent = llm.model || '—';
if (activeProvider) { if (activeProvider) {
activeProvider.textContent = llm.provider === 'anthropic' ? 'anthropic' : 'ollama'; var provLabel = llm.provider === 'claude-code' ? 'claude-code' : llm.provider === 'anthropic' ? 'anthropic' : 'ollama';
activeProvider.textContent = provLabel;
activeProvider.style.background = 'var(--accent)'; activeProvider.style.background = 'var(--accent)';
activeProvider.style.color = '#fff'; activeProvider.style.color = '#fff';
} }
@ -4943,35 +5048,60 @@ async function loadBlogLLMStatus() {
queueEl.textContent = q > 0 ? 'Queue: ' + q + ' Jobs' : 'Queue: idle'; queueEl.textContent = q > 0 ? 'Queue: ' + q + ' Jobs' : 'Queue: idle';
} }
// Mark active model card with accent border // Reset all card borders + active badges
var foActive = document.getElementById('blog-model-fo-active'); ['cc','claude','fo'].forEach(function(k) {
var foCard = document.getElementById('blog-model-card-fo'); var card = document.getElementById('blog-model-card-' + k);
var claudeCard = document.getElementById('blog-model-card-claude'); if (card) card.style.border = '2px solid var(--border)';
var badge2 = document.getElementById('blog-model-' + k + '-active');
if (badge2) badge2.style.display = 'none';
});
if (llm.provider === 'anthropic') { if (llm.provider === 'claude-code') {
var ccCard = document.getElementById('blog-model-card-cc');
if (ccCard) ccCard.style.border = '2px solid var(--accent)';
var ccActive = document.getElementById('blog-model-cc-active');
if (ccActive) ccActive.style.display = 'inline';
var ccSt = document.getElementById('blog-model-cc-status');
if (ccSt) {
ccSt.textContent = llm.ok ? '● Aktiv — claude-bridge erreichbar' : '⚠ claude-bridge nicht erreichbar: ' + (llm.error || '').slice(0, 60);
ccSt.style.color = llm.ok ? '#1a7a3a' : '#b45309';
ccSt.style.fontWeight = '600';
}
var clSt2 = document.getElementById('blog-model-claude-status');
if (clSt2) { clSt2.textContent = 'bereit (nicht aktiv)'; clSt2.style.color = 'var(--text-dim)'; clSt2.style.fontWeight = '400'; }
var foSt2 = document.getElementById('blog-model-fo-status');
if (foSt2) { foSt2.textContent = 'bereit (nicht aktiv)'; foSt2.style.color = 'var(--text-dim)'; foSt2.style.fontWeight = '400'; }
} else if (llm.provider === 'anthropic') {
var claudeCard = document.getElementById('blog-model-card-claude');
if (claudeCard) claudeCard.style.border = '2px solid var(--accent)'; if (claudeCard) claudeCard.style.border = '2px solid var(--accent)';
var claudeActive = document.getElementById('blog-model-claude-active');
if (claudeActive) claudeActive.style.display = 'inline';
var claudeStatusEl = document.getElementById('blog-model-claude-status'); var claudeStatusEl = document.getElementById('blog-model-claude-status');
if (claudeStatusEl) { if (claudeStatusEl) {
claudeStatusEl.textContent = '● Aktiv — API-Key konfiguriert'; claudeStatusEl.textContent = '● Aktiv — API-Key konfiguriert';
claudeStatusEl.style.color = '#1a7a3a'; claudeStatusEl.style.color = '#1a7a3a';
claudeStatusEl.style.fontWeight = '600'; claudeStatusEl.style.fontWeight = '600';
} }
var foSt = document.getElementById('blog-model-fo-status'); var ccSt2 = document.getElementById('blog-model-cc-status');
if (foSt) { foSt.textContent = 'bereit (nicht aktiv)'; foSt.style.color = 'var(--text-dim)'; } if (ccSt2) { ccSt2.textContent = 'bereit (nicht aktiv)'; ccSt2.style.color = 'var(--text-dim)'; ccSt2.style.fontWeight = '400'; }
var foSt3 = document.getElementById('blog-model-fo-status');
if (foSt3) { foSt3.textContent = 'bereit (nicht aktiv)'; foSt3.style.color = 'var(--text-dim)'; foSt3.style.fontWeight = '400'; }
} else { } else {
if (foActive) foActive.style.display = 'inline'; // ollama
var foCard = document.getElementById('blog-model-card-fo');
if (foCard) foCard.style.border = '2px solid var(--accent)'; if (foCard) foCard.style.border = '2px solid var(--accent)';
var foActive = document.getElementById('blog-model-fo-active');
if (foActive) foActive.style.display = 'inline';
var foStatusEl = document.getElementById('blog-model-fo-status'); var foStatusEl = document.getElementById('blog-model-fo-status');
if (foStatusEl) { if (foStatusEl) {
foStatusEl.textContent = llm.ok ? '● Aktiv — Ollama erreichbar' : '⚠ Ollama nicht erreichbar: ' + (llm.error || '').slice(0, 60); foStatusEl.textContent = llm.ok ? '● Aktiv — Ollama erreichbar' : '⚠ Ollama nicht erreichbar: ' + (llm.error || '').slice(0, 60);
foStatusEl.style.color = llm.ok ? '#1a7a3a' : '#b45309'; foStatusEl.style.color = llm.ok ? '#1a7a3a' : '#b45309';
foStatusEl.style.fontWeight = '600'; foStatusEl.style.fontWeight = '600';
} }
var clSt = document.getElementById('blog-model-claude-status'); var ccSt3 = document.getElementById('blog-model-cc-status');
if (clSt) { if (ccSt3) { ccSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=claude-code setzen'; ccSt3.style.color = 'var(--text-dim)'; ccSt3.style.fontWeight = '400'; }
clSt.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + ANTHROPIC_API_KEY setzen'; var clSt3 = document.getElementById('blog-model-claude-status');
clSt.style.color = 'var(--text-dim)'; if (clSt3) { clSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + API-Key setzen'; clSt3.style.color = 'var(--text-dim)'; clSt3.style.fontWeight = '400'; }
}
} }
} catch(e) { } catch(e) {
var b = document.getElementById('blog-llm-status-badge'); var b = document.getElementById('blog-llm-status-badge');