fix(sources): Meta-Reparatur (telegram/x/x_account/pdf_document) + toten Code entfernt

- source_meta.py kennt jetzt den echten Bestand: Kategorien telegram (107 Quellen)
  und x (40), Typen x_account und pdf_document. Die 10 nie belegten
  Lagen-Kategorien und der ungenutzte Typ excluded sind raus.
- Pydantic-Pattern source_type um x_account ergaenzt, excluded raus.
  Behebt 422 beim Speichern von 40 X-Account-Quellen.
- Modal-Selects (Typ, Kategorie, PDF-Kategorie) werden aus /api/sources/meta
  befuellt statt hartkodiert. Submit sendet category/source_type nur noch,
  wenn nicht leer. Behebt stilles Loeschen der Kategorie bei Telegram-Quellen.
- source_suggester: Import auf shared.agents.claude_client angepasst
  (war im Portal seit jeher kaputt, Konvention wie source_classifier).
- Toter Code raus: POST /health/run-stream (147 Z. SSE-Duplikat),
  runHealthCheck() im Frontend, excluded_counts-CTE + Sperren-Spalte
  (immer 0), Trend-Delta (braucht 2 Runs, es gibt 1), Alignment-Chips
  (source_alignments ist leer), doppeltes formatDateTime, redundanter
  setupHealthTab-Listener, toter healthContent-Zugriff.
- Tests: Kategorien-/Typen-Sets neu gepinnt, Smoke-Karteileichen entfernt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 17:58:08 +00:00
Ursprung 68fc1794a9
Commit 1a08facb64
10 geänderte Dateien mit 93 neuen und 458 gelöschten Zeilen

Datei anzeigen

