refactor(klassifikation): Klassifikation aus Monitor entfernt — Pflege jetzt in der Verwaltung
Endpoints unter /api/sources/classification/* weg, Service-Module (source_classifier, external_reputation) gelöscht. Quellen-Modal verliert Tab Klassifikations-Review, Klassifikations-Section in der Edit-Form, alle Bulk-Buttons (Sync, Klassifikation starten, Bulk-Approve). API-Methoden in api.js entfernt, alignment-Helper raus, saveSource entschlackt. Read-Only bleibt: Filter-Dropdowns über der Quellenliste (Politik, Medientyp, Reliability, Externe Reputation, Alignment) und Inline-Badges (_renderClassificationBadges + Label-Maps in components.js). Kunde sieht nur freigegebene Werte. GET /api/sources liefert weiter Klassifikations-Felder + alignments für die Anzeige; SourceCreate/SourceUpdate akzeptieren keine Klassifikations-Felder mehr. Bulk-Klassifikations-Skripte entfernt — Pflege läuft über Verwaltungs-UI.
Dieser Commit ist enthalten in:
@@ -1,64 +0,0 @@
|
|||||||
"""Einmalige LLM-Klassifikation aller noch unklassifizierten Quellen.
|
|
||||||
|
|
||||||
Verwendung:
|
|
||||||
python3 scripts/migrate_sources_classification.py --limit 50
|
|
||||||
python3 scripts/migrate_sources_classification.py --limit 500 # Alle
|
|
||||||
python3 scripts/migrate_sources_classification.py --recheck-pending # bereits Pending neu
|
|
||||||
|
|
||||||
Schreibt Vorschlaege in proposed_*-Spalten. Approval erfolgt anschliessend
|
|
||||||
ueber das Verwaltungs-UI / API (POST /api/sources/{id}/classification/approve).
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# src/ in PYTHONPATH aufnehmen, wenn Skript direkt aufgerufen wird
|
|
||||||
HERE = Path(__file__).resolve().parent
|
|
||||||
SRC = HERE.parent / "src"
|
|
||||||
if str(SRC) not in sys.path:
|
|
||||||
sys.path.insert(0, str(SRC))
|
|
||||||
|
|
||||||
from database import get_db # noqa: E402
|
|
||||||
from services.source_classifier import bulk_classify # noqa: E402
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
||||||
)
|
|
||||||
logger = logging.getLogger("migrate_sources")
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
parser = argparse.ArgumentParser(description="LLM-Klassifikation aller Quellen.")
|
|
||||||
parser.add_argument("--limit", type=int, default=50, help="Max. Quellen pro Lauf")
|
|
||||||
parser.add_argument(
|
|
||||||
"--recheck-pending",
|
|
||||||
action="store_true",
|
|
||||||
help="Auch Quellen mit classification_source='llm_pending' neu klassifizieren",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
result = await bulk_classify(
|
|
||||||
db,
|
|
||||||
limit=args.limit,
|
|
||||||
only_unclassified=not args.recheck_pending,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
|
|
||||||
print(f"Verarbeitet: {result['processed']}")
|
|
||||||
print(f"Erfolgreich: {result['success']}")
|
|
||||||
print(f"Fehler: {len(result['errors'])}")
|
|
||||||
print(f"Kosten: ${result['total_cost_usd']:.4f}")
|
|
||||||
if result["errors"]:
|
|
||||||
print("\nFehler-Details:")
|
|
||||||
for e in result["errors"][:10]:
|
|
||||||
print(f" source_id={e['source_id']}: {e['error']}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -142,14 +142,6 @@ class IncidentListItem(BaseModel):
|
|||||||
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$"
|
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed)$"
|
||||||
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$"
|
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$"
|
||||||
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
|
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
|
||||||
POLITICAL_ORIENTATION_PATTERN = "^(links_extrem|links|mitte_links|liberal|mitte|konservativ|mitte_rechts|rechts|rechts_extrem|na)$"
|
|
||||||
MEDIA_TYPE_PATTERN = "^(tageszeitung|wochenzeitung|magazin|tv_sender|radio|oeffentlich_rechtlich|nachrichtenagentur|online_only|blog|telegram_kanal|telegram_bot|podcast|social_media|imageboard|think_tank|ngo|behoerde|staatsmedium|fachmedium|sonstige)$"
|
|
||||||
RELIABILITY_PATTERN = "^(sehr_hoch|hoch|gemischt|niedrig|sehr_niedrig|na)$"
|
|
||||||
ALIGNMENT_PATTERN = "^(prorussisch|proiranisch|prowestlich|proukrainisch|prochinesisch|projapanisch|proisraelisch|propalaestinensisch|protuerkisch|panarabisch|neutral|sonstige)$"
|
|
||||||
COUNTRY_CODE_PATTERN = "^[A-Z]{2}$"
|
|
||||||
CLASSIFICATION_SOURCE_PATTERN = "^(manual|llm_approved|llm_pending|legacy)$"
|
|
||||||
|
|
||||||
|
|
||||||
class SourceCreate(BaseModel):
|
class SourceCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200)
|
name: str = Field(min_length=1, max_length=200)
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
@@ -160,12 +152,6 @@ class SourceCreate(BaseModel):
|
|||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
bias: Optional[str] = None
|
bias: Optional[str] = None
|
||||||
political_orientation: Optional[str] = Field(default=None, pattern=POLITICAL_ORIENTATION_PATTERN)
|
|
||||||
media_type: Optional[str] = Field(default=None, pattern=MEDIA_TYPE_PATTERN)
|
|
||||||
reliability: Optional[str] = Field(default=None, pattern=RELIABILITY_PATTERN)
|
|
||||||
state_affiliated: Optional[bool] = None
|
|
||||||
country_code: Optional[str] = Field(default=None, pattern=COUNTRY_CODE_PATTERN)
|
|
||||||
alignments: Optional[list[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SourceUpdate(BaseModel):
|
class SourceUpdate(BaseModel):
|
||||||
@@ -178,12 +164,6 @@ class SourceUpdate(BaseModel):
|
|||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
bias: Optional[str] = None
|
bias: Optional[str] = None
|
||||||
political_orientation: Optional[str] = Field(default=None, pattern=POLITICAL_ORIENTATION_PATTERN)
|
|
||||||
media_type: Optional[str] = Field(default=None, pattern=MEDIA_TYPE_PATTERN)
|
|
||||||
reliability: Optional[str] = Field(default=None, pattern=RELIABILITY_PATTERN)
|
|
||||||
state_affiliated: Optional[bool] = None
|
|
||||||
country_code: Optional[str] = Field(default=None, pattern=COUNTRY_CODE_PATTERN)
|
|
||||||
alignments: Optional[list[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class SourceResponse(BaseModel):
|
class SourceResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Sources-Router: Quellenverwaltung (Multi-Tenant)."""
|
"""Sources-Router: Quellenverwaltung (Multi-Tenant). Klassifikation: Read-Only — Pflege in der Verwaltung."""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
|
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import db_dependency, get_db, refresh_source_counts
|
from database import db_dependency, refresh_source_counts
|
||||||
from services.external_reputation import apply_reputation_overrides, sync_all as sync_external_reputation
|
|
||||||
from services.source_classifier import bulk_classify, classify_source
|
|
||||||
from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name, _DOMAIN_ALIASES
|
from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name, _DOMAIN_ALIASES
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
@@ -18,22 +16,11 @@ router = APIRouter(prefix="/api/sources", tags=["sources"])
|
|||||||
SOURCE_UPDATE_COLUMNS = {
|
SOURCE_UPDATE_COLUMNS = {
|
||||||
"name", "url", "domain", "source_type", "category", "status", "notes",
|
"name", "url", "domain", "source_type", "category", "status", "notes",
|
||||||
"language", "bias",
|
"language", "bias",
|
||||||
"political_orientation", "media_type", "reliability",
|
|
||||||
"state_affiliated", "country_code",
|
|
||||||
}
|
|
||||||
SOURCE_CLASSIFICATION_FIELDS = {
|
|
||||||
"political_orientation", "media_type", "reliability",
|
|
||||||
"state_affiliated", "country_code",
|
|
||||||
}
|
|
||||||
ALLOWED_ALIGNMENTS = {
|
|
||||||
"prorussisch", "proiranisch", "prowestlich", "proukrainisch",
|
|
||||||
"prochinesisch", "projapanisch", "proisraelisch", "propalaestinensisch",
|
|
||||||
"protuerkisch", "panarabisch", "neutral", "sonstige",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _load_alignments_for(db: aiosqlite.Connection, source_ids: list[int]) -> dict[int, list[str]]:
|
async def _load_alignments_for(db: aiosqlite.Connection, source_ids: list[int]) -> dict[int, list[str]]:
|
||||||
"""Lädt alignments fuer mehrere Quellen in einer Query und gibt {source_id: [alignment, ...]} zurück."""
|
"""Lädt alignments fuer mehrere Quellen — Read-Only fuer Anzeige (Pflege in Verwaltung)."""
|
||||||
if not source_ids:
|
if not source_ids:
|
||||||
return {}
|
return {}
|
||||||
placeholders = ",".join("?" for _ in source_ids)
|
placeholders = ",".join("?" for _ in source_ids)
|
||||||
@@ -47,26 +34,6 @@ async def _load_alignments_for(db: aiosqlite.Connection, source_ids: list[int])
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _replace_alignments(db: aiosqlite.Connection, source_id: int, alignments: list[str]):
|
|
||||||
"""Ersetzt die alignments-Liste einer Quelle (DELETE + INSERT) — Aufrufer muss commit() machen."""
|
|
||||||
await db.execute("DELETE FROM source_alignments WHERE source_id = ?", (source_id,))
|
|
||||||
seen: set[str] = set()
|
|
||||||
for raw in alignments:
|
|
||||||
a = (raw or "").strip().lower()
|
|
||||||
if not a or a in seen:
|
|
||||||
continue
|
|
||||||
if a not in ALLOWED_ALIGNMENTS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail=f"Ungueltiger alignment-Wert: '{a}'",
|
|
||||||
)
|
|
||||||
seen.add(a)
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO source_alignments (source_id, alignment) VALUES (?, ?)",
|
|
||||||
(source_id, a),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_source_ownership(source: dict, username: str):
|
def _check_source_ownership(source: dict, username: str):
|
||||||
"""Prueft ob der Nutzer die Quelle bearbeiten/loeschen darf.
|
"""Prueft ob der Nutzer die Quelle bearbeiten/loeschen darf.
|
||||||
|
|
||||||
@@ -538,14 +505,9 @@ async def create_source(
|
|||||||
)
|
)
|
||||||
|
|
||||||
payload = data.model_dump(exclude_unset=True)
|
payload = data.model_dump(exclude_unset=True)
|
||||||
alignments = payload.pop("alignments", None)
|
|
||||||
classification_touched = bool(SOURCE_CLASSIFICATION_FIELDS & payload.keys()) or alignments is not None
|
|
||||||
|
|
||||||
cols = ["name", "url", "domain", "source_type", "category", "status", "notes",
|
cols = ["name", "url", "domain", "source_type", "category", "status", "notes",
|
||||||
"language", "bias",
|
"language", "bias", "added_by", "tenant_id"]
|
||||||
"political_orientation", "media_type", "reliability",
|
|
||||||
"state_affiliated", "country_code",
|
|
||||||
"added_by", "tenant_id"]
|
|
||||||
vals = [
|
vals = [
|
||||||
data.name,
|
data.name,
|
||||||
data.url,
|
data.url,
|
||||||
@@ -556,31 +518,16 @@ async def create_source(
|
|||||||
data.notes,
|
data.notes,
|
||||||
payload.get("language"),
|
payload.get("language"),
|
||||||
payload.get("bias"),
|
payload.get("bias"),
|
||||||
payload.get("political_orientation"),
|
|
||||||
payload.get("media_type"),
|
|
||||||
payload.get("reliability"),
|
|
||||||
1 if payload.get("state_affiliated") else 0,
|
|
||||||
payload.get("country_code"),
|
|
||||||
current_user["username"],
|
current_user["username"],
|
||||||
tenant_id,
|
tenant_id,
|
||||||
]
|
]
|
||||||
if classification_touched:
|
|
||||||
cols += ["classification_source", "classified_at"]
|
|
||||||
vals += ["manual"]
|
|
||||||
ts_marker = True
|
|
||||||
else:
|
|
||||||
ts_marker = False
|
|
||||||
|
|
||||||
placeholders = ", ".join(["?"] * len(vals) + (["CURRENT_TIMESTAMP"] if ts_marker else []))
|
placeholders = ", ".join(["?"] * len(vals))
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
f"INSERT INTO sources ({', '.join(cols)}) VALUES ({placeholders})",
|
f"INSERT INTO sources ({', '.join(cols)}) VALUES ({placeholders})",
|
||||||
vals,
|
vals,
|
||||||
)
|
)
|
||||||
new_id = cursor.lastrowid
|
new_id = cursor.lastrowid
|
||||||
|
|
||||||
if alignments:
|
|
||||||
await _replace_alignments(db, new_id, alignments)
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (new_id,))
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (new_id,))
|
||||||
@@ -612,40 +559,19 @@ async def update_source(
|
|||||||
_check_source_ownership(dict(row), current_user["username"])
|
_check_source_ownership(dict(row), current_user["username"])
|
||||||
|
|
||||||
payload = data.model_dump(exclude_unset=True)
|
payload = data.model_dump(exclude_unset=True)
|
||||||
alignments = payload.pop("alignments", None)
|
|
||||||
|
|
||||||
updates = {}
|
updates = {}
|
||||||
for field, value in payload.items():
|
for field, value in payload.items():
|
||||||
if field not in SOURCE_UPDATE_COLUMNS:
|
if field not in SOURCE_UPDATE_COLUMNS:
|
||||||
continue
|
continue
|
||||||
# Domain normalisieren
|
|
||||||
if field == "domain" and value:
|
if field == "domain" and value:
|
||||||
value = _DOMAIN_ALIASES.get(value.lower(), value.lower())
|
value = _DOMAIN_ALIASES.get(value.lower(), value.lower())
|
||||||
if field == "state_affiliated":
|
|
||||||
value = 1 if value else 0
|
|
||||||
updates[field] = value
|
updates[field] = value
|
||||||
|
|
||||||
classification_touched = bool(SOURCE_CLASSIFICATION_FIELDS & updates.keys()) or alignments is not None
|
|
||||||
if classification_touched:
|
|
||||||
updates["classification_source"] = "manual"
|
|
||||||
updates["classified_at"] = "CURRENT_TIMESTAMP_MARKER"
|
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
set_parts = []
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
values = []
|
values = list(updates.values()) + [source_id]
|
||||||
for k, v in updates.items():
|
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
|
||||||
if v == "CURRENT_TIMESTAMP_MARKER":
|
|
||||||
set_parts.append(f"{k} = CURRENT_TIMESTAMP")
|
|
||||||
else:
|
|
||||||
set_parts.append(f"{k} = ?")
|
|
||||||
values.append(v)
|
|
||||||
values.append(source_id)
|
|
||||||
await db.execute(f"UPDATE sources SET {', '.join(set_parts)} WHERE id = ?", values)
|
|
||||||
|
|
||||||
if alignments is not None:
|
|
||||||
await _replace_alignments(db, source_id, alignments)
|
|
||||||
|
|
||||||
if updates or alignments is not None:
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
||||||
@@ -714,327 +640,3 @@ async def trigger_refresh_counts(
|
|||||||
await refresh_source_counts(db)
|
await refresh_source_counts(db)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# === Klassifikations-Review (LLM-Vorschlaege approve/reject/reclassify) ===
|
|
||||||
|
|
||||||
def _require_admin_for_global(row: dict, current_user: dict):
|
|
||||||
"""Globale Quellen (tenant_id IS NULL) duerfen nur org_admins approve-en/reclassify-en."""
|
|
||||||
if row.get("tenant_id") is None and current_user.get("role") != "org_admin":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Globale Quellen koennen nur von Admins klassifiziert werden",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/classification/stats")
|
|
||||||
async def classification_stats(
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Counts pro classification_source-Wert (global + eigene Org)."""
|
|
||||||
tenant_id = current_user.get("tenant_id")
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT classification_source, COUNT(*) as cnt
|
|
||||||
FROM sources
|
|
||||||
WHERE (tenant_id IS NULL OR tenant_id = ?) AND status = 'active'
|
|
||||||
GROUP BY classification_source""",
|
|
||||||
(tenant_id,),
|
|
||||||
)
|
|
||||||
by_source = {row["classification_source"] or "legacy": row["cnt"] for row in await cursor.fetchall()}
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT COUNT(*) as cnt FROM sources
|
|
||||||
WHERE (tenant_id IS NULL OR tenant_id = ?) AND status = 'active'
|
|
||||||
AND proposed_political_orientation IS NOT NULL""",
|
|
||||||
(tenant_id,),
|
|
||||||
)
|
|
||||||
pending = (await cursor.fetchone())["cnt"]
|
|
||||||
return {
|
|
||||||
"by_classification_source": by_source,
|
|
||||||
"pending_review": pending,
|
|
||||||
"total": sum(by_source.values()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/classification/queue")
|
|
||||||
async def classification_queue(
|
|
||||||
limit: int = 50,
|
|
||||||
min_confidence: float = 0.0,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Liefert Quellen mit nicht-leeren proposed_*-Spalten (Review-Queue)."""
|
|
||||||
tenant_id = current_user.get("tenant_id")
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT s.* FROM sources s
|
|
||||||
WHERE (s.tenant_id IS NULL OR s.tenant_id = ?)
|
|
||||||
AND s.proposed_political_orientation IS NOT NULL
|
|
||||||
AND COALESCE(s.proposed_confidence, 0) >= ?
|
|
||||||
ORDER BY s.proposed_confidence DESC, s.proposed_at DESC
|
|
||||||
LIMIT ?""",
|
|
||||||
(tenant_id, min_confidence, limit),
|
|
||||||
)
|
|
||||||
rows = [dict(r) for r in await cursor.fetchall()]
|
|
||||||
alignments_map = await _load_alignments_for(db, [r["id"] for r in rows])
|
|
||||||
out = []
|
|
||||||
for d in rows:
|
|
||||||
try:
|
|
||||||
proposed_aligns = json.loads(d.get("proposed_alignments_json") or "[]")
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
proposed_aligns = []
|
|
||||||
out.append({
|
|
||||||
"id": d["id"],
|
|
||||||
"name": d["name"],
|
|
||||||
"url": d.get("url"),
|
|
||||||
"domain": d.get("domain"),
|
|
||||||
"source_type": d.get("source_type"),
|
|
||||||
"category": d.get("category"),
|
|
||||||
"is_global": d.get("tenant_id") is None,
|
|
||||||
"current": {
|
|
||||||
"political_orientation": d.get("political_orientation"),
|
|
||||||
"media_type": d.get("media_type"),
|
|
||||||
"reliability": d.get("reliability"),
|
|
||||||
"state_affiliated": bool(d.get("state_affiliated")),
|
|
||||||
"country_code": d.get("country_code"),
|
|
||||||
"alignments": alignments_map.get(d["id"], []),
|
|
||||||
"classification_source": d.get("classification_source"),
|
|
||||||
},
|
|
||||||
"proposed": {
|
|
||||||
"political_orientation": d.get("proposed_political_orientation"),
|
|
||||||
"media_type": d.get("proposed_media_type"),
|
|
||||||
"reliability": d.get("proposed_reliability"),
|
|
||||||
"state_affiliated": bool(d.get("proposed_state_affiliated")),
|
|
||||||
"country_code": d.get("proposed_country_code"),
|
|
||||||
"alignments": proposed_aligns,
|
|
||||||
"confidence": d.get("proposed_confidence"),
|
|
||||||
"reasoning": d.get("proposed_reasoning"),
|
|
||||||
"proposed_at": d.get("proposed_at"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _clear_proposed(db: aiosqlite.Connection, source_id: int):
|
|
||||||
"""Loescht die proposed_*-Felder einer Quelle (ohne commit)."""
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE sources SET
|
|
||||||
proposed_political_orientation = NULL,
|
|
||||||
proposed_media_type = NULL,
|
|
||||||
proposed_reliability = NULL,
|
|
||||||
proposed_state_affiliated = NULL,
|
|
||||||
proposed_country_code = NULL,
|
|
||||||
proposed_alignments_json = NULL,
|
|
||||||
proposed_confidence = NULL,
|
|
||||||
proposed_reasoning = NULL,
|
|
||||||
proposed_at = NULL
|
|
||||||
WHERE id = ?""",
|
|
||||||
(source_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{source_id}/classification/approve")
|
|
||||||
async def approve_classification(
|
|
||||||
source_id: int,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Uebernimmt proposed_* in echte Felder, setzt classification_source='llm_approved'."""
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
|
|
||||||
src = dict(row)
|
|
||||||
_require_admin_for_global(src, current_user)
|
|
||||||
|
|
||||||
if src.get("proposed_political_orientation") is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Keine LLM-Vorschlaege fuer diese Quelle vorhanden")
|
|
||||||
|
|
||||||
try:
|
|
||||||
proposed_aligns = json.loads(src.get("proposed_alignments_json") or "[]")
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
proposed_aligns = []
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE sources SET
|
|
||||||
political_orientation = ?,
|
|
||||||
media_type = ?,
|
|
||||||
reliability = ?,
|
|
||||||
state_affiliated = ?,
|
|
||||||
country_code = ?,
|
|
||||||
classification_source = 'llm_approved',
|
|
||||||
classified_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?""",
|
|
||||||
(
|
|
||||||
src["proposed_political_orientation"],
|
|
||||||
src["proposed_media_type"],
|
|
||||||
src["proposed_reliability"],
|
|
||||||
1 if src.get("proposed_state_affiliated") else 0,
|
|
||||||
src.get("proposed_country_code"),
|
|
||||||
source_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await _replace_alignments(db, source_id, [a for a in proposed_aligns if a in ALLOWED_ALIGNMENTS])
|
|
||||||
await _clear_proposed(db, source_id)
|
|
||||||
await db.commit()
|
|
||||||
# Reliability-Override anwenden (IFCN/EUvsDisinfo)
|
|
||||||
try:
|
|
||||||
await apply_reputation_overrides(db, source_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Reputation-Override fuer source_id=%s fehlgeschlagen: %s", source_id, e)
|
|
||||||
return {"source_id": source_id, "status": "approved"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{source_id}/classification/reject")
|
|
||||||
async def reject_classification(
|
|
||||||
source_id: int,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Verwirft die LLM-Vorschlaege ohne Uebernahme. classification_source bleibt unveraendert."""
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
|
|
||||||
src = dict(row)
|
|
||||||
_require_admin_for_global(src, current_user)
|
|
||||||
|
|
||||||
await _clear_proposed(db, source_id)
|
|
||||||
# Wenn classification_source noch 'llm_pending' war, zurueck auf 'legacy'
|
|
||||||
if src.get("classification_source") == "llm_pending":
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE sources SET classification_source = 'legacy' WHERE id = ?",
|
|
||||||
(source_id,),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {"source_id": source_id, "status": "rejected"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{source_id}/classification/reclassify")
|
|
||||||
async def reclassify_source(
|
|
||||||
source_id: int,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Triggert eine LLM-Klassifikation einer einzelnen Quelle (synchron, ~3-5s)."""
|
|
||||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
|
|
||||||
src = dict(row)
|
|
||||||
_require_admin_for_global(src, current_user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await classify_source(db, source_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Reclassify source_id=%s fehlgeschlagen: %s", source_id, e, exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=f"Klassifikation fehlgeschlagen: {e}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _bulk_classify_background(limit: int, only_unclassified: bool):
|
|
||||||
"""Hintergrund-Task: oeffnet eigene DB-Connection."""
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
await bulk_classify(db, limit=limit, only_unclassified=only_unclassified)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/classification/bulk-classify")
|
|
||||||
async def trigger_bulk_classify(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
limit: int = 50,
|
|
||||||
only_unclassified: bool = True,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Startet eine Bulk-Klassifikation im Hintergrund (nur Admins)."""
|
|
||||||
if current_user.get("role") != "org_admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Nur Admins koennen Bulk-Klassifikation starten")
|
|
||||||
if limit < 1 or limit > 500:
|
|
||||||
raise HTTPException(status_code=400, detail="limit muss zwischen 1 und 500 liegen")
|
|
||||||
background_tasks.add_task(_bulk_classify_background, limit, only_unclassified)
|
|
||||||
return {"status": "started", "limit": limit, "only_unclassified": only_unclassified}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/external-reputation/sync")
|
|
||||||
async def trigger_external_reputation_sync(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Startet Sync von IFCN- und EUvsDisinfo-Daten (Admin, Hintergrund)."""
|
|
||||||
if current_user.get("role") != "org_admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Nur Admins koennen den externen Sync starten")
|
|
||||||
|
|
||||||
async def _bg():
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
await sync_external_reputation(db)
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
|
|
||||||
background_tasks.add_task(_bg)
|
|
||||||
return {"status": "started"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/classification/bulk-approve")
|
|
||||||
async def bulk_approve_classifications(
|
|
||||||
min_confidence: float = 0.85,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
|
||||||
):
|
|
||||||
"""Genehmigt alle Pending-Vorschlaege ueber dem confidence-Schwellwert (nur Admins).
|
|
||||||
|
|
||||||
Globale Quellen werden nur bearbeitet, wenn der Aufrufer org_admin ist;
|
|
||||||
Tenant-eigene Quellen sowieso.
|
|
||||||
"""
|
|
||||||
if current_user.get("role") != "org_admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Nur Admins koennen Bulk-Approve nutzen")
|
|
||||||
tenant_id = current_user.get("tenant_id")
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT id, proposed_political_orientation, proposed_media_type,
|
|
||||||
proposed_reliability, proposed_state_affiliated,
|
|
||||||
proposed_country_code, proposed_alignments_json, tenant_id
|
|
||||||
FROM sources
|
|
||||||
WHERE proposed_political_orientation IS NOT NULL
|
|
||||||
AND COALESCE(proposed_confidence, 0) >= ?
|
|
||||||
AND (tenant_id IS NULL OR tenant_id = ?)""",
|
|
||||||
(min_confidence, tenant_id),
|
|
||||||
)
|
|
||||||
rows = [dict(r) for r in await cursor.fetchall()]
|
|
||||||
approved_ids: list[int] = []
|
|
||||||
for src in rows:
|
|
||||||
try:
|
|
||||||
proposed_aligns = json.loads(src.get("proposed_alignments_json") or "[]")
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
proposed_aligns = []
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE sources SET
|
|
||||||
political_orientation = ?,
|
|
||||||
media_type = ?,
|
|
||||||
reliability = ?,
|
|
||||||
state_affiliated = ?,
|
|
||||||
country_code = ?,
|
|
||||||
classification_source = 'llm_approved',
|
|
||||||
classified_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?""",
|
|
||||||
(
|
|
||||||
src["proposed_political_orientation"],
|
|
||||||
src["proposed_media_type"],
|
|
||||||
src["proposed_reliability"],
|
|
||||||
1 if src.get("proposed_state_affiliated") else 0,
|
|
||||||
src.get("proposed_country_code"),
|
|
||||||
src["id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await _replace_alignments(
|
|
||||||
db, src["id"], [a for a in proposed_aligns if a in ALLOWED_ALIGNMENTS]
|
|
||||||
)
|
|
||||||
await _clear_proposed(db, src["id"])
|
|
||||||
approved_ids.append(src["id"])
|
|
||||||
await db.commit()
|
|
||||||
# Reliability-Override fuer alle gerade Approved
|
|
||||||
try:
|
|
||||||
for sid in approved_ids:
|
|
||||||
await apply_reputation_overrides(db, sid)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Bulk Reputation-Override fehlgeschlagen: %s", e)
|
|
||||||
return {"approved_count": len(approved_ids), "min_confidence": min_confidence}
|
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
"""Externe Reputations-Daten fuer Quellen.
|
|
||||||
|
|
||||||
Synchronisiert Domain-Listen von oeffentlichen Reputations-/Faktencheck-Datenbanken
|
|
||||||
und schreibt die Treffer in die sources-Spalten:
|
|
||||||
|
|
||||||
- IFCN-Signatories (anerkannte Faktenchecker) -> ifcn_signatory
|
|
||||||
- EUvsDisinfo (pro-Kreml-Desinformation, Zenodo-CSV) -> eu_disinfo_listed,
|
|
||||||
eu_disinfo_case_count, eu_disinfo_last_seen
|
|
||||||
|
|
||||||
Anschliessend wendet apply_reputation_overrides() Override-Regeln auf die
|
|
||||||
reliability-Spalte an:
|
|
||||||
- ifcn_signatory=1 -> reliability='sehr_hoch'
|
|
||||||
- eu_disinfo_case_count >= 5 -> reliability='sehr_niedrig'
|
|
||||||
- eu_disinfo_case_count >= 1 -> reliability eine Stufe runter (max bis 'niedrig')
|
|
||||||
"""
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import aiosqlite
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger("osint.external_reputation")
|
|
||||||
|
|
||||||
IFCN_LIST_URL = "https://raw.githubusercontent.com/IFCN/verified-signatories/main/list"
|
|
||||||
EU_DISINFO_CSV_URL = "https://zenodo.org/records/10514307/files/euvsdisinfo_base.csv?download=1"
|
|
||||||
|
|
||||||
HTTP_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
|
|
||||||
|
|
||||||
# Generische Plattform-Domains, die NICHT als Quelle markiert werden duerfen
|
|
||||||
# (EUvsDisinfo aggregiert anonyme Telegram-/Twitter-Posts unter Plattform-Domains).
|
|
||||||
PLATFORM_DOMAINS = {
|
|
||||||
"t.me", "telegram.me", "telegram.org",
|
|
||||||
"twitter.com", "x.com", "mobile.twitter.com",
|
|
||||||
"youtube.com", "youtu.be", "m.youtube.com",
|
|
||||||
"facebook.com", "fb.com", "m.facebook.com",
|
|
||||||
"instagram.com", "tiktok.com", "vk.com", "ok.ru",
|
|
||||||
"rumble.com", "bitchute.com", "odysee.com",
|
|
||||||
"reddit.com", "old.reddit.com",
|
|
||||||
"wordpress.com", "blogspot.com", "medium.com",
|
|
||||||
"substack.com", "wixsite.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reliability-Skala in Stufenfolge (schlecht -> gut)
|
|
||||||
RELIABILITY_ORDER = ["sehr_niedrig", "niedrig", "gemischt", "hoch", "sehr_hoch"]
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_domain(raw: str | None) -> str | None:
|
|
||||||
"""Normalisiert eine Domain: lowercase, ohne www., ohne Schema/Pfad."""
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
raw = raw.strip().lower()
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
# Falls eine vollstaendige URL uebergeben wurde
|
|
||||||
if "://" in raw:
|
|
||||||
try:
|
|
||||||
raw = urlparse(raw).netloc or raw
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# Pfad/Query strippen
|
|
||||||
raw = raw.split("/")[0].split("?")[0].split("#")[0]
|
|
||||||
if raw.startswith("www."):
|
|
||||||
raw = raw[4:]
|
|
||||||
return raw or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_text(url: str) -> str:
|
|
||||||
"""Laedt Text von einer URL. Wirft HTTPException bei Fehler."""
|
|
||||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT, follow_redirects=True) as client:
|
|
||||||
resp = await client.get(url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.text
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_ifcn_signatories(db: aiosqlite.Connection) -> dict:
|
|
||||||
"""Laedt IFCN-Domain-Liste und matcht gegen sources.domain.
|
|
||||||
|
|
||||||
Setzt ifcn_signatory=1 wo die Domain in der Liste vorkommt, sonst 0.
|
|
||||||
"""
|
|
||||||
text = await _fetch_text(IFCN_LIST_URL)
|
|
||||||
domains: set[str] = set()
|
|
||||||
for line in text.splitlines():
|
|
||||||
d = _normalize_domain(line)
|
|
||||||
if d:
|
|
||||||
domains.add(d)
|
|
||||||
logger.info("IFCN-Liste geladen: %d Domains", len(domains))
|
|
||||||
|
|
||||||
# Aktuelle Quellen mit Domain laden
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT id, domain FROM sources WHERE domain IS NOT NULL AND domain != ''"
|
|
||||||
)
|
|
||||||
sources = [dict(r) for r in await cursor.fetchall()]
|
|
||||||
|
|
||||||
matched_ids: list[int] = []
|
|
||||||
unmatched_ids: list[int] = []
|
|
||||||
for s in sources:
|
|
||||||
nd = _normalize_domain(s["domain"])
|
|
||||||
if nd and nd not in PLATFORM_DOMAINS and nd in domains:
|
|
||||||
matched_ids.append(s["id"])
|
|
||||||
else:
|
|
||||||
unmatched_ids.append(s["id"])
|
|
||||||
|
|
||||||
# Bulk-Update in zwei Statements
|
|
||||||
if matched_ids:
|
|
||||||
placeholders = ",".join("?" for _ in matched_ids)
|
|
||||||
await db.execute(
|
|
||||||
f"UPDATE sources SET ifcn_signatory = 1 WHERE id IN ({placeholders})",
|
|
||||||
matched_ids,
|
|
||||||
)
|
|
||||||
if unmatched_ids:
|
|
||||||
placeholders = ",".join("?" for _ in unmatched_ids)
|
|
||||||
await db.execute(
|
|
||||||
f"UPDATE sources SET ifcn_signatory = 0 WHERE id IN ({placeholders})",
|
|
||||||
unmatched_ids,
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
logger.info("IFCN-Sync: %d Quellen als Faktenchecker markiert (von %d)",
|
|
||||||
len(matched_ids), len(sources))
|
|
||||||
return {
|
|
||||||
"list_size": len(domains),
|
|
||||||
"sources_checked": len(sources),
|
|
||||||
"matched": len(matched_ids),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_eu_disinfo(db: aiosqlite.Connection) -> dict:
|
|
||||||
"""Laedt EUvsDisinfo-CSV von Zenodo, aggregiert pro Domain, schreibt sources.
|
|
||||||
|
|
||||||
- eu_disinfo_listed: 1 wenn Domain mindestens 1x als 'disinformation' debunkt
|
|
||||||
- eu_disinfo_case_count: Anzahl Disinformation-Faelle
|
|
||||||
- eu_disinfo_last_seen: spaetestes debunk_date
|
|
||||||
"""
|
|
||||||
text = await _fetch_text(EU_DISINFO_CSV_URL)
|
|
||||||
reader = csv.DictReader(io.StringIO(text))
|
|
||||||
|
|
||||||
# Per-Domain aggregieren (nur class='disinformation')
|
|
||||||
counts: dict[str, int] = defaultdict(int)
|
|
||||||
last_seen: dict[str, str] = {}
|
|
||||||
total_rows = 0
|
|
||||||
for row in reader:
|
|
||||||
total_rows += 1
|
|
||||||
if (row.get("class") or "").strip().lower() != "disinformation":
|
|
||||||
continue
|
|
||||||
d = _normalize_domain(row.get("article_domain"))
|
|
||||||
if not d:
|
|
||||||
continue
|
|
||||||
counts[d] += 1
|
|
||||||
debunk_date = (row.get("debunk_date") or "").strip()
|
|
||||||
if debunk_date:
|
|
||||||
prev = last_seen.get(d)
|
|
||||||
if not prev or debunk_date > prev:
|
|
||||||
last_seen[d] = debunk_date
|
|
||||||
logger.info("EUvsDisinfo-CSV: %d Zeilen, %d Domains mit Desinformation",
|
|
||||||
total_rows, len(counts))
|
|
||||||
|
|
||||||
# Quellen laden + matchen
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT id, domain FROM sources WHERE domain IS NOT NULL AND domain != ''"
|
|
||||||
)
|
|
||||||
sources = [dict(r) for r in await cursor.fetchall()]
|
|
||||||
|
|
||||||
matched = 0
|
|
||||||
for s in sources:
|
|
||||||
nd = _normalize_domain(s["domain"])
|
|
||||||
if nd and nd not in PLATFORM_DOMAINS and nd in counts:
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE sources SET
|
|
||||||
eu_disinfo_listed = 1,
|
|
||||||
eu_disinfo_case_count = ?,
|
|
||||||
eu_disinfo_last_seen = ?
|
|
||||||
WHERE id = ?""",
|
|
||||||
(counts[nd], last_seen.get(nd), s["id"]),
|
|
||||||
)
|
|
||||||
matched += 1
|
|
||||||
else:
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE sources SET
|
|
||||||
eu_disinfo_listed = 0,
|
|
||||||
eu_disinfo_case_count = 0,
|
|
||||||
eu_disinfo_last_seen = NULL
|
|
||||||
WHERE id = ?""",
|
|
||||||
(s["id"],),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
logger.info("EUvsDisinfo-Sync: %d Quellen als Desinformations-Quelle markiert (von %d)",
|
|
||||||
matched, len(sources))
|
|
||||||
return {
|
|
||||||
"rows_in_csv": total_rows,
|
|
||||||
"domains_with_disinfo_in_csv": len(counts),
|
|
||||||
"sources_checked": len(sources),
|
|
||||||
"matched": matched,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _override_reliability(current: str | None, ifcn: bool, eu_count: int) -> str | None:
|
|
||||||
"""Wendet Override-Regeln auf eine reliability-Stufe an.
|
|
||||||
|
|
||||||
Rueckgabe: neue Stufe (oder None, wenn unveraendert).
|
|
||||||
"""
|
|
||||||
cur = current or "na"
|
|
||||||
|
|
||||||
# IFCN gewinnt: zertifizierter Faktenchecker -> sehr_hoch (immer)
|
|
||||||
if ifcn:
|
|
||||||
return "sehr_hoch" if cur != "sehr_hoch" else None
|
|
||||||
|
|
||||||
# EUvsDisinfo: Downgrade
|
|
||||||
if eu_count >= 5:
|
|
||||||
return "sehr_niedrig" if cur != "sehr_niedrig" else None
|
|
||||||
if eu_count >= 1:
|
|
||||||
# Eine Stufe runter, mindestens bis 'niedrig'
|
|
||||||
if cur == "na":
|
|
||||||
return "niedrig"
|
|
||||||
if cur in RELIABILITY_ORDER:
|
|
||||||
idx = RELIABILITY_ORDER.index(cur)
|
|
||||||
new_idx = max(0, idx - 1)
|
|
||||||
new = RELIABILITY_ORDER[new_idx]
|
|
||||||
# Mindeststufe 'niedrig' bei eu_count >= 1
|
|
||||||
if RELIABILITY_ORDER.index(new) > RELIABILITY_ORDER.index("niedrig"):
|
|
||||||
new = "niedrig"
|
|
||||||
return new if new != cur else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_reputation_overrides(db: aiosqlite.Connection, source_id: int | None = None) -> dict:
|
|
||||||
"""Wendet Reliability-Override-Regeln an.
|
|
||||||
|
|
||||||
Wenn source_id angegeben ist, nur fuer diese Quelle. Sonst fuer alle Quellen.
|
|
||||||
"""
|
|
||||||
if source_id is not None:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT id, reliability, ifcn_signatory, eu_disinfo_case_count "
|
|
||||||
"FROM sources WHERE id = ?",
|
|
||||||
(source_id,),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT id, reliability, ifcn_signatory, eu_disinfo_case_count FROM sources"
|
|
||||||
)
|
|
||||||
sources = [dict(r) for r in await cursor.fetchall()]
|
|
||||||
|
|
||||||
changed = 0
|
|
||||||
for s in sources:
|
|
||||||
new = _override_reliability(
|
|
||||||
s.get("reliability"),
|
|
||||||
bool(s.get("ifcn_signatory")),
|
|
||||||
int(s.get("eu_disinfo_case_count") or 0),
|
|
||||||
)
|
|
||||||
if new is not None:
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE sources SET reliability = ? WHERE id = ?",
|
|
||||||
(new, s["id"]),
|
|
||||||
)
|
|
||||||
changed += 1
|
|
||||||
await db.commit()
|
|
||||||
logger.info("Reliability-Override: %d Quellen angepasst (von %d gepruefte)",
|
|
||||||
changed, len(sources))
|
|
||||||
return {"checked": len(sources), "changed": changed}
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_all(db: aiosqlite.Connection) -> dict:
|
|
||||||
"""Vollstaendiger Sync: IFCN + EUvsDisinfo + Reliability-Override.
|
|
||||||
|
|
||||||
Setzt external_data_synced_at fuer alle Quellen.
|
|
||||||
"""
|
|
||||||
ifcn_result = await sync_ifcn_signatories(db)
|
|
||||||
eu_result = await sync_eu_disinfo(db)
|
|
||||||
override_result = await apply_reputation_overrides(db)
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE sources SET external_data_synced_at = CURRENT_TIMESTAMP "
|
|
||||||
"WHERE domain IS NOT NULL AND domain != ''"
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ifcn": ifcn_result,
|
|
||||||
"eu_disinfo": eu_result,
|
|
||||||
"override": override_result,
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
"""Klassifiziert Quellen via Claude (Haiku) nach 4 Achsen + state_affiliated + country.
|
|
||||||
|
|
||||||
Schreibt Vorschlaege in die proposed_*-Spalten von sources und setzt
|
|
||||||
classification_source='llm_pending'. Approval erfolgt ueber separate Endpoints,
|
|
||||||
die proposed_* in die echten Spalten kopieren.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
import aiosqlite
|
|
||||||
|
|
||||||
from agents.claude_client import call_claude
|
|
||||||
from config import CLAUDE_MODEL_FAST
|
|
||||||
|
|
||||||
logger = logging.getLogger("osint.source_classifier")
|
|
||||||
|
|
||||||
POLITICAL_VALUES = {
|
|
||||||
"links_extrem", "links", "mitte_links", "liberal", "mitte",
|
|
||||||
"konservativ", "mitte_rechts", "rechts", "rechts_extrem", "na",
|
|
||||||
}
|
|
||||||
MEDIA_TYPE_VALUES = {
|
|
||||||
"tageszeitung", "wochenzeitung", "magazin", "tv_sender", "radio",
|
|
||||||
"oeffentlich_rechtlich", "nachrichtenagentur", "online_only", "blog",
|
|
||||||
"telegram_kanal", "telegram_bot", "podcast", "social_media", "imageboard",
|
|
||||||
"think_tank", "ngo", "behoerde", "staatsmedium", "fachmedium", "sonstige",
|
|
||||||
}
|
|
||||||
RELIABILITY_VALUES = {"sehr_hoch", "hoch", "gemischt", "niedrig", "sehr_niedrig", "na"}
|
|
||||||
ALIGNMENT_VALUES = {
|
|
||||||
"prorussisch", "proiranisch", "prowestlich", "proukrainisch",
|
|
||||||
"prochinesisch", "projapanisch", "proisraelisch", "propalaestinensisch",
|
|
||||||
"protuerkisch", "panarabisch", "neutral", "sonstige",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(src: dict, sample_articles: list[dict]) -> str:
|
|
||||||
sample_text = ""
|
|
||||||
if sample_articles:
|
|
||||||
lines = []
|
|
||||||
for i, art in enumerate(sample_articles[:5], 1):
|
|
||||||
headline = (art.get("headline") or art.get("headline_de") or "").strip()
|
|
||||||
if headline:
|
|
||||||
lines.append(f"{i}. {headline[:200]}")
|
|
||||||
if lines:
|
|
||||||
sample_text = "\nLetzte Artikel/Headlines:\n" + "\n".join(lines)
|
|
||||||
|
|
||||||
return f"""Du bist ein OSINT-Analyst und klassifizierst Nachrichten- und Medienquellen fuer ein Lagebild-Monitoring-System (DACH-Raum).
|
|
||||||
|
|
||||||
QUELLE:
|
|
||||||
Name: {src.get('name')}
|
|
||||||
URL: {src.get('url') or '-'}
|
|
||||||
Domain: {src.get('domain') or '-'}
|
|
||||||
Quellentyp: {src.get('source_type')}
|
|
||||||
Bisherige Kategorie: {src.get('category')}
|
|
||||||
Sprache: {src.get('language') or 'unbekannt'}
|
|
||||||
Bisherige Notiz (Freitext): {src.get('bias') or '-'}{sample_text}
|
|
||||||
|
|
||||||
AUFGABE: Klassifiziere die Quelle nach folgenden Achsen.
|
|
||||||
|
|
||||||
1. political_orientation:
|
|
||||||
- links_extrem (z.B. linksunten.indymedia)
|
|
||||||
- links (klar links, z.B. junge Welt, taz)
|
|
||||||
- mitte_links (linksliberal/sozialdemokratisch, z.B. SZ, Spiegel)
|
|
||||||
- liberal (wirtschafts-/grünliberal, z.B. NZZ, Zeit)
|
|
||||||
- mitte (politisch neutral, Agentur, z.B. dpa, Reuters, tagesschau)
|
|
||||||
- konservativ (buergerlich-konservativ, z.B. FAZ, Welt)
|
|
||||||
- mitte_rechts (rechts-buergerlich, z.B. Tichys Einblick, Achgut)
|
|
||||||
- rechts (klar rechts, z.B. Junge Freiheit, EpochTimes)
|
|
||||||
- rechts_extrem (z.B. Compact, PI-News)
|
|
||||||
- na (nicht klassifizierbar: Behoerde, Fachmedium, Think Tank ohne klare politische Linie)
|
|
||||||
|
|
||||||
2. media_type (genau einer):
|
|
||||||
tageszeitung, wochenzeitung, magazin, tv_sender, radio, oeffentlich_rechtlich,
|
|
||||||
nachrichtenagentur, online_only, blog, telegram_kanal, telegram_bot, podcast,
|
|
||||||
social_media, imageboard, think_tank, ngo, behoerde, staatsmedium, fachmedium, sonstige
|
|
||||||
|
|
||||||
3. reliability:
|
|
||||||
- sehr_hoch (etablierte Qualitaet, Faktencheck: tagesschau, dpa, FAZ, Reuters)
|
|
||||||
- hoch (serioes mit gelegentlichen Schwaechen: taz, Welt, BILD bei harten News)
|
|
||||||
- gemischt (Mix Meinung/Einseitigkeit: Tichys Einblick, Achgut, Boulevard)
|
|
||||||
- niedrig (haeufig irrefuehrend, schwache Quellenarbeit: Junge Freiheit, EpochTimes)
|
|
||||||
- sehr_niedrig (bekannt fuer Desinformation/Verschwoerung: Compact, RT, Sputnik, PI-News)
|
|
||||||
- na (nicht bewertbar)
|
|
||||||
|
|
||||||
4. alignments (Mehrfach, leeres Array wenn keine ausgepraegte Naehe):
|
|
||||||
prorussisch, proiranisch, prowestlich, proukrainisch, prochinesisch, projapanisch,
|
|
||||||
proisraelisch, propalaestinensisch, protuerkisch, panarabisch, neutral, sonstige
|
|
||||||
|
|
||||||
5. state_affiliated (true/false): true wenn vom Staat finanziert/kontrolliert
|
|
||||||
(RT, Sputnik, CGTN, PressTV, Xinhua, TRT). Public Service Broadcaster
|
|
||||||
wie ARD/ZDF/BBC sind NICHT state_affiliated.
|
|
||||||
|
|
||||||
6. country_code (ISO 3166-1 alpha-2): Heimatland (DE, AT, CH, RU, US, ...). null wenn unklar.
|
|
||||||
|
|
||||||
7. confidence (0.0-1.0): 0.85+ fuer bekannte Outlets, 0.5-0.85 fuer mittelbekannt, <0.5 fuer unsicher.
|
|
||||||
|
|
||||||
8. reasoning (1-2 Saetze): Kurze Begruendung der Hauptklassifikationen.
|
|
||||||
|
|
||||||
WICHTIG:
|
|
||||||
- Antworte AUSSCHLIESSLICH mit einem JSON-Objekt, kein Text drumherum.
|
|
||||||
- Nutze ausschliesslich die genannten enum-Werte (snake_case).
|
|
||||||
- Bei Unklarheit lieber `na` und niedrige confidence.
|
|
||||||
|
|
||||||
JSON-Schema:
|
|
||||||
{{
|
|
||||||
"political_orientation": "...",
|
|
||||||
"media_type": "...",
|
|
||||||
"reliability": "...",
|
|
||||||
"alignments": ["..."],
|
|
||||||
"state_affiliated": false,
|
|
||||||
"country_code": "DE",
|
|
||||||
"confidence": 0.9,
|
|
||||||
"reasoning": "..."
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_sample_articles(db: aiosqlite.Connection, name: str, domain: str | None, limit: int = 5) -> list[dict]:
|
|
||||||
"""Laedt die letzten Headlines einer Quelle (per name oder Domain-Match)."""
|
|
||||||
rows: list = []
|
|
||||||
if name:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT headline, headline_de FROM articles WHERE source = ? ORDER BY collected_at DESC LIMIT ?",
|
|
||||||
(name, limit),
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
if not rows and domain:
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT headline, headline_de FROM articles WHERE source_url LIKE ? ORDER BY collected_at DESC LIMIT ?",
|
|
||||||
(f"%{domain}%", limit),
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _validate(parsed: dict) -> dict:
|
|
||||||
"""Validiert + normalisiert eine LLM-Antwort gegen die Enums."""
|
|
||||||
pol = parsed.get("political_orientation", "na")
|
|
||||||
if pol not in POLITICAL_VALUES:
|
|
||||||
pol = "na"
|
|
||||||
mt = parsed.get("media_type", "sonstige")
|
|
||||||
if mt not in MEDIA_TYPE_VALUES:
|
|
||||||
mt = "sonstige"
|
|
||||||
rel = parsed.get("reliability", "na")
|
|
||||||
if rel not in RELIABILITY_VALUES:
|
|
||||||
rel = "na"
|
|
||||||
aligns_raw = parsed.get("alignments") or []
|
|
||||||
if not isinstance(aligns_raw, list):
|
|
||||||
aligns_raw = []
|
|
||||||
aligns = sorted({a for a in aligns_raw if isinstance(a, str) and a in ALIGNMENT_VALUES})
|
|
||||||
sa = bool(parsed.get("state_affiliated", False))
|
|
||||||
cc = parsed.get("country_code")
|
|
||||||
if isinstance(cc, str) and len(cc) == 2 and cc.isalpha():
|
|
||||||
cc = cc.upper()
|
|
||||||
else:
|
|
||||||
cc = None
|
|
||||||
try:
|
|
||||||
confidence = float(parsed.get("confidence", 0.5))
|
|
||||||
confidence = max(0.0, min(1.0, confidence))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
confidence = 0.5
|
|
||||||
reasoning = str(parsed.get("reasoning", ""))[:1000]
|
|
||||||
return {
|
|
||||||
"political_orientation": pol,
|
|
||||||
"media_type": mt,
|
|
||||||
"reliability": rel,
|
|
||||||
"alignments": aligns,
|
|
||||||
"state_affiliated": sa,
|
|
||||||
"country_code": cc,
|
|
||||||
"confidence": confidence,
|
|
||||||
"reasoning": reasoning,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def classify_source(
|
|
||||||
db: aiosqlite.Connection,
|
|
||||||
source_id: int,
|
|
||||||
sample_limit: int = 5,
|
|
||||||
model: str = CLAUDE_MODEL_FAST,
|
|
||||||
) -> dict:
|
|
||||||
"""Klassifiziert eine einzelne Quelle und schreibt die Vorschlaege in proposed_*-Spalten."""
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT id, name, url, domain, source_type, category, language, bias, "
|
|
||||||
"classification_source FROM sources WHERE id = ?",
|
|
||||||
(source_id,),
|
|
||||||
)
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ValueError(f"Quelle {source_id} nicht gefunden")
|
|
||||||
src = dict(row)
|
|
||||||
|
|
||||||
sample = await _load_sample_articles(db, src["name"], src.get("domain"), sample_limit)
|
|
||||||
prompt = _build_prompt(src, sample)
|
|
||||||
response, usage = await call_claude(prompt, tools=None, model=model)
|
|
||||||
|
|
||||||
json_match = re.search(r"\{.*\}", response, re.DOTALL)
|
|
||||||
if not json_match:
|
|
||||||
raise ValueError(f"Keine JSON-Antwort von Claude fuer source_id={source_id}: {response[:200]}")
|
|
||||||
parsed = json.loads(json_match.group(0))
|
|
||||||
result = _validate(parsed)
|
|
||||||
|
|
||||||
# Nur classification_source auf 'llm_pending' setzen, wenn nicht bereits manuell/approved
|
|
||||||
new_src = "CASE WHEN classification_source IN ('manual','llm_approved') THEN classification_source ELSE 'llm_pending' END"
|
|
||||||
await db.execute(
|
|
||||||
f"""UPDATE sources SET
|
|
||||||
proposed_political_orientation = ?,
|
|
||||||
proposed_media_type = ?,
|
|
||||||
proposed_reliability = ?,
|
|
||||||
proposed_state_affiliated = ?,
|
|
||||||
proposed_country_code = ?,
|
|
||||||
proposed_alignments_json = ?,
|
|
||||||
proposed_confidence = ?,
|
|
||||||
proposed_reasoning = ?,
|
|
||||||
proposed_at = CURRENT_TIMESTAMP,
|
|
||||||
classification_source = {new_src}
|
|
||||||
WHERE id = ?""",
|
|
||||||
(
|
|
||||||
result["political_orientation"],
|
|
||||||
result["media_type"],
|
|
||||||
result["reliability"],
|
|
||||||
1 if result["state_affiliated"] else 0,
|
|
||||||
result["country_code"],
|
|
||||||
json.dumps(result["alignments"], ensure_ascii=False),
|
|
||||||
result["confidence"],
|
|
||||||
result["reasoning"],
|
|
||||||
source_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Klassifiziert source_id=%s '%s' -> %s/%s/%s conf=%.2f ($%.4f)",
|
|
||||||
source_id, src["name"], result["political_orientation"],
|
|
||||||
result["media_type"], result["reliability"], result["confidence"],
|
|
||||||
usage.cost_usd,
|
|
||||||
)
|
|
||||||
|
|
||||||
result["source_id"] = source_id
|
|
||||||
result["usage"] = {
|
|
||||||
"cost_usd": usage.cost_usd,
|
|
||||||
"input_tokens": usage.input_tokens,
|
|
||||||
"output_tokens": usage.output_tokens,
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def bulk_classify(
|
|
||||||
db: aiosqlite.Connection,
|
|
||||||
limit: int = 50,
|
|
||||||
only_unclassified: bool = True,
|
|
||||||
model: str = CLAUDE_MODEL_FAST,
|
|
||||||
) -> dict:
|
|
||||||
"""Klassifiziert noch unklassifizierte Quellen (sequenziell).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximale Anzahl Quellen pro Aufruf
|
|
||||||
only_unclassified: Wenn True, nur classification_source='legacy'.
|
|
||||||
Wenn False, auch 'llm_pending' neu klassifizieren.
|
|
||||||
"""
|
|
||||||
if only_unclassified:
|
|
||||||
where = "classification_source = 'legacy'"
|
|
||||||
else:
|
|
||||||
where = "classification_source IN ('legacy', 'llm_pending')"
|
|
||||||
cursor = await db.execute(
|
|
||||||
f"SELECT id FROM sources WHERE {where} AND status = 'active' "
|
|
||||||
f"AND source_type != 'excluded' ORDER BY id LIMIT ?",
|
|
||||||
(limit,),
|
|
||||||
)
|
|
||||||
ids = [row["id"] for row in await cursor.fetchall()]
|
|
||||||
|
|
||||||
total_cost = 0.0
|
|
||||||
success = 0
|
|
||||||
errors: list[dict] = []
|
|
||||||
|
|
||||||
for sid in ids:
|
|
||||||
try:
|
|
||||||
r = await classify_source(db, sid, model=model)
|
|
||||||
total_cost += r["usage"]["cost_usd"]
|
|
||||||
success += 1
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Klassifikation source_id=%s fehlgeschlagen: %s", sid, e, exc_info=True)
|
|
||||||
errors.append({"source_id": sid, "error": str(e)})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Bulk-Klassifikation fertig: %d/%d erfolgreich, $%.4f Kosten, %d Fehler",
|
|
||||||
success, len(ids), total_cost, len(errors),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"processed": len(ids),
|
|
||||||
"success": success,
|
|
||||||
"errors": errors,
|
|
||||||
"total_cost_usd": total_cost,
|
|
||||||
}
|
|
||||||
@@ -3503,203 +3503,6 @@ a.dev-source-pill:hover {
|
|||||||
color: var(--info);
|
color: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sources-Modal: Tabs */
|
|
||||||
.sources-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1));
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.sources-tab {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary, #555);
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.sources-tab:hover {
|
|
||||||
color: var(--text-primary, #222);
|
|
||||||
}
|
|
||||||
.sources-tab.active {
|
|
||||||
color: var(--primary, #2a81cb);
|
|
||||||
border-bottom-color: var(--primary, #2a81cb);
|
|
||||||
}
|
|
||||||
.sources-tab-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 20px;
|
|
||||||
padding: 0 6px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--primary, #2a81cb);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Review-Queue */
|
|
||||||
.review-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--cat-sonstige-bg, #f6f6fa);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.review-toolbar-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.review-conf-filter {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary, #555);
|
|
||||||
}
|
|
||||||
.review-conf-filter select {
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--border-color, rgba(0,0,0,0.15));
|
|
||||||
}
|
|
||||||
.review-toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.review-card {
|
|
||||||
background: var(--surface, #fff);
|
|
||||||
border: 1px solid var(--border-color, rgba(0,0,0,0.08));
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
.review-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.review-card-title {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.review-card-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.review-card-domain {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-disabled, #888);
|
|
||||||
}
|
|
||||||
.review-global-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: #5e35b1;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.review-card-confidence {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
|
||||||
.review-card-confidence .conf-value {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.review-card-confidence .conf-label {
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.review-card-confidence.conf-high { background: #e8f5e9; color: #2e7d32; }
|
|
||||||
.review-card-confidence.conf-medium { background: #fff8e1; color: #ef6c00; }
|
|
||||||
.review-card-confidence.conf-low { background: #ffebee; color: #c62828; }
|
|
||||||
|
|
||||||
.review-card-diff {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.review-diff-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 110px 1fr 24px 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.review-diff-row.changed {
|
|
||||||
background: #fff8e1;
|
|
||||||
}
|
|
||||||
.review-diff-label {
|
|
||||||
color: var(--text-secondary, #555);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.review-diff-current {
|
|
||||||
color: var(--text-disabled, #888);
|
|
||||||
}
|
|
||||||
.review-diff-arrow {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-disabled, #888);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.review-diff-proposed {
|
|
||||||
color: var(--text-primary, #222);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.review-diff-row.changed .review-diff-proposed {
|
|
||||||
color: #ef6c00;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-card-reasoning {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary, #555);
|
|
||||||
background: var(--cat-sonstige-bg, #f6f6fa);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.review-card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Klassifikations-Badges (politisch / reliability / alignments / state) */
|
/* Klassifikations-Badges (politisch / reliability / alignments / state) */
|
||||||
.source-classification-badges {
|
.source-classification-badges {
|
||||||
@@ -3797,46 +3600,6 @@ a.dev-source-pill:hover {
|
|||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Edit-Form: Klassifikations-Sektion */
|
|
||||||
.sources-classification-section {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--border-color, rgba(0,0,0,0.08));
|
|
||||||
}
|
|
||||||
.sources-classification-header {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary, #555);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.alignment-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.alignment-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary, #555);
|
|
||||||
border: 1px solid var(--border-color, rgba(0,0,0,0.15));
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
}
|
|
||||||
.alignment-chip:hover {
|
|
||||||
background: var(--cat-sonstige-bg, #eef);
|
|
||||||
}
|
|
||||||
.alignment-chip.active {
|
|
||||||
background: var(--primary, #2a81cb);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--primary, #2a81cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Typ-Badges */
|
/* Typ-Badges */
|
||||||
.source-type-badge {
|
.source-type-badge {
|
||||||
|
|||||||
@@ -456,15 +456,6 @@
|
|||||||
<!-- Stats-Leiste -->
|
<!-- Stats-Leiste -->
|
||||||
<div class="sources-stats-bar" id="sources-stats-bar"></div>
|
<div class="sources-stats-bar" id="sources-stats-bar"></div>
|
||||||
|
|
||||||
<!-- Tabs: Liste vs. Klassifikations-Review -->
|
|
||||||
<div class="sources-tabs" role="tablist">
|
|
||||||
<button type="button" class="sources-tab active" id="sources-tab-list" role="tab" aria-selected="true" onclick="App.switchSourcesTab('list')">Quellenliste</button>
|
|
||||||
<button type="button" class="sources-tab" id="sources-tab-review" role="tab" aria-selected="false" onclick="App.switchSourcesTab('review')" style="display:none;">Klassifikations-Review <span id="sources-review-count" class="sources-tab-badge">0</span></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View: Quellenliste -->
|
|
||||||
<div id="sources-list-view">
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="sources-toolbar">
|
<div class="sources-toolbar">
|
||||||
<div class="sources-filters">
|
<div class="sources-filters">
|
||||||
@@ -627,89 +618,6 @@
|
|||||||
<input type="text" id="src-notes" placeholder="Optional">
|
<input type="text" id="src-notes" placeholder="Optional">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sources-classification-section">
|
|
||||||
<div class="sources-classification-header">Einordnung</div>
|
|
||||||
<div class="sources-add-form-grid">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="src-political">Politische Ausrichtung</label>
|
|
||||||
<select id="src-political">
|
|
||||||
<option value="na">Nicht eingeordnet</option>
|
|
||||||
<option value="links_extrem">Links (extrem)</option>
|
|
||||||
<option value="links">Links</option>
|
|
||||||
<option value="mitte_links">Mitte-Links</option>
|
|
||||||
<option value="liberal">Liberal</option>
|
|
||||||
<option value="mitte">Mitte</option>
|
|
||||||
<option value="konservativ">Konservativ</option>
|
|
||||||
<option value="mitte_rechts">Mitte-Rechts</option>
|
|
||||||
<option value="rechts">Rechts</option>
|
|
||||||
<option value="rechts_extrem">Rechts (extrem)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="src-mediatype">Medientyp</label>
|
|
||||||
<select id="src-mediatype">
|
|
||||||
<option value="sonstige">Sonstige</option>
|
|
||||||
<option value="tageszeitung">Tageszeitung</option>
|
|
||||||
<option value="wochenzeitung">Wochenzeitung</option>
|
|
||||||
<option value="magazin">Magazin</option>
|
|
||||||
<option value="tv_sender">TV-Sender</option>
|
|
||||||
<option value="radio">Radio</option>
|
|
||||||
<option value="oeffentlich_rechtlich">Öffentlich-Rechtlich</option>
|
|
||||||
<option value="nachrichtenagentur">Nachrichtenagentur</option>
|
|
||||||
<option value="online_only">Online-only</option>
|
|
||||||
<option value="blog">Blog</option>
|
|
||||||
<option value="telegram_kanal">Telegram-Kanal</option>
|
|
||||||
<option value="telegram_bot">Telegram-Bot</option>
|
|
||||||
<option value="podcast">Podcast</option>
|
|
||||||
<option value="social_media">Social Media</option>
|
|
||||||
<option value="imageboard">Imageboard</option>
|
|
||||||
<option value="think_tank">Think Tank</option>
|
|
||||||
<option value="ngo">NGO</option>
|
|
||||||
<option value="behoerde">Behörde</option>
|
|
||||||
<option value="staatsmedium">Staatsmedium</option>
|
|
||||||
<option value="fachmedium">Fachmedium</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="src-reliability">Glaubwürdigkeit</label>
|
|
||||||
<select id="src-reliability">
|
|
||||||
<option value="na">Nicht eingeordnet</option>
|
|
||||||
<option value="sehr_hoch">Sehr hoch</option>
|
|
||||||
<option value="hoch">Hoch</option>
|
|
||||||
<option value="gemischt">Gemischt</option>
|
|
||||||
<option value="niedrig">Niedrig</option>
|
|
||||||
<option value="sehr_niedrig">Sehr niedrig</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="src-country">Land (ISO 3166)</label>
|
|
||||||
<input type="text" id="src-country" maxlength="2" placeholder="z.B. DE, RU, US" style="text-transform:uppercase;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;">
|
|
||||||
<input type="checkbox" id="src-state-affiliated">
|
|
||||||
<span>Staatsnah/-kontrolliert</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-top:8px;">
|
|
||||||
<label>Geopolitische Nähe (Mehrfachauswahl)</label>
|
|
||||||
<div id="src-alignments-chips" class="alignment-chips" onclick="App.handleAlignmentChipClick(event)">
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="prorussisch">prorussisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="proiranisch">proiranisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="prowestlich">prowestlich</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="proukrainisch">proukrainisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="prochinesisch">prochinesisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="projapanisch">projapanisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="proisraelisch">proisraelisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="propalaestinensisch">propalästinensisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="protuerkisch">protürkisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="panarabisch">panarabisch</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="neutral">neutral</button>
|
|
||||||
<button type="button" class="alignment-chip" data-alignment="sonstige">sonstige</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sources-discovery-actions">
|
<div class="sources-discovery-actions">
|
||||||
<button class="btn btn-primary btn-small" onclick="App.saveSource()">Speichern</button>
|
<button class="btn btn-primary btn-small" onclick="App.saveSource()">Speichern</button>
|
||||||
<button class="btn btn-secondary btn-small" onclick="App.toggleSourceForm(false)">Abbrechen</button>
|
<button class="btn btn-secondary btn-small" onclick="App.toggleSourceForm(false)">Abbrechen</button>
|
||||||
@@ -721,36 +629,6 @@
|
|||||||
<div class="sources-list" id="sources-list">
|
<div class="sources-list" id="sources-list">
|
||||||
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Quellen...</div>
|
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Quellen...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
<!-- /sources-list-view -->
|
|
||||||
|
|
||||||
<!-- View: Klassifikations-Review (Admin-only) -->
|
|
||||||
<div id="sources-review-view" style="display:none;">
|
|
||||||
<div class="review-toolbar">
|
|
||||||
<div class="review-toolbar-info">
|
|
||||||
<span><strong id="review-pending-count">0</strong> Vorschlaege ausstehend</span>
|
|
||||||
<label class="review-conf-filter">
|
|
||||||
Mindest-Konfidenz:
|
|
||||||
<select id="review-min-confidence" onchange="App.loadClassificationQueue()">
|
|
||||||
<option value="0">alle</option>
|
|
||||||
<option value="0.5">0.5+</option>
|
|
||||||
<option value="0.7">0.7+</option>
|
|
||||||
<option value="0.85">0.85+</option>
|
|
||||||
<option value="0.9">0.9+</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="review-toolbar-actions">
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="App.triggerExternalReputationSync()" title="IFCN-Faktenchecker-Liste und EUvsDisinfo-Daten synchronisieren">Externe Daten syncen</button>
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="App.triggerBulkClassify()" title="LLM-Klassifikation fuer noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
|
||||||
<button class="btn btn-small btn-primary" onclick="App.bulkApproveHighConfidence()" title="Alle Vorschlaege ueber dem Konfidenz-Schwellwert genehmigen">Alle ≥ 0.85 genehmigen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="review-list" id="sources-review-list">
|
|
||||||
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Review-Queue...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -209,35 +209,6 @@ const API = {
|
|||||||
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
|
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sources: Klassifikations-Review (LLM)
|
|
||||||
getClassificationStats() {
|
|
||||||
return this._request('GET', '/sources/classification/stats');
|
|
||||||
},
|
|
||||||
getClassificationQueue(limit = 50, minConfidence = 0.0) {
|
|
||||||
const qs = new URLSearchParams({ limit: String(limit), min_confidence: String(minConfidence) }).toString();
|
|
||||||
return this._request('GET', `/sources/classification/queue?${qs}`);
|
|
||||||
},
|
|
||||||
approveClassification(id) {
|
|
||||||
return this._request('POST', `/sources/${id}/classification/approve`);
|
|
||||||
},
|
|
||||||
rejectClassification(id) {
|
|
||||||
return this._request('POST', `/sources/${id}/classification/reject`);
|
|
||||||
},
|
|
||||||
reclassifySource(id) {
|
|
||||||
return this._request('POST', `/sources/${id}/classification/reclassify`);
|
|
||||||
},
|
|
||||||
triggerBulkClassify(limit = 50, onlyUnclassified = true) {
|
|
||||||
const qs = new URLSearchParams({ limit: String(limit), only_unclassified: String(onlyUnclassified) }).toString();
|
|
||||||
return this._request('POST', `/sources/classification/bulk-classify?${qs}`);
|
|
||||||
},
|
|
||||||
bulkApproveClassifications(minConfidence = 0.85) {
|
|
||||||
const qs = new URLSearchParams({ min_confidence: String(minConfidence) }).toString();
|
|
||||||
return this._request('POST', `/sources/classification/bulk-approve?${qs}`);
|
|
||||||
},
|
|
||||||
triggerExternalReputationSync() {
|
|
||||||
return this._request('POST', '/sources/external-reputation/sync');
|
|
||||||
},
|
|
||||||
|
|
||||||
createSource(data) {
|
createSource(data) {
|
||||||
return this._request('POST', '/sources', data);
|
return this._request('POST', '/sources', data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2702,12 +2702,6 @@ async handleRefresh() {
|
|||||||
async openSourceManagement() {
|
async openSourceManagement() {
|
||||||
openModal('modal-sources');
|
openModal('modal-sources');
|
||||||
await this.loadSources();
|
await this.loadSources();
|
||||||
// Admin sieht den Review-Tab
|
|
||||||
const reviewTab = document.getElementById('sources-tab-review');
|
|
||||||
if (reviewTab && this.user && this.user.role === 'org_admin') {
|
|
||||||
reviewTab.style.display = '';
|
|
||||||
this._refreshReviewBadge().catch(() => {});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSources() {
|
async loadSources() {
|
||||||
@@ -2728,122 +2722,6 @@ async handleRefresh() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async _refreshReviewBadge() {
|
|
||||||
try {
|
|
||||||
const stats = await API.getClassificationStats();
|
|
||||||
const badge = document.getElementById('sources-review-count');
|
|
||||||
if (badge) badge.textContent = String(stats.pending_review || 0);
|
|
||||||
} catch (_) { /* still ok */ }
|
|
||||||
},
|
|
||||||
|
|
||||||
switchSourcesTab(tab) {
|
|
||||||
const listView = document.getElementById('sources-list-view');
|
|
||||||
const reviewView = document.getElementById('sources-review-view');
|
|
||||||
const tabList = document.getElementById('sources-tab-list');
|
|
||||||
const tabReview = document.getElementById('sources-tab-review');
|
|
||||||
if (!listView || !reviewView) return;
|
|
||||||
if (tab === 'review') {
|
|
||||||
listView.style.display = 'none';
|
|
||||||
reviewView.style.display = '';
|
|
||||||
if (tabList) { tabList.classList.remove('active'); tabList.setAttribute('aria-selected', 'false'); }
|
|
||||||
if (tabReview) { tabReview.classList.add('active'); tabReview.setAttribute('aria-selected', 'true'); }
|
|
||||||
this.loadClassificationQueue();
|
|
||||||
} else {
|
|
||||||
listView.style.display = '';
|
|
||||||
reviewView.style.display = 'none';
|
|
||||||
if (tabList) { tabList.classList.add('active'); tabList.setAttribute('aria-selected', 'true'); }
|
|
||||||
if (tabReview) { tabReview.classList.remove('active'); tabReview.setAttribute('aria-selected', 'false'); }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadClassificationQueue() {
|
|
||||||
const list = document.getElementById('sources-review-list');
|
|
||||||
if (!list) return;
|
|
||||||
const minConf = parseFloat(document.getElementById('review-min-confidence')?.value || '0');
|
|
||||||
list.innerHTML = '<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade...</div>';
|
|
||||||
try {
|
|
||||||
const items = await API.getClassificationQueue(200, minConf);
|
|
||||||
this._reviewItems = items;
|
|
||||||
const countEl = document.getElementById('review-pending-count');
|
|
||||||
if (countEl) countEl.textContent = String(items.length);
|
|
||||||
if (items.length === 0) {
|
|
||||||
list.innerHTML = '<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Keine ausstehenden Vorschlaege.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = items.map(item => UI.renderClassificationQueueItem(item)).join('');
|
|
||||||
} catch (err) {
|
|
||||||
list.innerHTML = `<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;color:var(--danger);">Fehler: ${err.message}</div>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async approveClassification(id) {
|
|
||||||
try {
|
|
||||||
await API.approveClassification(id);
|
|
||||||
UI.showToast('Klassifikation uebernommen.', 'success');
|
|
||||||
await this.loadClassificationQueue();
|
|
||||||
this._refreshReviewBadge();
|
|
||||||
} catch (err) {
|
|
||||||
UI.showToast('Approve fehlgeschlagen: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async rejectClassification(id) {
|
|
||||||
try {
|
|
||||||
await API.rejectClassification(id);
|
|
||||||
UI.showToast('Vorschlag verworfen.', 'success');
|
|
||||||
await this.loadClassificationQueue();
|
|
||||||
this._refreshReviewBadge();
|
|
||||||
} catch (err) {
|
|
||||||
UI.showToast('Reject fehlgeschlagen: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async reclassifySource(id) {
|
|
||||||
const btn = document.querySelector(`[data-reclassify-id="${id}"]`);
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
|
||||||
try {
|
|
||||||
await API.reclassifySource(id);
|
|
||||||
UI.showToast('Neu klassifiziert.', 'success');
|
|
||||||
await this.loadClassificationQueue();
|
|
||||||
} catch (err) {
|
|
||||||
UI.showToast('Reclassify fehlgeschlagen: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
if (btn) { btn.disabled = false; btn.textContent = 'Neu klassifizieren'; }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async triggerBulkClassify() {
|
|
||||||
if (!confirm('Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Lauft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).')) return;
|
|
||||||
try {
|
|
||||||
const r = await API.triggerBulkClassify(500, true);
|
|
||||||
UI.showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). Nachschauen mit Reload.`, 'info');
|
|
||||||
} catch (err) {
|
|
||||||
UI.showToast('Start fehlgeschlagen: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async bulkApproveHighConfidence() {
|
|
||||||
if (!confirm('Alle Vorschlaege mit Konfidenz >= 0.85 genehmigen?')) return;
|
|
||||||
try {
|
|
||||||
const r = await API.bulkApproveClassifications(0.85);
|
|
||||||
UI.showToast(`${r.approved_count} Vorschlaege uebernommen.`, 'success');
|
|
||||||
await this.loadClassificationQueue();
|
|
||||||
this._refreshReviewBadge();
|
|
||||||
} catch (err) {
|
|
||||||
UI.showToast('Bulk-Approve fehlgeschlagen: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async triggerExternalReputationSync() {
|
|
||||||
if (!confirm('IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Lauft im Hintergrund (~30 Sek).')) return;
|
|
||||||
try {
|
|
||||||
await API.triggerExternalReputationSync();
|
|
||||||
UI.showToast('Externer Sync gestartet. Quellenliste in 30 Sek neu laden.', 'info');
|
|
||||||
} catch (err) {
|
|
||||||
UI.showToast('Sync fehlgeschlagen: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
renderSourceStats(stats) {
|
renderSourceStats(stats) {
|
||||||
const bar = document.getElementById('sources-stats-bar');
|
const bar = document.getElementById('sources-stats-bar');
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
@@ -3200,13 +3078,6 @@ async handleRefresh() {
|
|||||||
document.getElementById('src-discover-btn').disabled = false;
|
document.getElementById('src-discover-btn').disabled = false;
|
||||||
document.getElementById('src-discover-btn').textContent = 'Erkennen';
|
document.getElementById('src-discover-btn').textContent = 'Erkennen';
|
||||||
document.getElementById('src-type-select').value = 'rss_feed';
|
document.getElementById('src-type-select').value = 'rss_feed';
|
||||||
// Klassifikations-Felder auf Default zurücksetzen
|
|
||||||
const polEl = document.getElementById('src-political'); if (polEl) polEl.value = 'na';
|
|
||||||
const mtEl = document.getElementById('src-mediatype'); if (mtEl) mtEl.value = 'sonstige';
|
|
||||||
const relEl = document.getElementById('src-reliability'); if (relEl) relEl.value = 'na';
|
|
||||||
const ccEl = document.getElementById('src-country'); if (ccEl) ccEl.value = '';
|
|
||||||
const saEl = document.getElementById('src-state-affiliated'); if (saEl) saEl.checked = false;
|
|
||||||
this._setAlignmentChips([]);
|
|
||||||
// Save-Button Text zurücksetzen
|
// Save-Button Text zurücksetzen
|
||||||
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
||||||
if (saveBtn) saveBtn.textContent = 'Speichern';
|
if (saveBtn) saveBtn.textContent = 'Speichern';
|
||||||
@@ -3388,19 +3259,6 @@ async handleRefresh() {
|
|||||||
rss_url: source.url,
|
rss_url: source.url,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Klassifikations-Felder setzen
|
|
||||||
const polEl = document.getElementById('src-political');
|
|
||||||
if (polEl) polEl.value = source.political_orientation || 'na';
|
|
||||||
const mtEl = document.getElementById('src-mediatype');
|
|
||||||
if (mtEl) mtEl.value = source.media_type || 'sonstige';
|
|
||||||
const relEl = document.getElementById('src-reliability');
|
|
||||||
if (relEl) relEl.value = source.reliability || 'na';
|
|
||||||
const ccEl = document.getElementById('src-country');
|
|
||||||
if (ccEl) ccEl.value = source.country_code || '';
|
|
||||||
const saEl = document.getElementById('src-state-affiliated');
|
|
||||||
if (saEl) saEl.checked = !!source.state_affiliated;
|
|
||||||
this._setAlignmentChips(source.alignments || []);
|
|
||||||
|
|
||||||
// Submit-Button-Text ändern
|
// Submit-Button-Text ändern
|
||||||
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
const saveBtn = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
|
||||||
if (saveBtn) saveBtn.textContent = 'Quelle speichern';
|
if (saveBtn) saveBtn.textContent = 'Quelle speichern';
|
||||||
@@ -3409,27 +3267,6 @@ async handleRefresh() {
|
|||||||
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
},
|
},
|
||||||
|
|
||||||
_setAlignmentChips(active) {
|
|
||||||
const chips = document.querySelectorAll('#src-alignments-chips .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');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_getAlignmentChips() {
|
|
||||||
return Array.from(document.querySelectorAll('#src-alignments-chips .alignment-chip.active'))
|
|
||||||
.map(chip => chip.dataset.alignment);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleAlignmentChipClick(e) {
|
|
||||||
const chip = e.target.closest('.alignment-chip');
|
|
||||||
if (!chip) return;
|
|
||||||
e.preventDefault();
|
|
||||||
chip.classList.toggle('active');
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveSource() {
|
async saveSource() {
|
||||||
const name = document.getElementById('src-name').value.trim();
|
const name = document.getElementById('src-name').value.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -3445,12 +3282,6 @@ async handleRefresh() {
|
|||||||
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
|
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
|
||||||
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
|
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
|
||||||
notes: document.getElementById('src-notes').value.trim() || null,
|
notes: document.getElementById('src-notes').value.trim() || null,
|
||||||
political_orientation: document.getElementById('src-political')?.value || 'na',
|
|
||||||
media_type: document.getElementById('src-mediatype')?.value || 'sonstige',
|
|
||||||
reliability: document.getElementById('src-reliability')?.value || 'na',
|
|
||||||
country_code: (document.getElementById('src-country')?.value || '').trim().toUpperCase() || null,
|
|
||||||
state_affiliated: !!document.getElementById('src-state-affiliated')?.checked,
|
|
||||||
alignments: this._getAlignmentChips(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data.domain && discovered.domain) {
|
if (!data.domain && discovered.domain) {
|
||||||
|
|||||||
@@ -1119,71 +1119,6 @@ const UI = {
|
|||||||
sonstige: 'sonstige',
|
sonstige: 'sonstige',
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Eintrag in der Klassifikations-Review-Queue.
|
|
||||||
* Zeigt Diff zwischen aktuellem Wert und LLM-Vorschlag.
|
|
||||||
*/
|
|
||||||
renderClassificationQueueItem(item) {
|
|
||||||
const cur = item.current || {};
|
|
||||||
const prop = item.proposed || {};
|
|
||||||
const conf = prop.confidence || 0;
|
|
||||||
const confPct = Math.round(conf * 100);
|
|
||||||
const confClass = conf >= 0.85 ? 'high' : (conf >= 0.7 ? 'medium' : 'low');
|
|
||||||
|
|
||||||
const diffRow = (label, currentVal, proposedVal, formatter) => {
|
|
||||||
const fmt = formatter || (v => v == null || v === '' ? '–' : String(v));
|
|
||||||
const c = fmt(currentVal);
|
|
||||||
const p = fmt(proposedVal);
|
|
||||||
const changed = c !== p;
|
|
||||||
return `<div class="review-diff-row${changed ? ' changed' : ''}">
|
|
||||||
<span class="review-diff-label">${this.escape(label)}</span>
|
|
||||||
<span class="review-diff-current">${this.escape(c)}</span>
|
|
||||||
<span class="review-diff-arrow">→</span>
|
|
||||||
<span class="review-diff-proposed">${this.escape(p)}</span>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const polFmt = v => (v && v !== 'na') ? (this._politicalLabels[v]?.full || v) : '–';
|
|
||||||
const mtFmt = v => (v && v !== 'sonstige') ? (this._mediaTypeLabels[v] || v) : (v === 'sonstige' ? 'Sonstige' : '–');
|
|
||||||
const relFmt = v => (v && v !== 'na') ? (this._reliabilityLabels[v] || v) : '–';
|
|
||||||
const stateFmt = v => v ? 'ja' : 'nein';
|
|
||||||
const ccFmt = v => v || '–';
|
|
||||||
const alignFmt = v => (Array.isArray(v) && v.length > 0)
|
|
||||||
? v.map(a => this._alignmentLabels[a] || a).join(', ')
|
|
||||||
: '–';
|
|
||||||
|
|
||||||
const globalBadge = item.is_global ? '<span class="review-global-badge">Grundquelle</span>' : '';
|
|
||||||
const reasoning = prop.reasoning ? this.escape(prop.reasoning) : '';
|
|
||||||
|
|
||||||
return `<div class="review-card" data-source-id="${item.id}">
|
|
||||||
<div class="review-card-header">
|
|
||||||
<div class="review-card-title">
|
|
||||||
<span class="review-card-name">${this.escape(item.name)}</span>
|
|
||||||
${globalBadge}
|
|
||||||
<span class="review-card-domain">${this.escape(item.domain || '')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="review-card-confidence conf-${confClass}" title="LLM-Konfidenz">
|
|
||||||
<span class="conf-value">${confPct}%</span>
|
|
||||||
<span class="conf-label">Konfidenz</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="review-card-diff">
|
|
||||||
${diffRow('Politik', cur.political_orientation, prop.political_orientation, polFmt)}
|
|
||||||
${diffRow('Medientyp', cur.media_type, prop.media_type, mtFmt)}
|
|
||||||
${diffRow('Glaubwürdigkeit', cur.reliability, prop.reliability, relFmt)}
|
|
||||||
${diffRow('Staatsnah', cur.state_affiliated, prop.state_affiliated, stateFmt)}
|
|
||||||
${diffRow('Land', cur.country_code, prop.country_code, ccFmt)}
|
|
||||||
${diffRow('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">
|
|
||||||
<button class="btn btn-small btn-primary" onclick="App.approveClassification(${item.id})">Übernehmen</button>
|
|
||||||
<button class="btn btn-small btn-secondary" onclick="App.rejectClassification(${item.id})">Verwerfen</button>
|
|
||||||
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="App.reclassifySource(${item.id})">Neu klassifizieren</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
},
|
|
||||||
|
|
||||||
_renderClassificationBadges(feed) {
|
_renderClassificationBadges(feed) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const pol = feed.political_orientation;
|
const pol = feed.political_orientation;
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren