Compare commits
2 Commits
b9bdcd6fc6
...
7718356327
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7718356327 | ||
|
|
2ebba07bb0 |
@ -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."}
|
||||||
|
|||||||
@ -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" };
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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 ? ' · <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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user