@@ -14,6 +14,34 @@ let globalSortAsc = true;
// CATEGORY_LABELS jetzt global (aus app.js loadMeta)
// TYPE_LABELS jetzt global (aus app.js loadMeta)
// META muss geladen sein, bevor Dropdowns befüllt werden (loadMeta läuft async beim Start)
async function ensureMeta() {
if (!window.META || !window.META.categories || !window.META.categories.length) {
if (typeof loadMeta === "function") await loadMeta();
}
}
// Modal-Selects ohne Leer-Option aus META befüllen
function fillSelect(el, items, opts = {}) {
if (!el) return;
el.innerHTML = "";
(items || []).forEach((it) => {
const o = document.createElement("option");
o.value = it.key;
o.textContent = it.label + (opts.disabledKeys && opts.disabledKeys.includes(it.key) && opts.disabledHint ? " " + opts.disabledHint : "");
if (opts.disabledKeys && opts.disabledKeys.includes(it.key)) o.disabled = true;
el.appendChild(o);
});
if (opts.value !== undefined) el.value = opts.value;
}
function fillSourceModalSelects() {
fillSelect(document.getElementById("sourceType"), (window.META && window.META.types) || [],
{ disabledKeys: ["pdf_document"], disabledHint: "(nur Upload)" });
fillSelect(document.getElementById("sourceCategory"), (window.META && window.META.categories) || []);
}
// --- Init ---
document.addEventListener("DOMContentLoaded", () => {
setupSourceSubTabs();
@@ -46,11 +74,11 @@ function setupSourceSubTabs() {
// --- Grundquellen ---
async function loadGlobalSources() {
try {
await ensureMeta();
// Kategorien/Typen-Dropdowns aus META befüllen (idempotent)
if (window.META && window.META.categories && window.META.categories.length) {
populateSelect(document.getElementById("globalFilterCategory"), window.META.categories, "Alle Kategorien");
populateSelect(document.getElementById("globalFilterType"),
(window.META.types || []).filter(t => t.key !== "excluded"), "Alle Typen");
populateSelect(document.getElementById("globalFilterType"), window.META.types || [], "Alle Typen");
}
const [list, stats, languages] = await Promise.all([
API.get("/api/sources/global"),
@@ -145,7 +173,6 @@ function renderGlobalStats(stats) {
const parts = [];
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${stats.total || 0}</span> Quellen gesamt</span>`);
for (const t of types) {
if (t.key === "excluded") continue;
const v = stats.by_type[t.key] || { count: 0, articles: 0 };
parts.push(`<span class="sources-stat-item"><span class="sources-stat-value">${v.count}</span> ${esc(t.label)}</span>`);
}
@@ -161,7 +188,7 @@ function renderGlobalStats(stats) {
function renderGlobalSources(sources) {
const tbody = document.getElementById("globalSourceTable");
const cols = 13;
const cols = 12;
if (sources.length === 0) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Grundquellen</td></tr>`;
return;
@@ -200,7 +227,6 @@ function renderGlobalSources(sources) {
<td>${typeLabel(s.source_type)}</td>
<td class="text-right">${s.article_count || 0}</td>
<td class="${(s.articles_30d || 0) === 0 ? "activity-cell activity-zero" : "activity-cell"}" title="7 Tage / 30 Tage"><strong>${s.articles_7d || 0}</strong> / ${s.articles_30d || 0}</td>
<td class="text-right"><span class="${(s.tenant_excluded_count || 0) === 0 ? "exclude-badge exclude-zero" : "exclude-badge"}">${s.tenant_excluded_count || 0}</span></td>
<td class="text-secondary">${esc(s.language || "-")}</td>
<td class="text-secondary" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.bias || "")}">${esc(s.bias || "-")}</td>
<td class="text-secondary">${lastSeen}</td>
@@ -252,7 +278,7 @@ function filterGlobalSources() {
filtered.sort((a, b) => {
let va = a[globalSortField] ?? "";
let vb = b[globalSortField] ?? "";
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d", "tenant_excluded_count"];
const NUMERIC_FIELDS = ["article_count", "articles_7d", "articles_30d"];
if (NUMERIC_FIELDS.includes(globalSortField)) {
va = parseInt(va) || 0;
vb = parseInt(vb) || 0;
@@ -278,18 +304,23 @@ function sortGlobalSources(field) {
}
// --- Grundquelle erstellen/bearbeiten ---
function openNewGlobalSource() {
async function openNewGlobalSource() {
editingSourceId = null;
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
document.getElementById("sourceForm").reset();
setAlignmentChips([]);
await ensureMeta();
fillSourceModalSelects();
document.getElementById("sourceType").value = "rss_feed";
document.getElementById("sourceCategory").value = "sonstige";
openModal("modalSource");
}
function editGlobalSource(id) {
async function editGlobalSource(id) {
const s = globalSourcesCache.find((x) => x.id === id);
if (!s) return;
editingSourceId = id;
await ensureMeta();
fillSourceModalSelects();
document.getElementById("sourceModalTitle").textContent = "Grundquelle bearbeiten";
document.getElementById("sourceName").value = s.name;
document.getElementById("sourceUrl").value = s.url || "";
@@ -306,7 +337,6 @@ function editGlobalSource(id) {
document.getElementById("sourceReliability").value = s.reliability || "";
document.getElementById("sourceCountryCode").value = s.country_code || "";
document.getElementById("sourceStateAffiliated").checked = !!s.state_affiliated;
setAlignmentChips(s.alignments || []);
openModal("modalSource");
}
@@ -330,14 +360,18 @@ function setupSourceForms() {
name: document.getElementById("sourceName").value,
url: document.getElementById("sourceUrl").value || null,
domain: document.getElementById("sourceDomain").value || null,
source_type: document.getElementById("sourceType").value,
category: document.getElementById("sourceCategory").value,
status: document.getElementById("sourceStatus").value,
notes: document.getElementById("sourceNotes").value || null,
language: document.getElementById("sourceLanguage").value || null,
bias: document.getElementById("sourceBias").value || null,
fetch_strategy: document.getElementById("sourceFetchStrategy").value || "default",
};
// Leere Werte NIE mitsenden. Ein leerer Select-Wert (z.B. META noch nicht
// geladen) würde sonst die Kategorie serverseitig still überschreiben.
const st = document.getElementById("sourceType").value;
if (st) body.source_type = st;
const cat = document.getElementById("sourceCategory").value;
if (cat) body.category = cat;
const pol = document.getElementById("sourcePolitical")?.value;
if (pol) body.political_orientation = pol;
@@ -349,7 +383,6 @@ function setupSourceForms() {
if (cc) body.country_code = cc;
if (editingSourceId) {
body.state_affiliated = !!document.getElementById("sourceStateAffiliated")?.checked;
body.alignments = getAlignmentChips();
}
try {
@@ -408,8 +441,7 @@ async function loadTenantSources() {
function populateTenantFilters() {
// Typ + Kategorie aus META, Org aus Cache
if (window.META && window.META.types) {
populateSelect(document.getElementById("tenantFilterType"),
window.META.types.filter(t => t.key !== "excluded"), "Alle Typen");
populateSelect(document.getElementById("tenantFilterType"), window.META.types, "Alle Typen");
}
if (window.META && window.META.categories) {
populateSelect(document.getElementById("tenantFilterCategory"),
@@ -693,34 +725,6 @@ const MEDIA_TYPE_LABELS = {
ngo: "NGO", behoerde: "Behörde", staatsmedium: "Staatsmedium",
fachmedium: "Fachmedium", sonstige: "Sonstige",
};
const ALIGNMENT_LABELS = {
prorussisch: "prorussisch", proiranisch: "proiranisch", prowestlich: "prowestlich",
proukrainisch: "proukrainisch", prochinesisch: "prochinesisch", projapanisch: "projapanisch",
proisraelisch: "proisraelisch", propalaestinensisch: "propalästinensisch",
protuerkisch: "protürkisch", panarabisch: "panarabisch", neutral: "neutral", sonstige: "sonstige",
};
function setAlignmentChips(active) {
const chips = document.querySelectorAll("#sourceAlignmentChips .alignment-chip");
const set = new Set((active || []).map((a) => (a || "").toLowerCase()));
chips.forEach((chip) => {
if (set.has(chip.dataset.alignment)) chip.classList.add("active");
else chip.classList.remove("active");
});
}
function getAlignmentChips() {
return Array.from(document.querySelectorAll("#sourceAlignmentChips .alignment-chip.active"))
.map((chip) => chip.dataset.alignment);
}
function handleAlignmentChipClick(e) {
const chip = e.target.closest(".alignment-chip");
if (!chip) return;
e.preventDefault();
chip.classList.toggle("active");
}
async function refreshClassificationStats() {
try {
const stats = await API.get("/api/sources/classification/stats");
@@ -761,8 +765,6 @@ function renderClassificationQueueItem(item) {
const relFmt = (v) => (v && v !== "na" ? RELIABILITY_LABELS[v] || v : "–");
const stateFmt = (v) => (v ? "ja" : "nein");
const ccFmt = (v) => v || "–";
const alignFmt = (v) =>
Array.isArray(v) && v.length > 0 ? v.map((a) => ALIGNMENT_LABELS[a] || a).join(", ") : "–";
const row = (label, c, p, fmt) => {
const cs = fmt(c);
@@ -796,7 +798,6 @@ function renderClassificationQueueItem(item) {
${row("Glaubwürdigkeit", cur.reliability, prop.reliability, relFmt)}
${row("Staatsnah", cur.state_affiliated, prop.state_affiliated, stateFmt)}
${row("Land", cur.country_code, prop.country_code, ccFmt)}
${row("Geopol. Nähe", cur.alignments, prop.alignments, alignFmt)}
</div>
${reasoning ? `<div class="review-card-reasoning"><strong>Begründung:</strong> ${reasoning}</div>` : ""}
<div class="review-card-actions">
@@ -885,9 +886,11 @@ function toggleSourceInfo(id) {
}
// --- PDF-Quellen-Upload ---
function openPdfUploadModal() {
async function openPdfUploadModal() {
const form = document.getElementById("pdfUploadForm");
if (form) form.reset();
await ensureMeta();
fillSelect(document.getElementById("pdfCategory"), (window.META && window.META.categories) || [], { value: "sonstige" });
const err = document.getElementById("pdfUploadError");
if (err) { err.style.display = "none"; err.textContent = ""; }
const prog = document.getElementById("pdfUploadProgress");