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:
@@ -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");
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren