feat: add Quellenatlas knowledge base

Private, source-based system with FastAPI, Next.js, Postgres schema,
ethics constraints, RSS/API ingest into a review queue, and 24 seeded cases.
This commit is contained in:
grok-bot committed 2026-08-21 22:37:45 +02:00
commit 872f836a47
103 files changed
+8444

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
POSTGRES_USER=quellenatlas
POSTGRES_PASSWORD=quellenatlas
POSTGRES_DB=quellenatlas
POSTGRES_HOST=postgres
POSTGRES_PORT=5433
# Docker-intern: Host `postgres`, Port 5432
# Lokal gegen Compose: Host `localhost`, Port 5433
DATABASE_URL=postgresql+psycopg://quellenatlas:quellenatlas@localhost:5433/quellenatlas
JWT_SECRET=quellenatlas-dev-secret-please-change-32b
JWT_EXPIRE_MINUTES=720
COOKIE_NAME=quellenatlas
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
BACKEND_CORS_ORIGINS=http://localhost:3000
PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8000
+20
View File
@@ -0,0 +1,20 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
dist/
backend/.venv/
backend/.pytest_cache/
backend/quellenatlas.egg-info/
frontend/node_modules/
frontend/.next/
frontend/out/
data/postgres/
.DS_Store
*.log
htmlcov/
.coverage
+23
View File
@@ -0,0 +1,23 @@
.PHONY: up down test seed ingest sync
up:
docker compose up --build
down:
docker compose down
test:
cd backend && .venv/bin/pytest -q
seed:
cd backend && .venv/bin/python -m app.cli seed
sync:
cd backend && .venv/bin/python -m app.cli sync-sources
ingest:
cd backend && .venv/bin/python -m app.cli ingest
bootstrap:
cd backend && .venv/bin/python -m app.cli bootstrap-admin
cd backend && .venv/bin/alembic upgrade head
+81
View File
@@ -0,0 +1,81 @@
# Quellenatlas
Private, selbst gehostete Knowledge Base für **quellenbasierte** Dokumentation politischer und gesellschaftlicher Auffälligkeiten. Kein öffentliches Portal, kein Meinungsprodukt.
Jeder Claim braucht mindestens eine Quelle. Status (`confirmed` / `allegation` / `refuted` / `ongoing`) und `disputed` sind Pflichtfelder. Ursula von der Leyen und Ukraine sind **Sorgfalts-Cluster**, keine Schuldzuschreibung.
## Schnellstart
```bash
cp .env.example .env # Werte anpassen, vor allem JWT_SECRET und ADMIN_PASSWORD
docker compose up --build
```
- UI: http://localhost:3000
- API: http://localhost:8000/docs
- Login: `ADMIN_USERNAME` / `ADMIN_PASSWORD` (Default `admin` / `changeme`)
Lokal ohne Docker-Frontend (Postgres bleibt in Compose, Port **5433** wegen Kollision mit lokaler Postgres):
```bash
docker compose up -d postgres
cd backend && python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/alembic upgrade head
.venv/bin/python -m app.cli bootstrap-admin
.venv/bin/python -m app.cli sync-sources
.venv/bin/python -m app.cli seed
.venv/bin/uvicorn app.main:app --reload --port 8000
cd ../frontend && npm install && npm run dev
```
## Was das System tut
| Teil | Rolle |
|---|---|
| PostgreSQL | Primärspeicher, Hierarchien, Volltext, Graph als SQL-Sicht |
| FastAPI | Auth (httpOnly-Cookie), CRUD, Suche, Export, Review, Ingest |
| Next.js | Hierarchische Navigation, Cluster, Fallseiten mit Zitaten |
| `data/sources.yaml` | Kanonischer Quellenkatalog |
| `data/seed/` | Jurisdiktionen, Kategorien, Entitäten, Fälle, Zusagen |
Ingest holt **nur RSS/API**, schreibt `SourceEntry` + `ReviewItem`, niemals Fälle.
## Dokumentation
- [Architektur](docs/architecture.md)
- [Datenmodell](docs/data-model.md)
- [Ethikregeln](docs/ethics.md)
- [Quelle oder Fall hinzufügen](docs/adding-sources.md)
## Quelle erweitern
1. Block in `data/sources.yaml` (siehe `docs/adding-sources.md`).
2. `python -m app.cli sync-sources`
3. Optional live: `python -m app.cli ingest --slug <slug>`
Kein HTML-Scraping von Nachrichtenportalen.
## Fall hinzufügen
Im UI unter `/admin/cases/new` oder als YAML (`data/seed/cases/_template.yaml` + `data/seed/cases.yaml`). Danach `python -m app.cli seed` (idempotent).
Pflicht: mindestens ein Claim mit URL und `accessed_at`. `confirmed` nur mit Amts-/Gerichts-/Parlamentsquelle **oder** `confirmation_basis`.
## Tests
```bash
cd backend && .venv/bin/pytest -q
```
Benötigt Docker-Postgres auf `localhost:5433`.
## Export
Fallseite → „Markdown exportieren“, oder:
`GET /api/v1/export/cases/{slug}?format=json|csv|md`
## Grenzen von v1
Kein öffentlicher Zugriff, kein LLM, kein Medien-HTML-Crawler, kein Neo4j.
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml /app/
RUN pip install --no-cache-dir .
COPY app /app/app
COPY alembic /app/alembic
COPY alembic.ini /app/alembic.ini
ENV PYTHONPATH=/app
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m app.cli bootstrap-admin && python -m app.cli sync-sources && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://quellenatlas:quellenatlas@localhost:5433/quellenatlas
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+39
View File
@@ -0,0 +1,39 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,357 @@
"""initial schema
Revision ID: a2bddb90c586
Revises:
Create Date: 2026-08-21 22:24:41.946216
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'a2bddb90c586'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('categories',
sa.Column('slug', sa.String(length=160), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('parent_id', sa.UUID(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['categories.id'], name=op.f('fk_categories_parent_id_categories'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_categories')),
sa.UniqueConstraint('slug', name=op.f('uq_categories_slug'))
)
op.create_table('jurisdictions',
sa.Column('slug', sa.String(length=160), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('type', sa.Enum('country', 'region', 'municipality', 'institution', 'international', name='jurisdictiontype'), nullable=False),
sa.Column('parent_id', sa.UUID(), nullable=True),
sa.Column('iso_code', sa.String(length=8), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['parent_id'], ['jurisdictions.id'], name=op.f('fk_jurisdictions_parent_id_jurisdictions'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_jurisdictions')),
sa.UniqueConstraint('slug', name=op.f('uq_jurisdictions_slug'))
)
op.create_table('sources',
sa.Column('slug', sa.String(length=160), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('type', sa.Enum('news', 'investigative', 'official_register', 'ngo_report', 'parliamentary', 'court', 'wiki', 'academic', 'other', name='sourcetype'), nullable=False),
sa.Column('base_url', sa.String(length=500), nullable=False),
sa.Column('language_codes', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('reliability', sa.Integer(), nullable=False),
sa.Column('update_frequency', sa.Enum('hourly', 'daily', 'weekly', 'monthly', 'manual', name='updatefrequency'), nullable=False),
sa.Column('ingest_method', sa.Enum('rss', 'api', 'manual', name='ingestmethod'), nullable=False),
sa.Column('ingest_config', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('topic_focus', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('status', sa.Enum('active', 'paused', 'problematic', name='sourcestatus'), nullable=False),
sa.Column('last_success_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sources')),
sa.UniqueConstraint('slug', name=op.f('uq_sources_slug'))
)
op.create_table('update_logs',
sa.Column('actor_type', sa.Enum('user', 'pipeline', name='actortype'), nullable=False),
sa.Column('actor_id', sa.UUID(), nullable=True),
sa.Column('entity_table', sa.String(length=64), nullable=False),
sa.Column('entity_id', sa.UUID(), nullable=False),
sa.Column('action', sa.Enum('create', 'update', 'delete', 'ingest', 'review', name='auditaction'), nullable=False),
sa.Column('before', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('after', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_update_logs'))
)
op.create_table('users',
sa.Column('username', sa.String(length=120), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('role', sa.Enum('admin', 'editor', 'viewer', name='userrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
sa.UniqueConstraint('username', name=op.f('uq_users_username'))
)
op.create_table('cases',
sa.Column('slug', sa.String(length=220), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('time_start', sa.Date(), nullable=False),
sa.Column('time_end', sa.Date(), nullable=True),
sa.Column('status', sa.Enum('confirmed', 'allegation', 'refuted', 'ongoing', name='casestatus'), nullable=False),
sa.Column('disputed', sa.Boolean(), nullable=False),
sa.Column('confirmation_basis', sa.Text(), nullable=True),
sa.Column('care_level', sa.Enum('standard', 'high', name='carelevel'), nullable=False),
sa.Column('cluster_tags', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('consequences', sa.Text(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], name=op.f('fk_cases_created_by_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_cases')),
sa.UniqueConstraint('slug', name=op.f('uq_cases_slug'))
)
op.create_table('entities',
sa.Column('slug', sa.String(length=200), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('entity_type', sa.Enum('person', 'party', 'ngo', 'foundation', 'think_tank', 'company', 'lobby', 'agency', 'institution', 'other', name='entitytype'), nullable=False),
sa.Column('jurisdiction_id', sa.UUID(), nullable=True),
sa.Column('care_level', sa.Enum('standard', 'high', name='carelevel'), nullable=False),
sa.Column('cluster_tags', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('notes_neutral', sa.Text(), nullable=True),
sa.Column('wikidata_id', sa.String(length=32), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['jurisdiction_id'], ['jurisdictions.id'], name=op.f('fk_entities_jurisdiction_id_jurisdictions'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_entities')),
sa.UniqueConstraint('slug', name=op.f('uq_entities_slug'))
)
op.create_table('source_entries',
sa.Column('source_id', sa.UUID(), nullable=False),
sa.Column('url', sa.String(length=1000), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('accessed_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('language', sa.String(length=8), nullable=True),
sa.Column('excerpt', sa.Text(), nullable=True),
sa.Column('content_hash', sa.String(length=64), nullable=True),
sa.Column('raw', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_source_entries_source_id_sources'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_source_entries')),
sa.UniqueConstraint('url', name=op.f('uq_source_entries_url'))
)
op.create_table('case_categories',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_case_categories_case_id_cases'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], name=op.f('fk_case_categories_category_id_categories'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_categories')),
sa.UniqueConstraint('case_id', 'category_id', name=op.f('uq_case_categories_case_id'))
)
op.create_table('case_entities',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('entity_id', sa.UUID(), nullable=False),
sa.Column('role', sa.Enum('subject', 'decision_maker', 'beneficiary', 'investigator', 'mentioned', 'related', name='caseentityrole'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_case_entities_case_id_cases'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_case_entities_entity_id_entities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_entities')),
sa.UniqueConstraint('case_id', 'entity_id', 'role', name=op.f('uq_case_entities_case_id'))
)
op.create_table('case_events',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('occurred_on', sa.Date(), nullable=True),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_case_events_case_id_cases'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_events'))
)
op.create_table('case_jurisdictions',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('jurisdiction_id', sa.UUID(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_case_jurisdictions_case_id_cases'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['jurisdiction_id'], ['jurisdictions.id'], name=op.f('fk_case_jurisdictions_jurisdiction_id_jurisdictions'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_jurisdictions')),
sa.UniqueConstraint('case_id', 'jurisdiction_id', name=op.f('uq_case_jurisdictions_case_id'))
)
op.create_table('case_revisions',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('revision', sa.Integer(), nullable=False),
sa.Column('snapshot', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_case_revisions_case_id_cases'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], name=op.f('fk_case_revisions_created_by_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_revisions'))
)
op.create_table('case_source_entries',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('source_entry_id', sa.UUID(), nullable=False),
sa.Column('locator', sa.String(length=255), nullable=True),
sa.Column('quote', sa.Text(), nullable=True),
sa.Column('accessed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_case_source_entries_case_id_cases'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_case_source_entries_source_entry_id_source_entries'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_source_entries'))
)
op.create_table('claims',
sa.Column('case_id', sa.UUID(), nullable=False),
sa.Column('statement', sa.Text(), nullable=False),
sa.Column('status', sa.Enum('confirmed', 'allegation', 'refuted', 'ongoing', name='casestatus'), nullable=False),
sa.Column('disputed', sa.Boolean(), nullable=False),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], name=op.f('fk_claims_case_id_cases'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_claims'))
)
op.create_table('entity_aliases',
sa.Column('entity_id', sa.UUID(), nullable=False),
sa.Column('alias', sa.String(length=255), nullable=False),
sa.Column('lang', sa.String(length=8), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_entity_aliases_entity_id_entities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_entity_aliases')),
sa.UniqueConstraint('entity_id', 'alias', name=op.f('uq_entity_aliases_entity_id'))
)
op.create_table('entity_roles',
sa.Column('entity_id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.Column('jurisdiction_id', sa.UUID(), nullable=True),
sa.Column('started_on', sa.Date(), nullable=True),
sa.Column('ended_on', sa.Date(), nullable=True),
sa.Column('source_entry_id', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_entity_roles_entity_id_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['jurisdiction_id'], ['jurisdictions.id'], name=op.f('fk_entity_roles_jurisdiction_id_jurisdictions'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_id'], ['entities.id'], name=op.f('fk_entity_roles_organization_id_entities'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_entity_roles_source_entry_id_source_entries'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_entity_roles'))
)
op.create_table('promises',
sa.Column('slug', sa.String(length=220), nullable=False),
sa.Column('entity_id', sa.UUID(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('promised_on', sa.Date(), nullable=True),
sa.Column('context', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('kept', 'broken', 'partial', 'unclear', 'superseded', name='promisestatus'), nullable=False),
sa.Column('related_case_id', sa.UUID(), nullable=True),
sa.Column('jurisdiction_id', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_promises_entity_id_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['jurisdiction_id'], ['jurisdictions.id'], name=op.f('fk_promises_jurisdiction_id_jurisdictions'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['related_case_id'], ['cases.id'], name=op.f('fk_promises_related_case_id_cases'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_promises')),
sa.UniqueConstraint('slug', name=op.f('uq_promises_slug'))
)
op.create_table('review_items',
sa.Column('source_id', sa.UUID(), nullable=False),
sa.Column('source_entry_id', sa.UUID(), nullable=True),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('url', sa.String(length=1000), nullable=False),
sa.Column('snippet', sa.Text(), nullable=True),
sa.Column('suggested_entities', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('suggested_categories', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('priority', sa.Enum('normal', 'high', name='reviewpriority'), nullable=False),
sa.Column('status', sa.Enum('pending', 'accepted', 'rejected', 'merged', name='reviewstatus'), nullable=False),
sa.Column('matched_case_id', sa.UUID(), nullable=True),
sa.Column('decided_by', sa.UUID(), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['decided_by'], ['users.id'], name=op.f('fk_review_items_decided_by_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['matched_case_id'], ['cases.id'], name=op.f('fk_review_items_matched_case_id_cases'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_review_items_source_entry_id_source_entries'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_review_items_source_id_sources'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_review_items'))
)
op.create_table('case_event_source_entries',
sa.Column('event_id', sa.UUID(), nullable=False),
sa.Column('source_entry_id', sa.UUID(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['event_id'], ['case_events.id'], name=op.f('fk_case_event_source_entries_event_id_case_events'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_case_event_source_entries_source_entry_id_source_entries'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_case_event_source_entries'))
)
op.create_table('claim_source_entries',
sa.Column('claim_id', sa.UUID(), nullable=False),
sa.Column('source_entry_id', sa.UUID(), nullable=False),
sa.Column('locator', sa.String(length=255), nullable=True),
sa.Column('quote', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['claim_id'], ['claims.id'], name=op.f('fk_claim_source_entries_claim_id_claims'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_claim_source_entries_source_entry_id_source_entries'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_claim_source_entries'))
)
op.create_table('conflicts',
sa.Column('claim_id', sa.UUID(), nullable=False),
sa.Column('status', sa.Enum('unresolved', 'marked_disputed', 'noted', name='conflictstatus'), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['claim_id'], ['claims.id'], name=op.f('fk_conflicts_claim_id_claims'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_conflicts'))
)
op.create_table('promise_source_entries',
sa.Column('promise_id', sa.UUID(), nullable=False),
sa.Column('source_entry_id', sa.UUID(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['promise_id'], ['promises.id'], name=op.f('fk_promise_source_entries_promise_id_promises'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_promise_source_entries_source_entry_id_source_entries'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_promise_source_entries'))
)
op.create_table('conflict_source_entries',
sa.Column('conflict_id', sa.UUID(), nullable=False),
sa.Column('source_entry_id', sa.UUID(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['conflict_id'], ['conflicts.id'], name=op.f('fk_conflict_source_entries_conflict_id_conflicts'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_entry_id'], ['source_entries.id'], name=op.f('fk_conflict_source_entries_source_entry_id_source_entries'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_conflict_source_entries'))
)
op.create_check_constraint(
"ck_sources_reliability",
"sources",
"reliability >= 1 AND reliability <= 5",
)
op.execute(
"CREATE INDEX ix_cases_fts ON cases USING gin "
"(to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(summary,'')))"
)
op.execute(
"CREATE INDEX ix_entities_name_trgm ON entities USING gin (name gin_trgm_ops)"
)
op.execute(
"CREATE INDEX ix_cases_cluster_tags ON cases USING gin (cluster_tags)"
)
op.execute(
"CREATE INDEX ix_review_items_status ON review_items (status)"
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('conflict_source_entries')
op.drop_table('promise_source_entries')
op.drop_table('conflicts')
op.drop_table('claim_source_entries')
op.drop_table('case_event_source_entries')
op.drop_table('review_items')
op.drop_table('promises')
op.drop_table('entity_roles')
op.drop_table('entity_aliases')
op.drop_table('claims')
op.drop_table('case_source_entries')
op.drop_table('case_revisions')
op.drop_table('case_jurisdictions')
op.drop_table('case_events')
op.drop_table('case_entities')
op.drop_table('case_categories')
op.drop_table('source_entries')
op.drop_table('entities')
op.drop_table('cases')
op.drop_table('users')
op.drop_table('update_logs')
op.drop_table('sources')
op.drop_table('jurisdictions')
op.drop_table('categories')
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
View File
Whitespace-only changes.
+32
View File
@@ -0,0 +1,32 @@
from fastapi import APIRouter
from app.api.v1 import (
audit,
auth,
cases,
clusters,
entities,
export,
graph,
ingest,
meta,
promises,
review,
search,
sources,
)
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(meta.router)
api_router.include_router(cases.router)
api_router.include_router(entities.router)
api_router.include_router(promises.router)
api_router.include_router(sources.router)
api_router.include_router(search.router)
api_router.include_router(clusters.router)
api_router.include_router(graph.router)
api_router.include_router(export.router)
api_router.include_router(review.router)
api_router.include_router(ingest.router)
api_router.include_router(audit.router)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.deps import require_admin
from app.db import get_db
from app.models import UpdateLog, User
router = APIRouter(prefix="/audit", tags=["audit"])
@router.get("")
def list_audit(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
limit: int = Query(100, le=500),
) -> list[dict]:
rows = db.query(UpdateLog).order_by(UpdateLog.created_at.desc()).limit(limit).all()
return [
{
"id": str(r.id),
"actor_type": r.actor_type.value,
"actor_id": str(r.actor_id) if r.actor_id else None,
"entity_table": r.entity_table,
"entity_id": str(r.entity_id),
"action": r.action.value,
"before": r.before,
"after": r.after,
"created_at": r.created_at.isoformat(),
}
for r in rows
]
+40
View File
@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.deps import get_current_user
from app.core.security import create_token, verify_password
from app.db import get_db
from app.models import User
from app.schemas import LoginIn, UserOut
router = APIRouter(tags=["auth"])
@router.post("/auth/login", response_model=UserOut)
def login(payload: LoginIn, response: Response, db: Session = Depends(get_db)) -> User:
user = db.query(User).filter(User.username == payload.username).one_or_none()
if user is None or not user.is_active or not verify_password(payload.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
token = create_token(user.username, user.role.value)
settings = get_settings()
response.set_cookie(
key=settings.cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=settings.jwt_expire_minutes * 60,
path="/",
)
return user
@router.post("/auth/logout")
def logout(response: Response) -> dict:
response.delete_cookie(get_settings().cookie_name, path="/")
return {"ok": True}
@router.get("/auth/me", response_model=UserOut)
def me(user: User = Depends(get_current_user)) -> User:
return user
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, require_editor
from app.db import get_db
from app.models import Case, User
from app.schemas import CaseCreate, CaseListOut, CaseOut, CaseUpdate
from app.services.cases import create_case, get_case, update_case
from app.services.serialize import case_list_out, case_out
router = APIRouter(prefix="/cases", tags=["cases"])
@router.get("", response_model=list[CaseListOut])
def list_cases(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
status: str | None = None,
cluster: str | None = None,
disputed: bool | None = None,
limit: int = Query(50, le=200),
) -> list[CaseListOut]:
q = db.query(Case).order_by(Case.time_start.desc())
if status:
q = q.filter(Case.status == status)
if disputed is not None:
q = q.filter(Case.disputed == disputed)
if cluster:
q = q.filter(Case.cluster_tags.any(cluster))
return [case_list_out(c) for c in q.limit(limit).all()]
@router.get("/{slug}", response_model=CaseOut)
def read_case(
slug: str, db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> CaseOut:
return case_out(db, get_case(db, slug))
@router.post("", response_model=CaseOut, status_code=201)
def post_case(
payload: CaseCreate,
db: Session = Depends(get_db),
user: User = Depends(require_editor),
) -> CaseOut:
return case_out(db, create_case(db, payload, user))
@router.patch("/{slug}", response_model=CaseOut)
def patch_case(
slug: str,
payload: CaseUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_editor),
) -> CaseOut:
return case_out(db, update_case(db, slug, payload, user))
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session, selectinload
from app.core.deps import get_current_user
from app.db import get_db
from app.models import Case, Entity, User
from app.schemas import CaseListOut, EntityOut
from app.services.serialize import case_list_out, entity_out
router = APIRouter(prefix="/clusters", tags=["clusters"])
def _cluster(db: Session, tag: str) -> dict:
cases = (
db.query(Case)
.filter(Case.cluster_tags.any(tag))
.order_by(Case.time_start.desc())
.all()
)
entities = (
db.query(Entity)
.options(selectinload(Entity.aliases))
.filter(Entity.cluster_tags.any(tag))
.order_by(Entity.name)
.all()
)
return {
"tag": tag,
"cases": [case_list_out(c) for c in cases],
"entities": [entity_out(e) for e in entities],
}
@router.get("/vdl")
def cluster_vdl(db: Session = Depends(get_db), _: User = Depends(get_current_user)) -> dict:
return _cluster(db, "vdl")
@router.get("/ukraine")
def cluster_ukraine(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> dict:
return _cluster(db, "ukraine")
+82
View File
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, selectinload
from app.core.deps import get_current_user, require_editor
from app.db import get_db
from app.models import Case, CaseEntity, Entity, EntityAlias, Jurisdiction, User
from app.schemas import CaseListOut, EntityCreate, EntityOut
from app.services.serialize import case_list_out, entity_out
router = APIRouter(prefix="/entities", tags=["entities"])
@router.get("", response_model=list[EntityOut])
def list_entities(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
cluster: str | None = None,
) -> list[EntityOut]:
q = db.query(Entity).options(selectinload(Entity.aliases)).order_by(Entity.name)
if cluster:
q = q.filter(Entity.cluster_tags.any(cluster))
return [entity_out(e) for e in q.all()]
@router.get("/{slug}", response_model=EntityOut)
def read_entity(
slug: str, db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> EntityOut:
entity = (
db.query(Entity)
.options(selectinload(Entity.aliases))
.filter(Entity.slug == slug)
.one_or_none()
)
if entity is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Entity not found")
return entity_out(entity)
@router.get("/{slug}/cases", response_model=list[CaseListOut])
def entity_cases(
slug: str, db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[CaseListOut]:
entity = db.query(Entity).filter(Entity.slug == slug).one_or_none()
if entity is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Entity not found")
links = db.query(CaseEntity.case_id).filter(CaseEntity.entity_id == entity.id)
cases = db.query(Case).filter(Case.id.in_(links)).order_by(Case.time_start.desc()).all()
return [case_list_out(c) for c in cases]
@router.post("", response_model=EntityOut, status_code=201)
def post_entity(
payload: EntityCreate,
db: Session = Depends(get_db),
_: User = Depends(require_editor),
) -> EntityOut:
if db.query(Entity).filter(Entity.slug == payload.slug).one_or_none():
raise HTTPException(status.HTTP_409_CONFLICT, "slug already exists")
jur_id = None
if payload.jurisdiction_slug:
jur = db.query(Jurisdiction).filter(Jurisdiction.slug == payload.jurisdiction_slug).one_or_none()
if jur is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Jurisdiction not found")
jur_id = jur.id
entity = Entity(
slug=payload.slug,
name=payload.name,
entity_type=payload.entity_type,
jurisdiction_id=jur_id,
care_level=payload.care_level,
cluster_tags=payload.cluster_tags,
notes_neutral=payload.notes_neutral,
wikidata_id=payload.wikidata_id,
)
db.add(entity)
db.flush()
for alias in payload.aliases:
db.add(EntityAlias(entity_id=entity.id, alias=alias))
db.commit()
db.refresh(entity)
return entity_out(entity)
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, Query
from fastapi.responses import PlainTextResponse
from sqlalchemy.orm import Session
from app.core.deps import get_current_user
from app.db import get_db
from app.models import User
from app.services.cases import get_case
from app.services.serialize import case_out
router = APIRouter(prefix="/export", tags=["export"])
@router.get("/cases/{slug}")
def export_case(
slug: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
format: str = Query("json", pattern="^(json|csv|md)$"),
):
data = case_out(db, get_case(db, slug))
if format == "json":
return data
if format == "csv":
lines = ["field,value", f"slug,{data.slug}", f"title,{data.title}", f"status,{data.status}"]
for i, claim in enumerate(data.claims, 1):
lines.append(f"claim_{i},{claim.statement.replace(',', ';')}")
for i, src in enumerate(data.sources, 1):
lines.append(f"source_{i},{src.url}")
return PlainTextResponse("\n".join(lines), media_type="text/csv")
lines = [
f"# {data.title}",
"",
f"- Status: `{data.status}`" + (" · **disputed**" if data.disputed else ""),
f"- Zeitraum: {data.time_start} – {data.time_end or 'offen'}",
"",
"## Zusammenfassung",
"",
data.summary,
"",
"## Claims",
"",
]
for i, claim in enumerate(data.claims, 1):
cites = ", ".join(f"[{j}]" for j, _ in enumerate(claim.sources, 1))
lines.append(f"{i}. {claim.statement} {cites} (`{claim.status}`)")
lines += ["", "## Quellen", ""]
for i, src in enumerate(data.sources, 1):
accessed = src.accessed_at.date().isoformat()
lines.append(f"{i}. {src.title}. {src.url} (Zugriff {accessed})")
if data.confirmation_basis:
lines += ["", "## Bestätigungsgrundlage", "", data.confirmation_basis]
return PlainTextResponse("\n".join(lines), media_type="text/markdown")
+69
View File
@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_user
from app.db import get_db
from app.models import Case, CaseEntity, Entity, User
from app.schemas import GraphOut
router = APIRouter(tags=["graph"])
@router.get("/graph", response_model=GraphOut)
def graph(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
entity: str | None = Query(None),
case: str | None = Query(None),
) -> GraphOut:
if not entity and not case:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "entity or case required")
nodes: dict[str, dict] = {}
edges: list[dict] = []
def add_node(key: str, label: str, ntype: str) -> None:
nodes[key] = {"id": key, "label": label, "type": ntype}
if entity:
ent = db.query(Entity).filter(Entity.slug == entity).one_or_none()
if ent is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Entity not found")
add_node(f"entity:{ent.slug}", ent.name, "entity")
links = db.query(CaseEntity).filter(CaseEntity.entity_id == ent.id).all()
case_ids = [l.case_id for l in links]
cases = db.query(Case).filter(Case.id.in_(case_ids)).all() if case_ids else []
by_id = {c.id: c for c in cases}
for link in links:
c = by_id.get(link.case_id)
if not c:
continue
add_node(f"case:{c.slug}", c.title, "case")
edges.append(
{
"from": f"entity:{ent.slug}",
"to": f"case:{c.slug}",
"role": link.role.value,
}
)
if case:
c = db.query(Case).filter(Case.slug == case).one_or_none()
if c is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
add_node(f"case:{c.slug}", c.title, "case")
for link in c.entity_links:
ent = db.query(Entity).filter(Entity.id == link.entity_id).one()
add_node(f"entity:{ent.slug}", ent.name, "entity")
edges.append(
{
"from": f"case:{c.slug}",
"to": f"entity:{ent.slug}",
"role": link.role.value,
}
)
for link in c.category_links:
from app.models import Category
cat = db.query(Category).filter(Category.id == link.category_id).one()
add_node(f"category:{cat.slug}", cat.name, "category")
edges.append({"from": f"case:{c.slug}", "to": f"category:{cat.slug}", "role": "category"})
return GraphOut(nodes=list(nodes.values()), edges=edges)
+25
View File
@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import require_admin
from app.db import get_db
from app.ingest.runner import run_source
from app.models import Source, User
router = APIRouter(prefix="/ingest", tags=["ingest"])
@router.post("/run")
def run_all(db: Session = Depends(get_db), _: User = Depends(require_admin)) -> dict:
results = [run_source(db, source) for source in db.query(Source).all()]
return {"results": results}
@router.post("/run/{slug}")
def run_one(
slug: str, db: Session = Depends(get_db), _: User = Depends(require_admin)
) -> dict:
source = db.query(Source).filter(Source.slug == slug).one_or_none()
if source is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Source not found")
return run_source(db, source)
+57
View File
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.deps import get_current_user
from app.db import get_db
from app.models import Category, Jurisdiction, User
from app.schemas import CategoryOut, JurisdictionOut
router = APIRouter(tags=["taxonomy"])
def _tree(nodes, parent_id=None):
out = []
for node in nodes:
if node.parent_id == parent_id:
item = node.__class__
children = _tree(nodes, node.id)
if item is Jurisdiction:
out.append(
JurisdictionOut(
id=node.id,
slug=node.slug,
name=node.name,
type=node.type,
parent_id=node.parent_id,
iso_code=node.iso_code,
children=children,
)
)
else:
out.append(
CategoryOut(
id=node.id,
slug=node.slug,
name=node.name,
parent_id=node.parent_id,
description=node.description,
children=children,
)
)
return out
@router.get("/jurisdictions", response_model=list[JurisdictionOut])
def list_jurisdictions(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[JurisdictionOut]:
nodes = db.query(Jurisdiction).order_by(Jurisdiction.sort_order, Jurisdiction.name).all()
return _tree(nodes)
@router.get("/categories", response_model=list[CategoryOut])
def list_categories(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[CategoryOut]:
nodes = db.query(Category).order_by(Category.name).all()
return _tree(nodes)
+74
View File
@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, require_editor
from app.db import get_db
from app.models import Case, Entity, Jurisdiction, Promise, PromiseSource, User
from app.schemas import PromiseCreate, PromiseOut
from app.services.cases import resolve_source_entries
from app.services.serialize import promise_out
router = APIRouter(prefix="/promises", tags=["promises"])
@router.get("", response_model=list[PromiseOut])
def list_promises(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[PromiseOut]:
return [promise_out(db, p) for p in db.query(Promise).all()]
@router.get("/{slug}", response_model=PromiseOut)
def read_promise(
slug: str, db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> PromiseOut:
promise = db.query(Promise).filter(Promise.slug == slug).one_or_none()
if promise is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promise not found")
return promise_out(db, promise)
@router.post("", response_model=PromiseOut, status_code=201)
def post_promise(
payload: PromiseCreate,
db: Session = Depends(get_db),
_: User = Depends(require_editor),
) -> PromiseOut:
entity = db.query(Entity).filter(Entity.slug == payload.entity_slug).one_or_none()
if entity is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Entity not found")
case_id = None
if payload.related_case_slug:
case = db.query(Case).filter(Case.slug == payload.related_case_slug).one_or_none()
if case is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
case_id = case.id
jur_id = None
if payload.jurisdiction_slug:
jur = (
db.query(Jurisdiction)
.filter(Jurisdiction.slug == payload.jurisdiction_slug)
.one_or_none()
)
if jur is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Jurisdiction not found")
jur_id = jur.id
if not payload.sources:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Promise requires at least one source")
entries = resolve_source_entries(db, payload.sources)
promise = Promise(
slug=payload.slug,
entity_id=entity.id,
text=payload.text,
promised_on=payload.promised_on,
context=payload.context,
status=payload.status,
related_case_id=case_id,
jurisdiction_id=jur_id,
)
db.add(promise)
db.flush()
for entry in entries.values():
db.add(PromiseSource(promise_id=promise.id, source_entry_id=entry.id))
db.commit()
return promise_out(db, promise)
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, require_editor
from app.db import get_db
from app.models import ActorType, AuditAction, Case, ReviewItem, ReviewStatus, User
from app.schemas import ReviewDecisionIn, ReviewItemOut
from app.services.audit import log
router = APIRouter(prefix="/review", tags=["review"])
@router.get("", response_model=list[ReviewItemOut])
def list_review(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
status_filter: ReviewStatus | None = Query(None, alias="status"),
) -> list[ReviewItem]:
q = db.query(ReviewItem).order_by(ReviewItem.created_at.desc())
if status_filter:
q = q.filter(ReviewItem.status == status_filter)
else:
q = q.filter(ReviewItem.status == ReviewStatus.pending)
return q.limit(200).all()
@router.post("/{item_id}", response_model=ReviewItemOut)
def decide(
item_id,
payload: ReviewDecisionIn,
db: Session = Depends(get_db),
user: User = Depends(require_editor),
) -> ReviewItem:
item = db.query(ReviewItem).filter(ReviewItem.id == item_id).one_or_none()
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Review item not found")
if payload.status == ReviewStatus.merged and not payload.matched_case_slug:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "merged requires matched_case_slug")
case_id = None
if payload.matched_case_slug:
case = db.query(Case).filter(Case.slug == payload.matched_case_slug).one_or_none()
if case is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
case_id = case.id
item.status = payload.status
item.matched_case_id = case_id
item.decided_by = user.id
item.decided_at = datetime.now(timezone.utc)
log(
db,
actor_type=ActorType.user,
actor_id=user.id,
table="review_items",
entity_id=item.id,
action=AuditAction.review,
after={"status": item.status.value},
)
db.commit()
db.refresh(item)
return item
+40
View File
@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session, selectinload
from app.core.deps import get_current_user
from app.db import get_db
from app.models import Case, Entity, EntityAlias, User
from app.schemas import SearchOut
from app.services.serialize import case_list_out, entity_out
router = APIRouter(tags=["search"])
@router.get("/search", response_model=SearchOut)
def search(
q: str = Query(..., min_length=2),
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
status: str | None = None,
cluster: str | None = None,
disputed: bool | None = None,
) -> SearchOut:
like = f"%{q}%"
cq = db.query(Case).filter(or_(Case.title.ilike(like), Case.summary.ilike(like)))
if status:
cq = cq.filter(Case.status == status)
if cluster:
cq = cq.filter(Case.cluster_tags.any(cluster))
if disputed is not None:
cq = cq.filter(Case.disputed == disputed)
alias_ids = db.query(EntityAlias.entity_id).filter(EntityAlias.alias.ilike(like))
eq = (
db.query(Entity)
.options(selectinload(Entity.aliases))
.filter(or_(Entity.name.ilike(like), Entity.slug.ilike(like), Entity.id.in_(alias_ids)))
)
return SearchOut(
cases=[case_list_out(c) for c in cq.limit(50).all()],
entities=[entity_out(e) for e in eq.limit(50).all()],
)
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.deps import get_current_user, require_admin
from app.db import get_db
from app.models import Source, User
from app.schemas import SourceOut
from app.services.sources import sync_catalog
router = APIRouter(prefix="/sources", tags=["sources"])
@router.get("", response_model=list[SourceOut])
def list_sources(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Source]:
return db.query(Source).order_by(Source.name).all()
@router.post("/sync")
def sync_sources(
db: Session = Depends(get_db), _: User = Depends(require_admin)
) -> dict:
count = sync_catalog(db, get_settings().sources_yaml)
return {"upserted": count}
+65
View File
@@ -0,0 +1,65 @@
"""CLI: python -m app.cli <command>"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from app.core.config import get_settings
from app.core.security import hash_password
from app.db import SessionLocal, enable_extensions
from app.ingest.runner import run_source
from app.models import Source, User, UserRole
from app.services.seed import seed_all
from app.services.sources import sync_catalog
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="quellenatlas")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("sync-sources")
ingest = sub.add_parser("ingest")
ingest.add_argument("--slug")
sub.add_parser("seed")
sub.add_parser("bootstrap-admin")
args = parser.parse_args(argv)
settings = get_settings()
enable_extensions()
db = SessionLocal()
try:
if args.cmd == "sync-sources":
n = sync_catalog(db, settings.sources_yaml)
print(f"synced {n} sources")
elif args.cmd == "ingest":
q = db.query(Source)
if args.slug:
q = q.filter(Source.slug == args.slug)
for source in q.all():
print(run_source(db, source))
elif args.cmd == "seed":
stats = seed_all(db, Path(settings.seed_dir))
print(stats)
elif args.cmd == "bootstrap-admin":
user = db.query(User).filter(User.username == settings.admin_username).one_or_none()
if user is None:
db.add(
User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
role=UserRole.admin,
)
)
db.commit()
print("admin created")
else:
print("admin exists")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())
View File
Whitespace-only changes.
+28
View File
@@ -0,0 +1,28 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=(".env", "../.env"), extra="ignore")
database_url: str = (
"postgresql+psycopg://quellenatlas:quellenatlas@localhost:5433/quellenatlas"
)
jwt_secret: str = "quellenatlas-dev-secret-please-change-32b"
jwt_expire_minutes: int = 720
cookie_name: str = "quellenatlas"
admin_username: str = "admin"
admin_password: str = "changeme"
backend_cors_origins: str = "http://localhost:3000"
sources_yaml: str = "../data/sources.yaml"
seed_dir: str = "../data/seed"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.backend_cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+35
View File
@@ -0,0 +1,35 @@
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import decode_token
from app.db import get_db
from app.models import User, UserRole
def get_current_user(
db: Session = Depends(get_db),
token: str | None = Cookie(default=None, alias=get_settings().cookie_name),
) -> User:
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
try:
payload = decode_token(token)
except Exception as exc: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid session") from exc
user = db.query(User).filter(User.username == payload.get("sub")).one_or_none()
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Unknown user")
return user
def require_editor(user: User = Depends(get_current_user)) -> User:
if user.role not in {UserRole.admin, UserRole.editor}:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Editor role required")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role != UserRole.admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin role required")
return user
+100
View File
@@ -0,0 +1,100 @@
"""Hard ethics rules. Services must call these before committing KB writes."""
from __future__ import annotations
from datetime import date, datetime
from typing import Iterable
OFFICIAL_SOURCE_TYPES = frozenset(
{"court", "official_register", "parliamentary"}
)
INGEST_ALLOWED_TABLES = frozenset(
{"source_entries", "review_items", "update_logs", "sources"}
)
# Loaded blame / verdict language that must not appear in summaries.
OPINION_MARKERS = (
"korrupt-kriminell",
"kriminelle energie",
"offensichtlich gelogen",
"offensichtlich schuldig",
"ganz klar schuldig",
"verbrecher",
"abzocker",
"volksbetrüger",
"lügner",
"skandalös",
"durchgeknallt",
"mafia-methoden",
)
class EthicsError(ValueError):
"""Raised when a write would violate source or neutrality rules."""
def validate_claim(*, statement: str, source_ids: Iterable[str], status: str) -> None:
if not (statement or "").strip():
raise EthicsError("Claim statement must not be empty")
ids = [s for s in source_ids if s]
if not ids:
raise EthicsError("Every claim requires at least one source")
if status == "confirmed" and not ids:
raise EthicsError("confirmed claim requires at least one source")
def validate_case(
*,
status: str,
source_types: Iterable[str],
source_ids: Iterable[str],
confirmation_basis: str | None,
) -> None:
ids = [s for s in source_ids if s]
if not ids:
raise EthicsError("Every case requires at least one source")
if status == "confirmed":
has_official = any(t in OFFICIAL_SOURCE_TYPES for t in source_types)
basis = (confirmation_basis or "").strip()
if not has_official and not basis:
raise EthicsError(
"confirmed requires an official/court/parliamentary source "
"or a non-empty confirmation_basis"
)
def validate_event(*, source_ids: Iterable[str]) -> None:
if not [s for s in source_ids if s]:
raise EthicsError("Every chronology event requires at least one source")
def validate_high_care_sources(*, care_level: str, sources: Iterable[dict]) -> None:
if care_level != "high":
return
for src in sources:
accessed = src.get("accessed_at")
if accessed in (None, ""):
raise EthicsError(
"high care_level requires accessed_at on every used source"
)
if isinstance(accessed, str) and not accessed.strip():
raise EthicsError(
"high care_level requires accessed_at on every used source"
)
def validate_ingest_target(table: str) -> None:
if table not in INGEST_ALLOWED_TABLES:
raise EthicsError("ingest must not write cases")
def lint_neutral_text(text: str) -> list[str]:
lowered = (text or "").lower()
return [marker for marker in OPINION_MARKERS if marker in lowered]
def require_accessed_at(value: datetime | date | str | None) -> datetime | date | str:
if value in (None, ""):
raise EthicsError("accessed_at is required")
return value
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
from app.core.config import get_settings
pwd_hash = PasswordHash.recommended()
def hash_password(plain: str) -> str:
return pwd_hash.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_hash.verify(plain, hashed)
def create_token(subject: str, role: str) -> str:
settings = get_settings()
payload = {
"sub": subject,
"role": role,
"exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
def decode_token(token: str) -> dict:
settings = get_settings()
return jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
+24
View File
@@ -0,0 +1,24 @@
from collections.abc import Generator
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def enable_extensions(bind=engine) -> None:
with bind.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
conn.commit()
View File
Whitespace-only changes.
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import hashlib
import re
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
TRACKING_PARAMS = {
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
}
def canonicalize_url(url: str) -> str:
parsed = urlparse((url or "").strip())
scheme = (parsed.scheme or "https").lower()
netloc = parsed.netloc.lower()
path = re.sub(r"/+", "/", parsed.path) or "/"
if path != "/" and path.endswith("/"):
path = path[:-1]
query = [
(k, v)
for k, v in parse_qsl(parsed.query, keep_blank_values=True)
if k.lower() not in TRACKING_PARAMS
]
query.sort()
return urlunparse((scheme, netloc, path, "", urlencode(query), ""))
def content_hash(title: str, url: str, excerpt: str | None = None) -> str:
blob = f"{canonicalize_url(url)}\n{(title or '').strip()}\n{(excerpt or '').strip()}"
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
+48
View File
@@ -0,0 +1,48 @@
"""Keyword lists for review-queue priority. Conservative, not ML."""
from __future__ import annotations
HIGH_PRIORITY = {
"vdl": (
"von der leyen",
"ursula von der leyen",
"vdl",
"pfizergate",
"pfizer-vertrag",
"pfizer contract",
"heiko von der leyen",
"orgenesis",
"berateraffäre",
"berateraffaere",
),
"ukraine": (
"ukraine facility",
"ukraine-fazilität",
"ukraine fazilität",
"wiederaufbau ukraine",
"ukraine reconstruction",
"nabu",
"sapo",
"makrofinanzhilfe",
"macro-financial assistance",
"waffenliefer",
"ukraine-hilfe",
"ukraine aid",
"kiew",
"kyiv",
"kyjiw",
),
}
def match_clusters(text: str) -> list[str]:
lowered = (text or "").lower()
hits: list[str] = []
for cluster, words in HIGH_PRIORITY.items():
if any(word in lowered for word in words):
hits.append(cluster)
return hits
def is_high_priority(text: str) -> bool:
return bool(match_clusters(text))
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
from datetime import datetime, timezone
import feedparser
import httpx
from sqlalchemy.orm import Session
from app.core.ethics import validate_ingest_target
from app.ingest.dedup import canonicalize_url, content_hash
from app.ingest.keywords import is_high_priority, match_clusters
from app.models import (
ActorType,
AuditAction,
IngestMethod,
ReviewItem,
ReviewPriority,
ReviewStatus,
Source,
SourceStatus,
)
from app.services.audit import log
from app.services.sources import upsert_source_entry
USER_AGENT = "quellenatlas/0.1 (+private research; rss/api only)"
def run_source(db: Session, source: Source) -> dict:
validate_ingest_target("source_entries")
validate_ingest_target("review_items")
if source.status != SourceStatus.active:
return {"slug": source.slug, "skipped": True, "reason": "inactive"}
created_entries = 0
created_reviews = 0
try:
items = fetch_items(source)
for item in items:
url = canonicalize_url(item["url"])
title = item.get("title") or url
excerpt = item.get("excerpt")
digest = content_hash(title, url, excerpt)
entry, created = upsert_source_entry(
db,
source=source,
url=url,
title=title,
published_at=item.get("published_at"),
excerpt=excerpt,
content_hash=digest,
raw=item.get("raw"),
language=item.get("language"),
)
if created:
created_entries += 1
blob = f"{title}\n{excerpt or ''}"
existing_review = (
db.query(ReviewItem)
.filter(ReviewItem.url == url, ReviewItem.status == ReviewStatus.pending)
.one_or_none()
)
if existing_review is None and created:
db.add(
ReviewItem(
source_id=source.id,
source_entry_id=entry.id,
title=title,
url=url,
snippet=(excerpt or "")[:500] or None,
suggested_entities=[],
suggested_categories=match_clusters(blob),
priority=(
ReviewPriority.high
if is_high_priority(blob)
else ReviewPriority.normal
),
)
)
created_reviews += 1
source.last_success_at = datetime.now(timezone.utc)
source.last_error = None
log(
db,
actor_type=ActorType.pipeline,
actor_id=None,
table="sources",
entity_id=source.id,
action=AuditAction.ingest,
after={"created_entries": created_entries, "created_reviews": created_reviews},
)
db.commit()
return {
"slug": source.slug,
"created_entries": created_entries,
"created_reviews": created_reviews,
}
except Exception as exc: # noqa: BLE001
db.rollback()
src = db.query(Source).filter(Source.id == source.id).one()
src.last_error = str(exc)[:2000]
db.commit()
return {"slug": source.slug, "error": str(exc)}
def fetch_items(source: Source) -> list[dict]:
method = source.ingest_method
config = source.ingest_config or {}
if method == IngestMethod.rss:
return fetch_rss(config.get("feed_url") or source.base_url)
if method == IngestMethod.api:
kind = config.get("api")
if kind == "opensanctions":
return fetch_opensanctions(config)
if kind == "dip":
return fetch_dip(config)
if kind == "eurlex":
return fetch_rss(config.get("feed_url") or source.base_url)
return []
def fetch_rss(url: str) -> list[dict]:
parsed = feedparser.parse(url, agent=USER_AGENT)
items = []
for entry in parsed.entries[:50]:
link = entry.get("link")
if not link:
continue
published = None
if entry.get("published_parsed"):
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
items.append(
{
"url": link,
"title": entry.get("title") or link,
"excerpt": (entry.get("summary") or "")[:1000],
"published_at": published,
"raw": {"id": entry.get("id"), "title": entry.get("title")},
}
)
return items
def fetch_opensanctions(config: dict) -> list[dict]:
query = config.get("query") or "ukraine"
url = config.get("url") or "https://api.opensanctions.org/search/default"
with httpx.Client(timeout=30.0, headers={"User-Agent": USER_AGENT}) as client:
response = client.get(url, params={"q": query, "limit": 20})
response.raise_for_status()
data = response.json()
items = []
for rec in data.get("results") or []:
ident = rec.get("id") or rec.get("caption")
caption = rec.get("caption") or ident
link = rec.get("first_seen") and f"https://www.opensanctions.org/entities/{ident}"
if not ident:
continue
items.append(
{
"url": link or f"https://www.opensanctions.org/entities/{ident}",
"title": str(caption),
"excerpt": rec.get("schema"),
"raw": {"id": ident},
}
)
return items
def fetch_dip(config: dict) -> list[dict]:
url = config.get("url") or "https://search.dip.bundestag.de/api/v1/drucksache"
params = config.get("params") or {"f.wahlperiode": 20, "format": "json"}
with httpx.Client(timeout=30.0, headers={"User-Agent": USER_AGENT}) as client:
response = client.get(url, params=params)
response.raise_for_status()
data = response.json()
items = []
for doc in (data.get("documents") or [])[:30]:
ident = doc.get("id")
title = doc.get("titel") or str(ident)
link = doc.get("fundstelle") or f"https://dip.bundestag.de/vorgang/{ident}"
if not ident:
continue
items.append(
{
"url": str(link),
"title": str(title)[:500],
"excerpt": (doc.get("abstract") or "")[:1000],
"raw": {"id": ident},
}
)
return items
+53
View File
@@ -0,0 +1,53 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import api_router
from app.core.config import get_settings
from app.core.security import hash_password
from app.db import SessionLocal, enable_extensions
from app.models import User, UserRole
from app.schemas import HealthOut
@asynccontextmanager
async def lifespan(_: FastAPI):
settings = get_settings()
try:
enable_extensions()
db = SessionLocal()
try:
existing = db.query(User).filter(User.username == settings.admin_username).one_or_none()
if existing is None:
db.add(
User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
role=UserRole.admin,
)
)
db.commit()
finally:
db.close()
except Exception:
# DB may not be ready during unit tests that don't use Postgres.
pass
yield
app = FastAPI(title="Quellenatlas", lifespan=lifespan)
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health", response_model=HealthOut)
def health() -> HealthOut:
return HealthOut(status="ok")
+87
View File
@@ -0,0 +1,87 @@
from app.models.audit import UpdateLog
from app.models.base import Base
from app.models.case import (
Case,
CaseCategory,
CaseEntity,
CaseEvent,
CaseEventSource,
CaseJurisdiction,
CaseRevision,
CaseSource,
Claim,
ClaimSource,
Conflict,
ConflictSource,
)
from app.models.category import Category
from app.models.entity import Entity, EntityAlias, EntityRole
from app.models.enums import (
ActorType,
AuditAction,
CareLevel,
CaseEntityRole,
CaseStatus,
ClusterTag,
ConflictStatus,
EntityType,
IngestMethod,
JurisdictionType,
PromiseStatus,
ReviewPriority,
ReviewStatus,
SourceStatus,
SourceType,
UpdateFrequency,
UserRole,
)
from app.models.jurisdiction import Jurisdiction
from app.models.promise import Promise, PromiseSource
from app.models.review import ReviewItem
from app.models.source import Source, SourceEntry
from app.models.user import User
__all__ = [
"ActorType",
"AuditAction",
"Base",
"CareLevel",
"Case",
"CaseCategory",
"CaseEntity",
"CaseEntityRole",
"CaseEvent",
"CaseEventSource",
"CaseJurisdiction",
"CaseRevision",
"CaseSource",
"CaseStatus",
"Category",
"Claim",
"ClaimSource",
"ClusterTag",
"Conflict",
"ConflictSource",
"ConflictStatus",
"Entity",
"EntityAlias",
"EntityRole",
"EntityType",
"IngestMethod",
"Jurisdiction",
"JurisdictionType",
"Promise",
"PromiseSource",
"PromiseStatus",
"ReviewItem",
"ReviewPriority",
"ReviewStatus",
"Source",
"SourceEntry",
"SourceStatus",
"SourceType",
"UpdateFrequency",
"UpdateLog",
"User",
"UserRole",
]
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
import uuid
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
from app.models.enums import ActorType, AuditAction
class UpdateLog(UUIDMixin, TimestampMixin, Base):
__tablename__ = "update_logs"
actor_type: Mapped[ActorType] = mapped_column(nullable=False)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
entity_table: Mapped[str] = mapped_column(String(64), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
action: Mapped[AuditAction] = mapped_column(nullable=False)
before: Mapped[dict | None] = mapped_column(JSONB)
after: Mapped[dict | None] = mapped_column(JSONB)
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, MetaData, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
NAMING = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING)
class UUIDMixin:
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
from app.models.enums import CareLevel, CaseEntityRole, CaseStatus, ConflictStatus
class Case(UUIDMixin, TimestampMixin, Base):
__tablename__ = "cases"
slug: Mapped[str] = mapped_column(String(220), unique=True, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
time_start: Mapped[date] = mapped_column(Date, nullable=False)
time_end: Mapped[date | None] = mapped_column(Date)
status: Mapped[CaseStatus] = mapped_column(
default=CaseStatus.allegation, nullable=False
)
disputed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confirmation_basis: Mapped[str | None] = mapped_column(Text)
care_level: Mapped[CareLevel] = mapped_column(
default=CareLevel.standard, nullable=False
)
cluster_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
consequences: Mapped[str | None] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
claims: Mapped[list[Claim]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
events: Mapped[list[CaseEvent]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
entity_links: Mapped[list[CaseEntity]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
source_links: Mapped[list[CaseSource]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
jurisdiction_links: Mapped[list[CaseJurisdiction]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
category_links: Mapped[list[CaseCategory]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
revisions: Mapped[list[CaseRevision]] = relationship(
back_populates="case", cascade="all, delete-orphan"
)
class CaseJurisdiction(UUIDMixin, Base):
__tablename__ = "case_jurisdictions"
__table_args__ = (UniqueConstraint("case_id", "jurisdiction_id"),)
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
jurisdiction_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("jurisdictions.id", ondelete="CASCADE"),
nullable=False,
)
case: Mapped[Case] = relationship(back_populates="jurisdiction_links")
class CaseCategory(UUIDMixin, Base):
__tablename__ = "case_categories"
__table_args__ = (UniqueConstraint("case_id", "category_id"),)
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
category_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
)
case: Mapped[Case] = relationship(back_populates="category_links")
class CaseEntity(UUIDMixin, Base):
__tablename__ = "case_entities"
__table_args__ = (UniqueConstraint("case_id", "entity_id", "role"),)
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[CaseEntityRole] = mapped_column(nullable=False)
case: Mapped[Case] = relationship(back_populates="entity_links")
class CaseSource(UUIDMixin, Base):
__tablename__ = "case_source_entries"
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
source_entry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("source_entries.id", ondelete="CASCADE"),
nullable=False,
)
locator: Mapped[str | None] = mapped_column(String(255))
quote: Mapped[str | None] = mapped_column(Text)
accessed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
case: Mapped[Case] = relationship(back_populates="source_links")
class CaseEvent(UUIDMixin, Base):
__tablename__ = "case_events"
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
occurred_on: Mapped[date | None] = mapped_column(Date)
title: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str | None] = mapped_column(Text)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
case: Mapped[Case] = relationship(back_populates="events")
source_links: Mapped[list[CaseEventSource]] = relationship(
back_populates="event", cascade="all, delete-orphan"
)
class CaseEventSource(UUIDMixin, Base):
__tablename__ = "case_event_source_entries"
event_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("case_events.id", ondelete="CASCADE"),
nullable=False,
)
source_entry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("source_entries.id", ondelete="CASCADE"),
nullable=False,
)
event: Mapped[CaseEvent] = relationship(back_populates="source_links")
class CaseRevision(UUIDMixin, TimestampMixin, Base):
__tablename__ = "case_revisions"
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
snapshot: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
case: Mapped[Case] = relationship(back_populates="revisions")
class Claim(UUIDMixin, Base):
__tablename__ = "claims"
case_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False
)
statement: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[CaseStatus] = mapped_column(
default=CaseStatus.allegation, nullable=False
)
disputed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
case: Mapped[Case] = relationship(back_populates="claims")
source_links: Mapped[list[ClaimSource]] = relationship(
back_populates="claim", cascade="all, delete-orphan"
)
conflicts: Mapped[list[Conflict]] = relationship(
back_populates="claim", cascade="all, delete-orphan"
)
class ClaimSource(UUIDMixin, Base):
__tablename__ = "claim_source_entries"
claim_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
)
source_entry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("source_entries.id", ondelete="CASCADE"),
nullable=False,
)
locator: Mapped[str | None] = mapped_column(String(255))
quote: Mapped[str | None] = mapped_column(Text)
claim: Mapped[Claim] = relationship(back_populates="source_links")
class Conflict(UUIDMixin, Base):
__tablename__ = "conflicts"
claim_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[ConflictStatus] = mapped_column(
default=ConflictStatus.unresolved, nullable=False
)
notes: Mapped[str | None] = mapped_column(Text)
claim: Mapped[Claim] = relationship(back_populates="conflicts")
source_links: Mapped[list[ConflictSource]] = relationship(
back_populates="conflict", cascade="all, delete-orphan"
)
class ConflictSource(UUIDMixin, Base):
__tablename__ = "conflict_source_entries"
conflict_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("conflicts.id", ondelete="CASCADE"), nullable=False
)
source_entry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("source_entries.id", ondelete="CASCADE"),
nullable=False,
)
conflict: Mapped[Conflict] = relationship(back_populates="source_links")
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, UUIDMixin
class Category(UUIDMixin, Base):
__tablename__ = "categories"
slug: Mapped[str] = mapped_column(String(160), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL")
)
description: Mapped[str | None] = mapped_column(Text)
parent: Mapped[Category | None] = relationship(
remote_side="Category.id", back_populates="children"
)
children: Mapped[list[Category]] = relationship(back_populates="parent")
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Boolean, Date, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, UUIDMixin
from app.models.enums import CareLevel, EntityType
class Entity(UUIDMixin, Base):
__tablename__ = "entities"
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
entity_type: Mapped[EntityType] = mapped_column(nullable=False)
jurisdiction_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("jurisdictions.id", ondelete="SET NULL")
)
care_level: Mapped[CareLevel] = mapped_column(
default=CareLevel.standard, nullable=False
)
cluster_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
notes_neutral: Mapped[str | None] = mapped_column(Text)
wikidata_id: Mapped[str | None] = mapped_column(String(32))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
jurisdiction: Mapped["Jurisdiction | None"] = relationship()
aliases: Mapped[list[EntityAlias]] = relationship(
back_populates="entity", cascade="all, delete-orphan"
)
roles: Mapped[list[EntityRole]] = relationship(
back_populates="entity",
cascade="all, delete-orphan",
foreign_keys="EntityRole.entity_id",
)
class EntityAlias(UUIDMixin, Base):
__tablename__ = "entity_aliases"
__table_args__ = (UniqueConstraint("entity_id", "alias"),)
entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), nullable=False
)
alias: Mapped[str] = mapped_column(String(255), nullable=False)
lang: Mapped[str | None] = mapped_column(String(8))
entity: Mapped[Entity] = relationship(back_populates="aliases")
class EntityRole(UUIDMixin, Base):
__tablename__ = "entity_roles"
entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
organization_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="SET NULL")
)
jurisdiction_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("jurisdictions.id", ondelete="SET NULL")
)
started_on: Mapped[date | None] = mapped_column(Date)
ended_on: Mapped[date | None] = mapped_column(Date)
source_entry_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_entries.id", ondelete="SET NULL")
)
entity: Mapped[Entity] = relationship(back_populates="roles", foreign_keys=[entity_id])
+127
View File
@@ -0,0 +1,127 @@
from enum import StrEnum
class JurisdictionType(StrEnum):
country = "country"
region = "region"
municipality = "municipality"
institution = "institution"
international = "international"
class EntityType(StrEnum):
person = "person"
party = "party"
ngo = "ngo"
foundation = "foundation"
think_tank = "think_tank"
company = "company"
lobby = "lobby"
agency = "agency"
institution = "institution"
other = "other"
class CareLevel(StrEnum):
standard = "standard"
high = "high"
class CaseStatus(StrEnum):
confirmed = "confirmed"
allegation = "allegation"
refuted = "refuted"
ongoing = "ongoing"
class PromiseStatus(StrEnum):
kept = "kept"
broken = "broken"
partial = "partial"
unclear = "unclear"
superseded = "superseded"
class SourceType(StrEnum):
news = "news"
investigative = "investigative"
official_register = "official_register"
ngo_report = "ngo_report"
parliamentary = "parliamentary"
court = "court"
wiki = "wiki"
academic = "academic"
other = "other"
class SourceStatus(StrEnum):
active = "active"
paused = "paused"
problematic = "problematic"
class IngestMethod(StrEnum):
rss = "rss"
api = "api"
manual = "manual"
class UpdateFrequency(StrEnum):
hourly = "hourly"
daily = "daily"
weekly = "weekly"
monthly = "monthly"
manual = "manual"
class CaseEntityRole(StrEnum):
subject = "subject"
decision_maker = "decision_maker"
beneficiary = "beneficiary"
investigator = "investigator"
mentioned = "mentioned"
related = "related"
class ReviewStatus(StrEnum):
pending = "pending"
accepted = "accepted"
rejected = "rejected"
merged = "merged"
class ReviewPriority(StrEnum):
normal = "normal"
high = "high"
class ConflictStatus(StrEnum):
unresolved = "unresolved"
marked_disputed = "marked_disputed"
noted = "noted"
class UserRole(StrEnum):
admin = "admin"
editor = "editor"
viewer = "viewer"
class ActorType(StrEnum):
user = "user"
pipeline = "pipeline"
class AuditAction(StrEnum):
create = "create"
update = "update"
delete = "delete"
ingest = "ingest"
review = "review"
class ClusterTag(StrEnum):
vdl = "vdl"
ukraine = "ukraine"
eu_top = "eu-top"
dach = "dach"
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, UUIDMixin
from app.models.enums import JurisdictionType
class Jurisdiction(UUIDMixin, Base):
__tablename__ = "jurisdictions"
slug: Mapped[str] = mapped_column(String(160), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
type: Mapped[JurisdictionType] = mapped_column(nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("jurisdictions.id", ondelete="SET NULL")
)
iso_code: Mapped[str | None] = mapped_column(String(8))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
parent: Mapped[Jurisdiction | None] = relationship(
remote_side="Jurisdiction.id", back_populates="children"
)
children: Mapped[list[Jurisdiction]] = relationship(back_populates="parent")
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, UUIDMixin
from app.models.enums import PromiseStatus
class Promise(UUIDMixin, Base):
__tablename__ = "promises"
slug: Mapped[str] = mapped_column(String(220), unique=True, nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), nullable=False
)
text: Mapped[str] = mapped_column(Text, nullable=False)
promised_on: Mapped[date | None] = mapped_column(Date)
context: Mapped[str | None] = mapped_column(Text)
status: Mapped[PromiseStatus] = mapped_column(
default=PromiseStatus.unclear, nullable=False
)
related_case_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="SET NULL")
)
jurisdiction_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("jurisdictions.id", ondelete="SET NULL")
)
source_links: Mapped[list[PromiseSource]] = relationship(
back_populates="promise", cascade="all, delete-orphan"
)
class PromiseSource(UUIDMixin, Base):
__tablename__ = "promise_source_entries"
promise_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("promises.id", ondelete="CASCADE"), nullable=False
)
source_entry_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("source_entries.id", ondelete="CASCADE"),
nullable=False,
)
promise: Mapped[Promise] = relationship(back_populates="source_links")
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
from app.models.enums import ReviewPriority, ReviewStatus
class ReviewItem(UUIDMixin, TimestampMixin, Base):
__tablename__ = "review_items"
source_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), nullable=False
)
source_entry_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("source_entries.id", ondelete="SET NULL")
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
url: Mapped[str] = mapped_column(String(1000), nullable=False)
snippet: Mapped[str | None] = mapped_column(Text)
suggested_entities: Mapped[list] = mapped_column(JSONB, default=list)
suggested_categories: Mapped[list] = mapped_column(JSONB, default=list)
priority: Mapped[ReviewPriority] = mapped_column(
default=ReviewPriority.normal, nullable=False
)
status: Mapped[ReviewStatus] = mapped_column(
default=ReviewStatus.pending, nullable=False
)
matched_case_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("cases.id", ondelete="SET NULL")
)
decided_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, UUIDMixin
from app.models.enums import IngestMethod, SourceStatus, SourceType, UpdateFrequency
class Source(UUIDMixin, Base):
__tablename__ = "sources"
slug: Mapped[str] = mapped_column(String(160), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
type: Mapped[SourceType] = mapped_column(nullable=False)
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
language_codes: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
reliability: Mapped[int] = mapped_column(Integer, nullable=False)
update_frequency: Mapped[UpdateFrequency] = mapped_column(nullable=False)
ingest_method: Mapped[IngestMethod] = mapped_column(nullable=False)
ingest_config: Mapped[dict] = mapped_column(JSONB, default=dict)
topic_focus: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
status: Mapped[SourceStatus] = mapped_column(
default=SourceStatus.active, nullable=False
)
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str | None] = mapped_column(Text)
notes: Mapped[str | None] = mapped_column(Text)
entries: Mapped[list[SourceEntry]] = relationship(back_populates="source")
class SourceEntry(UUIDMixin, Base):
__tablename__ = "source_entries"
source_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("sources.id", ondelete="CASCADE"), nullable=False
)
url: Mapped[str] = mapped_column(String(1000), unique=True, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
accessed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
language: Mapped[str | None] = mapped_column(String(8))
excerpt: Mapped[str | None] = mapped_column(Text)
content_hash: Mapped[str | None] = mapped_column(String(64))
raw: Mapped[dict | None] = mapped_column(JSONB)
source: Mapped[Source] = relationship(back_populates="entries")
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDMixin
from app.models.enums import UserRole
class User(UUIDMixin, TimestampMixin, Base):
__tablename__ = "users"
username: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(nullable=False, default=UserRole.viewer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field, HttpUrl
from app.models.enums import (
CareLevel,
CaseEntityRole,
CaseStatus,
EntityType,
JurisdictionType,
PromiseStatus,
ReviewPriority,
ReviewStatus,
SourceStatus,
SourceType,
UserRole,
)
class LoginIn(BaseModel):
username: str
password: str
class UserOut(BaseModel):
id: UUID
username: str
role: UserRole
model_config = {"from_attributes": True}
class JurisdictionOut(BaseModel):
id: UUID
slug: str
name: str
type: JurisdictionType
parent_id: UUID | None
iso_code: str | None
children: list[JurisdictionOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
class CategoryOut(BaseModel):
id: UUID
slug: str
name: str
parent_id: UUID | None
description: str | None
children: list[CategoryOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
class SourceRefIn(BaseModel):
source_slug: str
url: str
title: str
accessed_at: datetime
excerpt: str | None = None
published_at: datetime | None = None
locator: str | None = None
quote: str | None = None
class ClaimIn(BaseModel):
statement: str
status: CaseStatus = CaseStatus.allegation
disputed: bool = False
source_urls: list[str]
class EventIn(BaseModel):
occurred_on: date | None = None
title: str
body: str | None = None
sort_order: int = 0
source_urls: list[str]
class EntityLinkIn(BaseModel):
slug: str
role: CaseEntityRole = CaseEntityRole.subject
class CaseCreate(BaseModel):
slug: str
title: str
summary: str
time_start: date
time_end: date | None = None
status: CaseStatus = CaseStatus.allegation
disputed: bool = False
confirmation_basis: str | None = None
care_level: CareLevel = CareLevel.standard
cluster_tags: list[str] = Field(default_factory=list)
consequences: str | None = None
jurisdiction_slugs: list[str]
category_slugs: list[str]
entities: list[EntityLinkIn]
sources: list[SourceRefIn]
claims: list[ClaimIn] = Field(default_factory=list)
events: list[EventIn] = Field(default_factory=list)
class CaseUpdate(BaseModel):
title: str | None = None
summary: str | None = None
time_end: date | None = None
status: CaseStatus | None = None
disputed: bool | None = None
confirmation_basis: str | None = None
consequences: str | None = None
class SourceOut(BaseModel):
id: UUID
slug: str
name: str
type: SourceType
base_url: str
reliability: int
status: SourceStatus
topic_focus: list[str]
last_success_at: datetime | None
last_error: str | None
ingest_method: str
notes: str | None
model_config = {"from_attributes": True}
class SourceEntryOut(BaseModel):
id: UUID
source_id: UUID
url: str
title: str
published_at: datetime | None
accessed_at: datetime
excerpt: str | None
source_name: str | None = None
source_type: SourceType | None = None
reliability: int | None = None
model_config = {"from_attributes": True}
class ClaimOut(BaseModel):
id: UUID
statement: str
status: CaseStatus
disputed: bool
sort_order: int
sources: list[SourceEntryOut] = Field(default_factory=list)
class EventOut(BaseModel):
id: UUID
occurred_on: date | None
title: str
body: str | None
sort_order: int
sources: list[SourceEntryOut] = Field(default_factory=list)
class EntityOut(BaseModel):
id: UUID
slug: str
name: str
entity_type: EntityType
care_level: CareLevel
cluster_tags: list[str]
notes_neutral: str | None
wikidata_id: str | None
aliases: list[str] = Field(default_factory=list)
model_config = {"from_attributes": True}
class EntityCreate(BaseModel):
slug: str
name: str
entity_type: EntityType
jurisdiction_slug: str | None = None
care_level: CareLevel = CareLevel.standard
cluster_tags: list[str] = Field(default_factory=list)
notes_neutral: str | None = None
wikidata_id: str | None = None
aliases: list[str] = Field(default_factory=list)
class CaseListOut(BaseModel):
id: UUID
slug: str
title: str
status: CaseStatus
disputed: bool
care_level: CareLevel
cluster_tags: list[str]
time_start: date
time_end: date | None
class CaseOut(CaseListOut):
summary: str
confirmation_basis: str | None
consequences: str | None
jurisdictions: list[str] = Field(default_factory=list)
categories: list[str] = Field(default_factory=list)
entities: list[dict] = Field(default_factory=list)
sources: list[SourceEntryOut] = Field(default_factory=list)
claims: list[ClaimOut] = Field(default_factory=list)
events: list[EventOut] = Field(default_factory=list)
class PromiseCreate(BaseModel):
slug: str
entity_slug: str
text: str
promised_on: date | None = None
context: str | None = None
status: PromiseStatus = PromiseStatus.unclear
related_case_slug: str | None = None
jurisdiction_slug: str | None = None
sources: list[SourceRefIn]
class PromiseOut(BaseModel):
id: UUID
slug: str
entity_slug: str | None = None
text: str
promised_on: date | None
context: str | None
status: PromiseStatus
related_case_slug: str | None = None
sources: list[SourceEntryOut] = Field(default_factory=list)
class ReviewItemOut(BaseModel):
id: UUID
title: str
url: str
snippet: str | None
priority: ReviewPriority
status: ReviewStatus
suggested_entities: list
suggested_categories: list
matched_case_id: UUID | None
created_at: datetime
model_config = {"from_attributes": True}
class ReviewDecisionIn(BaseModel):
status: ReviewStatus
matched_case_slug: str | None = None
class SearchOut(BaseModel):
cases: list[CaseListOut] = Field(default_factory=list)
entities: list[EntityOut] = Field(default_factory=list)
class GraphOut(BaseModel):
nodes: list[dict]
edges: list[dict]
class HealthOut(BaseModel):
status: str
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import uuid
from sqlalchemy.orm import Session
from app.models import ActorType, AuditAction, UpdateLog
def log(
db: Session,
*,
actor_type: ActorType,
actor_id: uuid.UUID | None,
table: str,
entity_id: uuid.UUID,
action: AuditAction,
before: dict | None = None,
after: dict | None = None,
) -> None:
db.add(
UpdateLog(
actor_type=actor_type,
actor_id=actor_id,
entity_table=table,
entity_id=entity_id,
action=action,
before=before,
after=after,
)
)
+284
View File
@@ -0,0 +1,284 @@
from __future__ import annotations
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy.orm import Session, selectinload
from app.core.ethics import (
EthicsError,
lint_neutral_text,
validate_case,
validate_claim,
validate_event,
validate_high_care_sources,
)
from app.models import (
ActorType,
AuditAction,
Case,
CaseCategory,
CaseEntity,
CaseEvent,
CaseEventSource,
CaseJurisdiction,
CaseRevision,
CaseSource,
Category,
Claim,
ClaimSource,
Entity,
Jurisdiction,
Source,
SourceEntry,
User,
)
from app.schemas import CaseCreate, CaseUpdate, SourceRefIn
from app.services.audit import log
from app.services.sources import upsert_source_entry
def _http_ethics(exc: EthicsError) -> HTTPException:
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc))
def _get_or_404(db: Session, model, slug: str):
obj = db.query(model).filter(model.slug == slug).one_or_none()
if obj is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{model.__name__} '{slug}' not found")
return obj
def resolve_source_entries(db: Session, refs: list[SourceRefIn]) -> dict[str, SourceEntry]:
by_url: dict[str, SourceEntry] = {}
for ref in refs:
source = _get_or_404(db, Source, ref.source_slug)
entry, _ = upsert_source_entry(
db,
source=source,
url=str(ref.url),
title=ref.title,
accessed_at=ref.accessed_at,
published_at=ref.published_at,
excerpt=ref.excerpt,
)
by_url[str(ref.url)] = entry
return by_url
def _entry_out_payload(entry: SourceEntry) -> dict:
source = entry.source
return {
"id": str(entry.id),
"accessed_at": entry.accessed_at.isoformat() if entry.accessed_at else None,
"source_type": source.type.value if source else None,
}
def create_case(db: Session, payload: CaseCreate, user: User) -> Case:
hits = lint_neutral_text(payload.summary)
if hits:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"Summary fails neutrality lint: {', '.join(hits)}",
)
if db.query(Case).filter(Case.slug == payload.slug).one_or_none():
raise HTTPException(status.HTTP_409_CONFLICT, "slug already exists")
entries = resolve_source_entries(db, payload.sources)
source_types = [e.source.type.value for e in entries.values()]
try:
validate_case(
status=payload.status.value,
source_types=source_types,
source_ids=[str(e.id) for e in entries.values()],
confirmation_basis=payload.confirmation_basis,
)
validate_high_care_sources(
care_level=payload.care_level.value,
sources=[_entry_out_payload(e) for e in entries.values()],
)
for claim in payload.claims:
claim_ids = []
for url in claim.source_urls:
if url not in entries:
raise EthicsError(f"Claim source URL not in case sources: {url}")
claim_ids.append(str(entries[url].id))
validate_claim(
statement=claim.statement,
source_ids=claim_ids,
status=claim.status.value,
)
for event in payload.events:
event_ids = []
for url in event.source_urls:
if url not in entries:
raise EthicsError(f"Event source URL not in case sources: {url}")
event_ids.append(str(entries[url].id))
validate_event(source_ids=event_ids)
except EthicsError as exc:
raise _http_ethics(exc) from exc
case = Case(
slug=payload.slug,
title=payload.title,
summary=payload.summary,
time_start=payload.time_start,
time_end=payload.time_end,
status=payload.status,
disputed=payload.disputed,
confirmation_basis=payload.confirmation_basis,
care_level=payload.care_level,
cluster_tags=payload.cluster_tags,
consequences=payload.consequences,
created_by=user.id,
)
db.add(case)
db.flush()
for slug in payload.jurisdiction_slugs:
jur = _get_or_404(db, Jurisdiction, slug)
db.add(CaseJurisdiction(case_id=case.id, jurisdiction_id=jur.id))
for slug in payload.category_slugs:
cat = _get_or_404(db, Category, slug)
db.add(CaseCategory(case_id=case.id, category_id=cat.id))
for link in payload.entities:
ent = _get_or_404(db, Entity, link.slug)
db.add(CaseEntity(case_id=case.id, entity_id=ent.id, role=link.role))
for ref in payload.sources:
entry = entries[str(ref.url)]
db.add(
CaseSource(
case_id=case.id,
source_entry_id=entry.id,
locator=ref.locator,
quote=ref.quote,
accessed_at=ref.accessed_at,
)
)
for i, claim in enumerate(payload.claims):
row = Claim(
case_id=case.id,
statement=claim.statement,
status=claim.status,
disputed=claim.disputed,
sort_order=i,
)
db.add(row)
db.flush()
for url in claim.source_urls:
db.add(ClaimSource(claim_id=row.id, source_entry_id=entries[url].id))
for event in payload.events:
ev = CaseEvent(
case_id=case.id,
occurred_on=event.occurred_on,
title=event.title,
body=event.body,
sort_order=event.sort_order,
)
db.add(ev)
db.flush()
for url in event.source_urls:
db.add(CaseEventSource(event_id=ev.id, source_entry_id=entries[url].id))
db.flush()
_snapshot(db, case, user.id)
log(
db,
actor_type=ActorType.user,
actor_id=user.id,
table="cases",
entity_id=case.id,
action=AuditAction.create,
after={"slug": case.slug, "status": case.status.value},
)
db.commit()
return get_case(db, case.slug)
def update_case(db: Session, slug: str, payload: CaseUpdate, user: User) -> Case:
case = _get_or_404(db, Case, slug)
before = {"status": case.status.value, "disputed": case.disputed}
data = payload.model_dump(exclude_unset=True)
if "summary" in data:
hits = lint_neutral_text(data["summary"])
if hits:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"Summary fails neutrality lint: {', '.join(hits)}",
)
for key, value in data.items():
setattr(case, key, value)
source_types = [
link_source_type(db, link.source_entry_id) for link in case.source_links
]
try:
validate_case(
status=case.status.value,
source_types=source_types,
source_ids=[str(link.source_entry_id) for link in case.source_links],
confirmation_basis=case.confirmation_basis,
)
except EthicsError as exc:
raise _http_ethics(exc) from exc
_snapshot(db, case, user.id)
log(
db,
actor_type=ActorType.user,
actor_id=user.id,
table="cases",
entity_id=case.id,
action=AuditAction.update,
before=before,
after={"status": case.status.value, "disputed": case.disputed},
)
db.commit()
return get_case(db, slug)
def link_source_type(db: Session, source_entry_id: UUID) -> str:
entry = db.query(SourceEntry).filter(SourceEntry.id == source_entry_id).one()
return entry.source.type.value
def get_case(db: Session, slug: str) -> Case:
case = (
db.query(Case)
.options(
selectinload(Case.claims).selectinload(Claim.source_links),
selectinload(Case.events).selectinload(CaseEvent.source_links),
selectinload(Case.source_links),
selectinload(Case.entity_links),
selectinload(Case.jurisdiction_links),
selectinload(Case.category_links),
)
.filter(Case.slug == slug)
.one_or_none()
)
if case is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found")
return case
def _snapshot(db: Session, case: Case, user_id) -> None:
rev = (
db.query(CaseRevision)
.filter(CaseRevision.case_id == case.id)
.order_by(CaseRevision.revision.desc())
.first()
)
n = (rev.revision + 1) if rev else 1
db.add(
CaseRevision(
case_id=case.id,
revision=n,
snapshot={
"slug": case.slug,
"title": case.title,
"summary": case.summary,
"status": case.status.value,
"disputed": case.disputed,
},
created_by=user_id,
)
)
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
from pathlib import Path
import yaml
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models import (
Category,
Entity,
EntityAlias,
EntityRole,
Jurisdiction,
User,
UserRole,
)
from app.core.config import get_settings
from app.models import Case, Promise, PromiseSource
from app.schemas import CaseCreate, ClaimIn, EntityLinkIn, EventIn, SourceRefIn
from app.services.cases import create_case
from app.services.sources import sync_catalog
def _load(path: Path):
if not path.exists():
return []
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if data is None:
return []
if isinstance(data, dict):
for key in ("jurisdictions", "categories", "entities", "promises", "cases"):
if key in data:
return data[key]
return data
def seed_jurisdictions(db: Session, items: list[dict]) -> int:
by_slug: dict[str, Jurisdiction] = {}
# parent-first: items listed with parents before children in YAML
for raw in items:
existing = db.query(Jurisdiction).filter(Jurisdiction.slug == raw["slug"]).one_or_none()
parent_id = None
if raw.get("parent"):
parent = by_slug.get(raw["parent"]) or db.query(Jurisdiction).filter(
Jurisdiction.slug == raw["parent"]
).one_or_none()
parent_id = parent.id if parent else None
if existing is None:
existing = Jurisdiction(
slug=raw["slug"],
name=raw["name"],
type=raw["type"],
parent_id=parent_id,
iso_code=raw.get("iso_code"),
sort_order=raw.get("sort_order") or 0,
)
db.add(existing)
db.flush()
by_slug[existing.slug] = existing
db.commit()
return len(items)
def seed_categories(db: Session, items: list[dict]) -> int:
for raw in items:
if db.query(Category).filter(Category.slug == raw["slug"]).one_or_none():
continue
parent_id = None
if raw.get("parent"):
parent = db.query(Category).filter(Category.slug == raw["parent"]).one_or_none()
parent_id = parent.id if parent else None
db.add(
Category(
slug=raw["slug"],
name=raw["name"],
parent_id=parent_id,
description=raw.get("description"),
)
)
db.commit()
return len(items)
def seed_entities(db: Session, items: list[dict]) -> int:
for raw in items:
if db.query(Entity).filter(Entity.slug == raw["slug"]).one_or_none():
continue
jur_id = None
if raw.get("jurisdiction"):
jur = db.query(Jurisdiction).filter(Jurisdiction.slug == raw["jurisdiction"]).one()
jur_id = jur.id
entity = Entity(
slug=raw["slug"],
name=raw["name"],
entity_type=raw["entity_type"],
jurisdiction_id=jur_id,
care_level=raw.get("care_level") or "standard",
cluster_tags=raw.get("cluster_tags") or [],
notes_neutral=raw.get("notes_neutral"),
wikidata_id=raw.get("wikidata_id"),
)
db.add(entity)
db.flush()
for alias in raw.get("aliases") or []:
db.add(EntityAlias(entity_id=entity.id, alias=alias))
for role in raw.get("roles") or []:
org_id = None
if role.get("organization"):
org = db.query(Entity).filter(Entity.slug == role["organization"]).one_or_none()
org_id = org.id if org else None
db.add(
EntityRole(
entity_id=entity.id,
title=role["title"],
organization_id=org_id,
started_on=role.get("started_on"),
ended_on=role.get("ended_on"),
)
)
db.commit()
return len(items)
def _case_from_raw(db: Session, raw: dict, user: User) -> bool:
if db.query(Case).filter(Case.slug == raw["slug"]).one_or_none():
return False
payload = CaseCreate(
slug=raw["slug"],
title=raw["title"],
summary=raw["summary"],
time_start=raw["time_start"],
time_end=raw.get("time_end"),
status=raw.get("status") or "allegation",
disputed=bool(raw.get("disputed")),
confirmation_basis=raw.get("confirmation_basis"),
care_level=raw.get("care_level") or "standard",
cluster_tags=raw.get("cluster_tags") or [],
consequences=raw.get("consequences"),
jurisdiction_slugs=raw["jurisdiction_slugs"],
category_slugs=raw["category_slugs"],
entities=[EntityLinkIn(**e) for e in raw["entities"]],
sources=[SourceRefIn(**s) for s in raw["sources"]],
claims=[ClaimIn(**c) for c in raw.get("claims") or []],
events=[EventIn(**e) for e in raw.get("events") or []],
)
create_case(db, payload, user)
return True
def seed_cases(db: Session, files: list[Path], user: User) -> int:
count = 0
for path in files:
if path.name.startswith("_"):
continue
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if _case_from_raw(db, raw, user):
count += 1
return count
def seed_promises(db: Session, items: list[dict]) -> int:
from app.services.cases import resolve_source_entries
count = 0
for raw in items:
if db.query(Promise).filter(Promise.slug == raw["slug"]).one_or_none():
continue
entity = db.query(Entity).filter(Entity.slug == raw["entity_slug"]).one()
case_id = None
if raw.get("related_case_slug"):
case = db.query(Case).filter(Case.slug == raw["related_case_slug"]).one_or_none()
case_id = case.id if case else None
jur_id = None
if raw.get("jurisdiction_slug"):
jur = db.query(Jurisdiction).filter(Jurisdiction.slug == raw["jurisdiction_slug"]).one()
jur_id = jur.id
refs = [SourceRefIn(**s) for s in raw["sources"]]
entries = resolve_source_entries(db, refs)
promise = Promise(
slug=raw["slug"],
entity_id=entity.id,
text=raw["text"],
promised_on=raw.get("promised_on"),
context=raw.get("context"),
status=raw.get("status") or "unclear",
related_case_id=case_id,
jurisdiction_id=jur_id,
)
db.add(promise)
db.flush()
for entry in entries.values():
db.add(PromiseSource(promise_id=promise.id, source_entry_id=entry.id))
count += 1
db.commit()
return count
def _ensure_admin(db: Session) -> User:
settings = get_settings()
user = db.query(User).filter(User.username == settings.admin_username).one_or_none()
if user is None:
user = User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
role=UserRole.admin,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def seed_all(db: Session, seed_dir: Path) -> dict:
settings = get_settings()
yaml_path = Path(settings.sources_yaml)
synced = sync_catalog(db, yaml_path) if yaml_path.exists() else 0
user = _ensure_admin(db)
n_j = seed_jurisdictions(db, _load(seed_dir / "jurisdictions.yaml"))
n_c = seed_categories(db, _load(seed_dir / "categories.yaml"))
n_e = seed_entities(db, _load(seed_dir / "entities.yaml"))
case_files = sorted((seed_dir / "cases").glob("*.yaml")) if (seed_dir / "cases").exists() else []
n_cases = seed_cases(db, case_files, user)
bundled = _load(seed_dir / "cases.yaml")
if bundled:
n_cases += sum(1 for raw in bundled if _case_from_raw(db, raw, user))
n_p = seed_promises(db, _load(seed_dir / "promises.yaml"))
return {
"sources": synced,
"jurisdictions": n_j,
"categories": n_c,
"entities": n_e,
"cases": n_cases,
"promises": n_p,
}
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models import Case, Category, Entity, Jurisdiction, Promise, SourceEntry
from app.schemas import (
CaseListOut,
CaseOut,
ClaimOut,
EntityOut,
EventOut,
PromiseOut,
SourceEntryOut,
)
def source_entry_out(db: Session, entry_id) -> SourceEntryOut:
entry = db.query(SourceEntry).filter(SourceEntry.id == entry_id).one()
return SourceEntryOut(
id=entry.id,
source_id=entry.source_id,
url=entry.url,
title=entry.title,
published_at=entry.published_at,
accessed_at=entry.accessed_at,
excerpt=entry.excerpt,
source_name=entry.source.name if entry.source else None,
source_type=entry.source.type if entry.source else None,
reliability=entry.source.reliability if entry.source else None,
)
def entity_out(entity: Entity) -> EntityOut:
return EntityOut(
id=entity.id,
slug=entity.slug,
name=entity.name,
entity_type=entity.entity_type,
care_level=entity.care_level,
cluster_tags=entity.cluster_tags or [],
notes_neutral=entity.notes_neutral,
wikidata_id=entity.wikidata_id,
aliases=[a.alias for a in entity.aliases],
)
def case_list_out(case: Case) -> CaseListOut:
return CaseListOut(
id=case.id,
slug=case.slug,
title=case.title,
status=case.status,
disputed=case.disputed,
care_level=case.care_level,
cluster_tags=case.cluster_tags or [],
time_start=case.time_start,
time_end=case.time_end,
)
def case_out(db: Session, case: Case) -> CaseOut:
jur_ids = [l.jurisdiction_id for l in case.jurisdiction_links]
cat_ids = [l.category_id for l in case.category_links]
jurs = {j.id: j.slug for j in db.query(Jurisdiction).filter(Jurisdiction.id.in_(jur_ids)).all()} if jur_ids else {}
cats = {c.id: c.slug for c in db.query(Category).filter(Category.id.in_(cat_ids)).all()} if cat_ids else {}
ents = {e.id: e for e in db.query(Entity).filter(Entity.id.in_([l.entity_id for l in case.entity_links])).all()} if case.entity_links else {}
return CaseOut(
**case_list_out(case).model_dump(),
summary=case.summary,
confirmation_basis=case.confirmation_basis,
consequences=case.consequences,
jurisdictions=[jurs[i] for i in jur_ids],
categories=[cats[i] for i in cat_ids],
entities=[
{"slug": ents[link.entity_id].slug, "name": ents[link.entity_id].name, "role": link.role.value}
for link in case.entity_links
if link.entity_id in ents
],
sources=[source_entry_out(db, link.source_entry_id) for link in case.source_links],
claims=[
ClaimOut(
id=c.id,
statement=c.statement,
status=c.status,
disputed=c.disputed,
sort_order=c.sort_order,
sources=[source_entry_out(db, s.source_entry_id) for s in c.source_links],
)
for c in sorted(case.claims, key=lambda x: x.sort_order)
],
events=[
EventOut(
id=e.id,
occurred_on=e.occurred_on,
title=e.title,
body=e.body,
sort_order=e.sort_order,
sources=[source_entry_out(db, s.source_entry_id) for s in e.source_links],
)
for e in sorted(case.events, key=lambda x: x.sort_order)
],
)
def promise_out(db: Session, promise: Promise) -> PromiseOut:
entity = db.query(Entity).filter(Entity.id == promise.entity_id).one()
case_slug = None
if promise.related_case_id:
related = db.query(Case).filter(Case.id == promise.related_case_id).one_or_none()
case_slug = related.slug if related else None
return PromiseOut(
id=promise.id,
slug=promise.slug,
entity_slug=entity.slug,
text=promise.text,
promised_on=promise.promised_on,
context=promise.context,
status=promise.status,
related_case_slug=case_slug,
sources=[source_entry_out(db, s.source_entry_id) for s in promise.source_links],
)
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import yaml
from sqlalchemy.orm import Session
from app.models import (
IngestMethod,
Source,
SourceEntry,
SourceStatus,
SourceType,
UpdateFrequency,
)
def load_yaml_catalog(path: str | Path) -> list[dict]:
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
return list(data.get("sources") or [])
def sync_catalog(db: Session, path: str | Path) -> int:
rows = load_yaml_catalog(path)
count = 0
for raw in rows:
src = db.query(Source).filter(Source.slug == raw["slug"]).one_or_none()
fields = dict(
name=raw["name"],
type=SourceType(raw["type"]),
base_url=raw["base_url"],
language_codes=raw.get("language_codes") or [],
reliability=int(raw["reliability"]),
update_frequency=UpdateFrequency(raw["update_frequency"]),
ingest_method=IngestMethod(raw["ingest_method"]),
ingest_config=raw.get("ingest_config") or {},
topic_focus=raw.get("topic_focus") or [],
status=SourceStatus(raw.get("status") or "active"),
notes=raw.get("notes"),
)
if src is None:
src = Source(slug=raw["slug"], **fields)
db.add(src)
else:
for key, value in fields.items():
setattr(src, key, value)
count += 1
db.commit()
return count
def upsert_source_entry(
db: Session,
*,
source: Source,
url: str,
title: str,
accessed_at: datetime | None = None,
published_at: datetime | None = None,
excerpt: str | None = None,
content_hash: str | None = None,
raw: dict | None = None,
language: str | None = None,
) -> tuple[SourceEntry, bool]:
existing = db.query(SourceEntry).filter(SourceEntry.url == url).one_or_none()
if existing:
if content_hash and existing.content_hash == content_hash:
return existing, False
existing.title = title
existing.excerpt = excerpt or existing.excerpt
existing.content_hash = content_hash or existing.content_hash
existing.raw = raw or existing.raw
return existing, False
entry = SourceEntry(
source_id=source.id,
url=url,
title=title,
published_at=published_at,
accessed_at=accessed_at or datetime.now(timezone.utc),
excerpt=excerpt,
content_hash=content_hash,
raw=raw,
language=language,
)
db.add(entry)
db.flush()
return entry, True
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "quellenatlas"
version = "0.1.0"
description = "Private, source-based knowledge base for documented political irregularities"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy>=2.0.36",
"alembic>=1.14",
"psycopg[binary]>=3.2",
"pydantic-settings>=2.6",
"email-validator>=2.0",
"pyyaml>=6.0",
"httpx>=0.27",
"feedparser>=6.0",
"PyJWT>=2.10",
"pwdlib[argon2]>=0.2",
"apscheduler>=3.10",
"python-multipart>=0.0.12",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
View File
Whitespace-only changes.
+80
View File
@@ -0,0 +1,80 @@
from collections.abc import Generator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
from app.core.security import hash_password
from app.db import get_db
from app.main import app
from app.models import Base, User, UserRole
TEST_URL = "postgresql+psycopg://quellenatlas:quellenatlas@localhost:5433/quellenatlas_test"
@pytest.fixture(scope="session")
def engine():
get_settings.cache_clear()
admin = create_engine(
"postgresql+psycopg://quellenatlas:quellenatlas@localhost:5433/quellenatlas",
isolation_level="AUTOCOMMIT",
)
with admin.connect() as conn:
exists = conn.execute(
text("SELECT 1 FROM pg_database WHERE datname='quellenatlas_test'")
).scalar()
if not exists:
conn.execute(text("CREATE DATABASE quellenatlas_test"))
admin.dispose()
eng = create_engine(TEST_URL, pool_pre_ping=True)
with eng.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
conn.commit()
yield eng
eng.dispose()
@pytest.fixture
def db(engine) -> Generator[Session, None, None]:
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
SessionTesting = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = SessionTesting()
session.add(
User(
username="admin",
password_hash=hash_password("changeme"),
role=UserRole.admin,
)
)
session.commit()
try:
yield session
finally:
session.close()
@pytest.fixture
def client(db, engine) -> Generator[TestClient, None, None]:
SessionTesting = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def _override() -> Generator[Session, None, None]:
session = SessionTesting()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def auth_client(client: TestClient) -> TestClient:
response = client.post("/api/v1/auth/login", json={"username": "admin", "password": "changeme"})
assert response.status_code == 200, response.text
return client
+155
View File
@@ -0,0 +1,155 @@
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Category, Entity, EntityType, Jurisdiction, JurisdictionType, Source, SourceType
from app.models.enums import CareLevel, IngestMethod, SourceStatus, UpdateFrequency
from app.services.sources import sync_catalog
def _taxonomy(db: Session) -> None:
db.add(Jurisdiction(slug="eu", name="EU", type=JurisdictionType.institution, sort_order=1))
db.add(Category(slug="interessenkonflikte", name="Interessenkonflikte"))
db.add(
Entity(
slug="ursula-von-der-leyen",
name="Ursula von der Leyen",
entity_type=EntityType.person,
care_level=CareLevel.high,
cluster_tags=["vdl", "eu-top"],
)
)
db.add(
Source(
slug="eu-ombudsman",
name="EU Ombudsman",
type=SourceType.official_register,
base_url="https://www.ombudsman.europa.eu",
language_codes=["en"],
reliability=5,
update_frequency=UpdateFrequency.weekly,
ingest_method=IngestMethod.manual,
ingest_config={},
topic_focus=["eu", "vdl"],
status=SourceStatus.active,
)
)
db.commit()
def _case_payload(**overrides) -> dict:
payload = {
"slug": "ombudsman-sms-transparenz",
"title": "EU-Ombudsmann: Missstand bei Transparenz von Textnachrichten",
"summary": "Der Europäische Bürgerbeauftragte stellte einen Missstand bei der Transparenz von Textnachrichten im Zusammenhang mit COVID-Impfstoffverträgen fest.",
"time_start": "2021-01-01",
"status": "confirmed",
"disputed": False,
"confirmation_basis": None,
"care_level": "high",
"cluster_tags": ["vdl", "eu-top"],
"jurisdiction_slugs": ["eu"],
"category_slugs": ["interessenkonflikte"],
"entities": [{"slug": "ursula-von-der-leyen", "role": "subject"}],
"sources": [
{
"source_slug": "eu-ombudsman",
"url": "https://www.ombudsman.europa.eu/en/decision/en/158295",
"title": "Decision on the European Commission's refusal to give public access to text messages",
"accessed_at": datetime(2026, 8, 21, tzinfo=timezone.utc).isoformat(),
}
],
"claims": [
{
"statement": "Der Europäische Bürgerbeauftragte stellte einen Missstand (maladministration) fest, weil die Kommission Textnachrichten nicht als Dokumente registrierte.",
"status": "confirmed",
"disputed": False,
"source_urls": [
"https://www.ombudsman.europa.eu/en/decision/en/158295"
],
}
],
"events": [],
}
payload.update(overrides)
return payload
def test_health(client: TestClient):
assert client.get("/health").json()["status"] == "ok"
def test_unauthenticated_cases_are_rejected(client: TestClient):
assert client.get("/api/v1/cases").status_code == 401
def test_login_and_me(auth_client: TestClient):
me = auth_client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["username"] == "admin"
def test_claim_without_source_is_rejected(auth_client: TestClient, db: Session):
_taxonomy(db)
payload = _case_payload()
payload["claims"][0]["source_urls"] = []
res = auth_client.post("/api/v1/cases", json=payload)
assert res.status_code == 422
assert "source" in res.json()["detail"].lower()
def test_confirmed_without_official_source_or_basis_is_rejected(
auth_client: TestClient, db: Session
):
_taxonomy(db)
news = Source(
slug="spiegel",
name="Spiegel",
type=SourceType.news,
base_url="https://www.spiegel.de",
language_codes=["de"],
reliability=3,
update_frequency=UpdateFrequency.daily,
ingest_method=IngestMethod.rss,
ingest_config={},
topic_focus=["de"],
status=SourceStatus.active,
)
db.add(news)
db.commit()
payload = _case_payload()
payload["sources"][0]["source_slug"] = "spiegel"
payload["sources"][0]["url"] = "https://www.spiegel.de/example"
payload["claims"][0]["source_urls"] = ["https://www.spiegel.de/example"]
payload["confirmation_basis"] = None
res = auth_client.post("/api/v1/cases", json=payload)
assert res.status_code == 422
def test_create_case_and_cluster_and_export(auth_client: TestClient, db: Session):
_taxonomy(db)
res = auth_client.post("/api/v1/cases", json=_case_payload())
assert res.status_code == 201, res.text
body = res.json()
assert body["slug"] == "ombudsman-sms-transparenz"
assert body["claims"][0]["sources"]
cluster = auth_client.get("/api/v1/clusters/vdl")
assert cluster.status_code == 200
slugs = [c["slug"] for c in cluster.json()["cases"]]
assert "ombudsman-sms-transparenz" in slugs
md = auth_client.get("/api/v1/export/cases/ombudsman-sms-transparenz?format=md")
assert md.status_code == 200
assert "ombudsman.europa.eu" in md.text
assert "Missstand" in md.text
def test_sync_catalog_roundtrip(db: Session):
from pathlib import Path
path = Path(__file__).resolve().parents[2] / "data" / "sources.yaml"
n = sync_catalog(db, path)
assert n >= 25
from app.models import Source as Src
assert db.query(Src).filter(Src.slug == "opensanctions").one().reliability == 5
+121
View File
@@ -0,0 +1,121 @@
"""Ethics rules must fail closed: unsourced or over-confirmed statements are rejected."""
import pytest
from app.core.ethics import (
EthicsError,
lint_neutral_text,
validate_case,
validate_claim,
validate_event,
validate_high_care_sources,
validate_ingest_target,
)
def test_claim_without_source_is_rejected():
with pytest.raises(EthicsError, match="at least one source"):
validate_claim(statement="X hat Y getan.", source_ids=[], status="allegation")
def test_empty_claim_statement_is_rejected():
with pytest.raises(EthicsError, match="empty"):
validate_claim(statement=" ", source_ids=["s1"], status="allegation")
def test_claim_with_source_is_accepted():
validate_claim(
statement="Der Ombudsmann stellte einen Missstand fest.",
source_ids=["s1"],
status="allegation",
)
def test_case_without_source_is_rejected():
with pytest.raises(EthicsError, match="at least one source"):
validate_case(
status="allegation",
source_types=["news"],
source_ids=[],
confirmation_basis=None,
)
def test_confirmed_without_official_source_or_basis_is_rejected():
with pytest.raises(EthicsError, match="confirmed"):
validate_case(
status="confirmed",
source_types=["news"],
source_ids=["s1"],
confirmation_basis=None,
)
def test_confirmed_with_court_source_is_accepted():
validate_case(
status="confirmed",
source_types=["court"],
source_ids=["s1"],
confirmation_basis=None,
)
def test_confirmed_with_confirmation_basis_is_accepted():
validate_case(
status="confirmed",
source_types=["news"],
source_ids=["s1"],
confirmation_basis="Universität hat den Grad entzogen.",
)
def test_allegation_is_default_ok_with_news_source():
validate_case(
status="allegation",
source_types=["news"],
source_ids=["s1"],
confirmation_basis=None,
)
def test_event_requires_source():
with pytest.raises(EthicsError, match="at least one source"):
validate_event(source_ids=[])
def test_high_care_requires_accessed_at():
with pytest.raises(EthicsError, match="accessed_at"):
validate_high_care_sources(
care_level="high",
sources=[{"id": "s1", "accessed_at": None}],
)
def test_high_care_with_accessed_at_is_accepted():
validate_high_care_sources(
care_level="high",
sources=[{"id": "s1", "accessed_at": "2026-08-21"}],
)
def test_ingest_may_not_write_cases():
with pytest.raises(EthicsError, match="must not write cases"):
validate_ingest_target("cases")
def test_ingest_may_write_source_entries_and_review_items():
validate_ingest_target("source_entries")
validate_ingest_target("review_items")
validate_ingest_target("update_logs")
def test_lint_flags_loaded_blame_adjectives():
hits = lint_neutral_text("Der korrupt-kriminelle Politiker hat offensichtlich gelogen.")
assert hits
def test_lint_allows_neutral_institutional_wording():
hits = lint_neutral_text(
"Der Europäische Rechnungshof kritisierte Lücken in der Berichterstattung."
)
assert hits == []
+27
View File
@@ -0,0 +1,27 @@
from app.ingest.dedup import canonicalize_url, content_hash
from app.ingest.keywords import is_high_priority, match_clusters
def test_canonicalize_strips_tracking_and_trailing_slash():
a = canonicalize_url("https://Example.com/path/?utm_source=x")
b = canonicalize_url("https://example.com/path")
assert a == b
def test_content_hash_stable_for_canonical_url():
h1 = content_hash("Titel", "https://example.com/a/?utm_campaign=1", "ex")
h2 = content_hash("Titel", "https://example.com/a", "ex")
assert h1 == h2
def test_vdl_keyword_is_high_priority():
assert is_high_priority("Ombudsman rügt von der Leyen wegen SMS")
assert "vdl" in match_clusters("Ursula von der Leyen und Pfizer")
def test_ukraine_keyword_is_high_priority():
assert "ukraine" in match_clusters("Bericht zur Ukraine Facility")
def test_unrelated_text_is_not_high_priority():
assert not is_high_priority("Kommunalwahl in Passau ohne besondere Vorkommnisse")
+58
View File
@@ -0,0 +1,58 @@
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.ingest import runner
from app.ingest.runner import run_source
from app.models import (
IngestMethod,
ReviewItem,
ReviewPriority,
Source,
SourceEntry,
SourceStatus,
SourceType,
UpdateFrequency,
)
def test_ingest_creates_entry_and_high_priority_review(db: Session, monkeypatch):
source = Source(
slug="politico-europe",
name="Politico",
type=SourceType.news,
base_url="https://www.politico.eu",
language_codes=["en"],
reliability=3,
update_frequency=UpdateFrequency.daily,
ingest_method=IngestMethod.rss,
ingest_config={"feed_url": "https://example.test/feed"},
topic_focus=["eu", "vdl"],
status=SourceStatus.active,
)
db.add(source)
db.commit()
def fake_fetch(_source: Source):
return [
{
"url": "https://www.politico.eu/article/von-der-leyen-ukraine-facility/",
"title": "von der Leyen on the Ukraine Facility",
"excerpt": "Commission president discusses Ukraine aid.",
"published_at": datetime.now(timezone.utc),
"raw": {"id": "1"},
}
]
monkeypatch.setattr(runner, "fetch_items", fake_fetch)
result = run_source(db, source)
assert result["created_entries"] == 1
assert result["created_reviews"] == 1
entry = db.query(SourceEntry).one()
assert "von-der-leyen" in entry.url
item = db.query(ReviewItem).one()
assert item.priority == ReviewPriority.high
# second run must not duplicate pending review
result2 = run_source(db, source)
assert result2["created_entries"] == 0
assert db.query(ReviewItem).count() == 1
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[2]
def test_seed_has_vdl_ukraine_and_enough_cases():
data = yaml.safe_load((ROOT / "data/seed/cases.yaml").read_text(encoding="utf-8"))
cases = data["cases"]
assert len(cases) >= 20
slugs = {c["slug"] for c in cases}
assert "ombudsman-sms-transparenz" in slugs
assert "bmvg-beratervertraege" in slugs
assert "eca-ukraine-facility-opinion" in slugs
vdl = [c for c in cases if "vdl" in (c.get("cluster_tags") or [])]
ua = [c for c in cases if "ukraine" in (c.get("cluster_tags") or [])]
assert len(vdl) >= 3
assert len(ua) >= 3
for case in cases:
assert case["sources"]
assert case["claims"]
for claim in case["claims"]:
assert claim["source_urls"]
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from app.services.sources import load_yaml_catalog
ROOT = Path(__file__).resolve().parents[2]
YAML_PATH = ROOT / "data" / "sources.yaml"
def test_catalog_has_minimum_coverage():
rows = load_yaml_catalog(YAML_PATH)
slugs = {r["slug"] for r in rows}
assert len(rows) >= 25
for required in (
"correctiv",
"eu-ombudsman",
"eu-court-of-auditors",
"opensanctions",
"ti-ukraine",
"politico-europe",
"bundestag-dip",
):
assert required in slugs
focuses = {tag for r in rows for tag in r.get("topic_focus") or []}
assert "vdl" in focuses or any("vdl" in (r.get("topic_focus") or []) for r in rows)
assert "ukraine" in focuses
assert "eu" in focuses
for row in rows:
assert 1 <= int(row["reliability"]) <= 5
assert row["ingest_method"] in {"rss", "api", "manual"}
assert row["type"]
assert row["base_url"].startswith("http")
+764
View File
@@ -0,0 +1,764 @@
cases:
- slug: ombudsman-sms-transparenz
title: "EU-Ombudsmann: Missstand bei Transparenz von Textnachrichten (Pfizer)"
summary: >
Der Europäische Bürgerbeauftragte untersuchte die Weigerung der Europäischen
Kommission, Textnachrichten zwischen der Kommissionspräsidentin und dem CEO
eines Pharmaunternehmens zu COVID-19-Impfstoffkäufen als Dokumente
herauszugeben bzw. zu registrieren. In der Entscheidung zu Fall 1316/2021/MIG
wurde ein Missstand in der Verwaltung (maladministration) festgestellt.
Der Befund betrifft die Dokumentenverwaltung und Transparenz, nicht eine
strafrechtliche Bewertung der Verträge.
time_start: 2021-09-16
time_end: 2022-07-12
status: confirmed
disputed: false
care_level: high
cluster_tags: [vdl, eu-top]
consequences: "Ombudsmann-Entscheidung mit Missstandsfeststellung; Kommission widersprach der Einordnung der Nachrichten als Dokumente."
jurisdiction_slugs: [eu, eu-kommission]
category_slugs: [intransparente-finanzierung, oeffentliche-auftraege-subventionen, interessenkonflikte]
entities:
- {slug: ursula-von-der-leyen, role: subject}
- {slug: europaeische-kommission, role: subject}
- {slug: pfizer, role: related}
- {slug: eu-ombudsmann, role: investigator}
sources:
- source_slug: eu-ombudsman
url: https://www.ombudsman.europa.eu/en/decision/en/158295
title: "Decision on the European Commission's refusal of public access to text messages (case 1316/2021/MIG)"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: eu-ombudsman
url: https://www.ombudsman.europa.eu/en/case/en/59777
title: "Case 1316/2021/MIG — maladministration found"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Der Europäische Bürgerbeauftragte stellte in Fall 1316/2021/MIG einen Missstand (maladministration) fest."
status: confirmed
source_urls: [https://www.ombudsman.europa.eu/en/decision/en/158295]
- statement: "Gegenstand waren Textnachrichten im Zusammenhang mit dem Ankauf von COVID-19-Impfstoffen zwischen Kommissionspräsidentin und CEO eines Pharmaunternehmens."
status: confirmed
source_urls: [https://www.ombudsman.europa.eu/en/case/en/59777]
- slug: bmvg-beratervertraege
title: "Beraterverträge im Bundesministerium der Verteidigung (2018–2020)"
summary: >
Der Bundesrechnungshof und ein Untersuchungsausschuss des Bundestags befassten
sich mit der Vergabe von Beratungsleistungen im Geschäftsbereich des
Bundesministeriums der Verteidigung. Öffentlich ausgewertete Prüfungen
bemängelten unter anderem freihändige Vergaben und unzureichende
Wirtschaftlichkeitsprüfung. Strafrechtliche Individualschuld der damaligen
Ministerin ist damit nicht festgestellt.
time_start: 2018-01-01
time_end: 2020-06-30
status: confirmed
disputed: false
confirmation_basis: "Parlamentarische Untersuchung und Rechnungshofkritik an Vergabepraxis; bestätigt ist der Befund von Verfahrensmängeln, nicht eine Straftat."
care_level: high
cluster_tags: [vdl, dach]
consequences: "Untersuchungsausschuss des Bundestags; organisatorische Änderungen im BMVg; öffentliche politische Debatte."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [oeffentliche-auftraege-subventionen, intransparente-finanzierung]
entities:
- {slug: ursula-von-der-leyen, role: decision_maker}
- {slug: bundestag, role: investigator}
sources:
- source_slug: bundestag-ua
url: https://www.bundestag.de/ausschuesse/untersuchungsausschuss
title: "Deutscher Bundestag — Untersuchungsausschüsse"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: zeit
url: https://www.zeit.de/politik/deutschland/2018-12/berater-affaere-ursula-von-der-leyen-rechnungshof-verteidigungsministerium-rechtsbrueche
title: "Zeit: Rechnungshof-Vorwürfe zu Beratervergaben im BMVg (2018)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Der Bundestag setzte einen Untersuchungsausschuss zu Vorgängen im Zusammenhang mit Beraterverträgen im Verteidigungsbereich ein."
status: confirmed
source_urls: [https://www.bundestag.de/ausschuesse/untersuchungsausschuss]
- statement: "Zeitgenössische Berichterstattung gab Rechnungshofkritik an Vergaben ohne ausreichende Ausschreibung wieder."
status: confirmed
source_urls: [https://www.zeit.de/politik/deutschland/2018-12/berater-affaere-ursula-von-der-leyen-rechnungshof-verteidigungsministerium-rechtsbrueche]
- slug: vdl-dissertation-2015
title: "Auseinandersetzung um die Dissertation von Ursula von der Leyen (2015)"
summary: >
2015 dokumentierte VroniPlag Textstellen in der medizinischen Dissertation.
Die Leibniz Universität Hannover prüfte die Arbeit. Die öffentliche Quellenlage
ist widersprüchlich zwischen Crowdsourcing-Dokumentation und hochschulischer
Bewertung; der Vorgang wird daher als Vorwurf mit disputed-Flag geführt,
nicht als bestätigte Täuschung.
time_start: 2015-09-01
time_end: 2016-03-31
status: allegation
disputed: true
care_level: high
cluster_tags: [vdl, dach]
consequences: "Hochschulische Prüfung; keine einheitliche öffentliche Feststellung einer Täuschung in den hier verwendeten Quellen."
jurisdiction_slugs: [deutschland]
category_slugs: [plagiate-akademisch]
entities:
- {slug: ursula-von-der-leyen, role: subject}
- {slug: uni-hannover, role: investigator}
sources:
- source_slug: vroniplag
url: https://de.vroniplag.wikia.org
title: "VroniPlag Wiki (Dokumentation von Textstellen)"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: uni-hannover
url: https://www.uni-hannover.de
title: "Leibniz Universität Hannover (institutionelle Quelle)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "VroniPlag dokumentierte 2015 Textübernahmen in der Dissertation."
status: allegation
disputed: true
source_urls: [https://de.vroniplag.wikia.org]
- statement: "Die Leibniz Universität Hannover ist die zuständige Hochschule für die Prüfung der Arbeit."
status: allegation
disputed: true
source_urls: [https://www.uni-hannover.de]
- slug: barroso-goldman-sachs
title: "Wechsel von José Manuel Barroso zu Goldman Sachs International"
summary: >
Der ehemalige Kommissionspräsident José Manuel Barroso übernahm 2016 eine
Funktion bei Goldman Sachs International. Der Vorgang löste eine Debatte über
Cooling-off-Fristen und den EU-Ethikkodex für Kommissionsmitglieder aus und
wurde von der Kommission bzw. ihrem Ethikgremium behandelt.
time_start: 2016-07-01
time_end: 2016-12-31
status: confirmed
disputed: false
confirmation_basis: "Öffentlich dokumentierter Funktionswechsel nach dem Amt; Ethikdebatte auf EU-Ebene, ohne dass hier eine Straftat behauptet wird."
care_level: high
cluster_tags: [eu-top]
consequences: "Politische und institutionelle Debatte über Revolving-Door-Regeln."
jurisdiction_slugs: [eu]
category_slugs: [interessenkonflikte, lobbyismus-intransparenz]
entities:
- {slug: jose-manuel-barroso, role: subject}
- {slug: goldman-sachs, role: related}
- {slug: europaeische-kommission, role: related}
sources:
- source_slug: politico-europe
url: https://www.politico.eu/article/jose-manuel-barroso-goldman-sachs-job-european-commission-ethics/
title: "Politico Europe coverage of Barroso–Goldman Sachs appointment"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: ceo
url: https://corporateeurope.org
title: "Corporate Europe Observatory (Revolving Door / EU ethics)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Barroso übernahm nach seiner Amtszeit als Kommissionspräsident eine Funktion bei Goldman Sachs International."
status: confirmed
source_urls: [https://www.politico.eu/article/jose-manuel-barroso-goldman-sachs-job-european-commission-ethics/]
- slug: eca-ukraine-facility-opinion
title: "Europäischer Rechnungshof: Stellungnahme zur Ukraine Facility"
summary: >
Der Europäische Rechnungshof gab 2023 eine Stellungnahme (Opinion 03/2023)
zum Kommissionsvorschlag für die Ukraine Facility ab. Darin befasst er sich
mit Haushaltsarchitektur, Rechenschaft und Risiken der Mittelverwaltung.
Der Eintrag dokumentiert amtliche Prüferhinweise, keine individuelle
Straftat.
time_start: 2023-07-17
time_end: 2023-10-05
status: confirmed
disputed: false
care_level: high
cluster_tags: [ukraine, eu-top]
consequences: "Legislativer Prozess der Facility unter Einbeziehung der ECA-Stellungnahme."
jurisdiction_slugs: [eu, ukraine]
category_slugs: [ukraine-vorgaenge, intransparente-finanzierung]
entities:
- {slug: eu-rechnungshof, role: investigator}
- {slug: europaeische-kommission, role: decision_maker}
- {slug: ukraine-regierung, role: related}
sources:
- source_slug: eu-court-of-auditors
url: https://www.eca.europa.eu/ECAPublications/OP-2023-03/OP-2023-03_EN.pdf
title: "ECA Opinion 03/2023 on the Ukraine Facility"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Der Europäische Rechnungshof veröffentlichte Opinion 03/2023 zum Vorschlag der Ukraine Facility."
status: confirmed
source_urls: [https://www.eca.europa.eu/ECAPublications/OP-2023-03/OP-2023-03_EN.pdf]
- slug: eu-mfa-ukraine
title: "EU-Makrofinanzhilfe für die Ukraine"
summary: >
Die Europäische Union beschloss mehrfach Makrofinanzhilfe (MFA) und
verwandte Instrumente für die Ukraine. Amtlich sind Volumina, Rechtsgrundlagen
und Berichtspflichten dokumentiert. Der Eintrag erfasst die institutionelle
Mittelvergabe, nicht einzelne Korruptionsurteile.
time_start: 2022-02-24
status: confirmed
disputed: false
care_level: high
cluster_tags: [ukraine, eu-top]
consequences: "Wiederholte MFA-Beschlüsse und Berichtspflichten der Kommission."
jurisdiction_slugs: [eu, ukraine]
category_slugs: [ukraine-vorgaenge, intransparente-finanzierung]
entities:
- {slug: europaeische-kommission, role: decision_maker}
- {slug: ukraine-regierung, role: beneficiary}
sources:
- source_slug: eu-ukraine-facility
url: https://commission.europa.eu/strategy-and-policy/priorities-2019-2024/stronger-europe-world/eu-solidarity-ukraine_en
title: "European Commission — EU solidarity with Ukraine"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: eur-lex
url: https://eur-lex.europa.eu
title: "EUR-Lex (Rechtsakte zu MFA/Ukraine)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Die Europäische Kommission veröffentlicht amtliche Informationen zu Unterstützungsinstrumenten für die Ukraine, einschließlich Finanzhilfen."
status: confirmed
source_urls: [https://commission.europa.eu/strategy-and-policy/priorities-2019-2024/stronger-europe-world/eu-solidarity-ukraine_en]
- slug: nabu-mandate
title: "NABU: institutionelle Antikorruptionsarbeit in der Ukraine"
summary: >
Das Nationale Antikorruptionsbüro der Ukraine (NABU) veröffentlicht
Mitteilungen zu Ermittlungen, auch im Bereich öffentlicher Beschaffung.
Der Eintrag verweist auf die Behörde als Primärquelle; einzelne
Beschuldigte werden hier nicht pauschal als schuldig geführt.
time_start: 2015-01-01
status: ongoing
disputed: false
care_level: high
cluster_tags: [ukraine]
consequences: "Laufende behördliche Verfahren nach ukrainischem Recht; Darstellung nur über NABU-Kommunikationen."
jurisdiction_slugs: [ukraine]
category_slugs: [ukraine-vorgaenge, korruption-bestechung, oeffentliche-auftraege-subventionen]
entities:
- {slug: nabu, role: investigator}
- {slug: ukraine-regierung, role: related}
sources:
- source_slug: nabu
url: https://nabu.gov.ua
title: "National Anti-Corruption Bureau of Ukraine"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: ti-ukraine
url: https://ti-ukraine.org
title: "Transparency International Ukraine"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "NABU ist die gesetzlich eingerichtete zentrale Antikorruptionsermittlungsbehörde der Ukraine und veröffentlicht Fallmitteilungen."
status: confirmed
source_urls: [https://nabu.gov.ua]
- slug: zeitenwende-militaerhilfe
title: "Zeitenwende-Erklärung und nachfolgende Militärhilfe-Zusagen"
summary: >
Bundeskanzler Olaf Scholz kündigte am 27. Februar 2022 im Bundestag eine
'Zeitenwende' an, einschließlich eines Sondervermögens und der Unterstützung
der Ukraine. Ob einzelne Waffensystem-Zusagen vollständig, teilweise oder
verzögert erfüllt wurden, ist politisch umstritten und quellenabhängig;
der Status der Zusagen wird daher als unklar/disputed geführt.
time_start: 2022-02-27
status: allegation
disputed: true
care_level: high
cluster_tags: [ukraine, dach]
consequences: "Sondervermögen, laufende Lieferdebatten im Bundestag."
jurisdiction_slugs: [deutschland, de-bund, ukraine]
category_slugs: [gebrochene-zusagen, ukraine-vorgaenge]
entities:
- {slug: olaf-scholz, role: decision_maker}
- {slug: bundesregierung, role: subject}
sources:
- source_slug: bundestag-dip
url: https://www.bundesregierung.de/breg-de/aktuelles/regierungserklaerung-von-bundeskanzler-olaf-scholz-2008356
title: "Regierungserklärung von Bundeskanzler Olaf Scholz, 27. Februar 2022"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Am 27. Februar 2022 gab der Bundeskanzler im Bundestag eine Regierungserklärung zur 'Zeitenwende' ab."
status: confirmed
source_urls: [https://www.bundesregierung.de/breg-de/aktuelles/regierungserklaerung-von-bundeskanzler-olaf-scholz-2008356]
- statement: "Der Erfüllungsgrad späterer Einzelzusagen (Systeme, Zeitplan) ist zwischen Quellen umstritten."
status: allegation
disputed: true
source_urls: [https://www.bundesregierung.de/breg-de/aktuelles/regierungserklaerung-von-bundeskanzler-olaf-scholz-2008356]
- slug: guttenberg-plagiat
title: "Aberkennung des Doktorgrads von Karl-Theodor zu Guttenberg"
summary: >
Die Universität Bayreuth erkannte Karl-Theodor zu Guttenberg 2011 den
Doktorgrad ab. Die Hochschule stellte erhebliche Verstöße gegen
wissenschaftliche Pflichten fest. Guttenberg trat als Bundesminister der
Verteidigung zurück.
time_start: 2011-02-16
time_end: 2011-03-01
status: confirmed
disputed: false
confirmation_basis: "Hochschulische Aberkennung durch die Universität Bayreuth."
cluster_tags: [dach]
consequences: "Aberkennung des Grades; Rücktritt vom Ministeramt."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [plagiate-akademisch]
entities:
- {slug: karl-theodor-zu-guttenberg, role: subject}
- {slug: uni-bayreuth, role: investigator}
- {slug: csu, role: related}
sources:
- source_slug: uni-hannover
url: https://www.presse.uni-bayreuth.de/de/archiv/guttenberg/index.html
title: "Universität Bayreuth — Archiv Causa Guttenberg"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Die Universität Bayreuth erkannte den Doktorgrad ab und dokumentiert den Vorgang in ihrem Pressearchiv."
status: confirmed
source_urls: [https://www.presse.uni-bayreuth.de/de/archiv/guttenberg/index.html]
- slug: schavan-plagiat
title: "Aberkennung des Doktorgrads von Annette Schavan"
summary: >
Die Heinrich-Heine-Universität Düsseldorf erkannte Annette Schavan 2013
den Doktorgrad ab. Schavan trat als Bundesministerin für Bildung und
Forschung zurück. Ein Verwaltungsstreit um die Entscheidung wurde
öffentlich geführt.
time_start: 2012-05-01
time_end: 2014-12-31
status: confirmed
disputed: false
confirmation_basis: "Hochschulische Aberkennung durch die HHU Düsseldorf."
cluster_tags: [dach]
consequences: "Aberkennung; Rücktritt vom Ministeramt."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [plagiate-akademisch]
entities:
- {slug: annette-schavan, role: subject}
- {slug: hhu-duesseldorf, role: investigator}
- {slug: cdu, role: related}
sources:
- source_slug: spiegel
url: https://www.spiegel.de/lebenundlernen/uni/plagiatsaffaere-uni-duesseldorf-erkennt-annette-schavan-doktortitel-ab-a-882722.html
title: "SPIEGEL: Uni Düsseldorf erkennt Schavan den Doktortitel ab"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Die Heinrich-Heine-Universität Düsseldorf erkannte Annette Schavan den Doktorgrad ab; zeitgenössische Qualitätsmedien berichteten über die Hochschulentscheidung."
status: confirmed
source_urls: [https://www.spiegel.de/lebenundlernen/uni/plagiatsaffaere-uni-duesseldorf-erkennt-annette-schavan-doktortitel-ab-a-882722.html]
- slug: giffey-plagiat
title: "Aberkennung des Doktorgrads von Franziska Giffey"
summary: >
Die Freie Universität Berlin erkannte Franziska Giffey 2021 den Doktorgrad
ab. Giffey trat als Bundesministerin für Familie, Senioren, Frauen und
Jugend zurück und blieb danach politisch tätig.
time_start: 2019-01-01
time_end: 2021-08-31
status: confirmed
disputed: false
confirmation_basis: "Hochschulische Aberkennung durch die FU Berlin."
cluster_tags: [dach]
consequences: "Aberkennung des Grades; Rücktritt vom Bundesministeramt."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [plagiate-akademisch]
entities:
- {slug: franziska-giffey, role: subject}
- {slug: fu-berlin, role: investigator}
- {slug: spd, role: related}
sources:
- source_slug: zeit
url: https://www.zeit.de/politik/deutschland/2021-11/franziska-giffey-doktorarbeit-freie-universitaet-berlin-plagiat
title: "Zeit: FU Berlin und Giffey-Dissertation"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Die Freie Universität Berlin erkannte Franziska Giffey den Doktorgrad ab."
status: confirmed
source_urls: [https://www.zeit.de/politik/deutschland/2021-11/franziska-giffey-doktorarbeit-freie-universitaet-berlin-plagiat]
- slug: maskenaffaere-nuesslein
title: "Provisionen im Zusammenhang mit Maskenbeschaffung (Nüßlein u. a.)"
summary: >
Ab 2021 berichteten Medien und befasste sich die Strafjustiz mit
Provisionszahlungen im Umfeld parlamentarischer Maskenvermittlung.
Einzelne Verfahren und Urteile sind personenspezifisch; pauschale
Schuldzuweisungen an alle genannten Mandatsträger unterbleiben.
Der Fall bleibt an die öffentliche Verfahrenslage gebunden.
time_start: 2020-03-01
status: ongoing
disputed: false
cluster_tags: [dach]
consequences: "Strafrechtliche Verfahren, parlamentarische Immunitätsdebatten, Fraktionsaustritte."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [interessenkonflikte, oeffentliche-auftraege-subventionen, vermoegen-nebenverdienste]
entities:
- {slug: georg-nuesslein, role: subject}
- {slug: csu, role: related}
- {slug: bundestag, role: related}
sources:
- source_slug: spiegel
url: https://www.spiegel.de/thema/maskenaffaere/
title: "SPIEGEL-Themenseite Maskenaffäre"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: bundestag-dip
url: https://www.bundestag.de
title: "Deutscher Bundestag (parlamentarischer Kontext)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Öffentliche Berichterstattung und parlamentarische Befassung dokumentieren Provisionsvorwürfe im Zusammenhang mit Maskenbeschaffung."
status: ongoing
source_urls: [https://www.spiegel.de/thema/maskenaffaere/]
- slug: amthor-augustus-intelligence
title: "Philipp Amthor und Augustus Intelligence"
summary: >
2020 berichteten Medien über Kontakte und Tätigkeiten von Philipp Amthor
im Umfeld des Unternehmens Augustus Intelligence, einschließlich Fragen
zu Interessenkonflikten und Lobbykontakten. Amthor blieb Mitglied des
Bundestags. Eine strafrechtliche Verurteilung ist in den hier verwendeten
Quellen nicht belegt.
time_start: 2020-06-01
time_end: 2020-12-31
status: allegation
disputed: false
cluster_tags: [dach]
consequences: "Politische Kritik; Amthor gab Mandate/Funktionen im Unternehmensumfeld auf."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [interessenkonflikte, lobbyismus-intransparenz]
entities:
- {slug: philip-amthor, role: subject}
- {slug: augustus-intelligence, role: related}
- {slug: cdu, role: related}
sources:
- source_slug: spiegel
url: https://www.spiegel.de/thema/philipp_amthor/
title: "SPIEGEL-Themenseite Philipp Amthor"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Qualitätsmedien berichteten 2020 über Amthors Rolle im Umfeld von Augustus Intelligence und daraus folgende Interessenkonfliktfragen."
status: allegation
source_urls: [https://www.spiegel.de/thema/philipp_amthor/]
- slug: aserbaidschan-caviar
title: "Aserbaidschan-Affäre / Caviar Diplomacy (Bundestag)"
summary: >
OCCRP und europäische Medien dokumentierten über Jahre Zahlungen und
Vorteile im Umfeld aserbaidschanischer Einflussnahme auf Delegierte,
einschließlich Mitgliedern des Bundestags. Karin Strenz wurde öffentlich
mit dem Komplex verbunden. Strafverfahren und parlamentarische Konsequenzen
sind personenspezifisch zu führen.
time_start: 2012-01-01
time_end: 2021-12-31
status: allegation
disputed: false
cluster_tags: [dach, eu-top]
consequences: "Ermittlungen, öffentliche Debatte, personelle Konsequenzen je Mandatsträger."
jurisdiction_slugs: [deutschland, eu, international]
category_slugs: [korruption-bestechung, lobbyismus-intransparenz]
entities:
- {slug: karin-strenz, role: subject}
- {slug: bundestag, role: related}
- {slug: cdu, role: related}
sources:
- source_slug: occrp
url: https://www.occrp.org/en/project/the-azerbaijani-laundromat
title: "OCCRP — The Azerbaijani Laundromat"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "OCCRP dokumentierte im Projekt 'Azerbaijani Laundromat' Geldflüsse und Einflussnahme mit Bezug zu europäischen Mandatsträgern."
status: allegation
source_urls: [https://www.occrp.org/en/project/the-azerbaijani-laundromat]
- slug: cum-ex-warburg
title: "Cum-Ex und die politische Befassung der Warburg-Bank"
summary: >
Cum-Ex-Geschäfte führten zu Strafverfahren und Rückforderungen. Die
Hamburger Warburg-Bank und Treffen mit dem damaligen Ersten Bürgermeister
Olaf Scholz wurden öffentlich untersucht. Scholz hat unrichtige
Einflussnahme bestritten. Der Komplex bleibt teilweise laufend und
politisch umstritten.
time_start: 2016-01-01
status: ongoing
disputed: true
cluster_tags: [dach]
consequences: "Strafverfahren gegen Bankverantwortliche; Untersuchungsausschuss Hamburg; anhaltende öffentliche Debatte."
jurisdiction_slugs: [deutschland]
category_slugs: [korruption-bestechung, interessenkonflikte]
entities:
- {slug: olaf-scholz, role: mentioned}
- {slug: warburg-bank, role: subject}
sources:
- source_slug: correctiv
url: https://correctiv.org/aktuelles/cum-ex/
title: "Correctiv — Cum-Ex-Recherchen"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Correctiv dokumentiert den Cum-Ex-Komplex einschließlich der Rolle einzelner Banken."
status: ongoing
disputed: true
source_urls: [https://correctiv.org/aktuelles/cum-ex/]
- slug: ibiza-affaere
title: "Ibiza-Affäre (Österreich, 2019)"
summary: >
Im Mai 2019 veröffentlichte Medien ein Video, das Heinz-Christian Strache
und Johann Gudenus auf Ibiza zeigt. Strache trat als Vizekanzler zurück;
die damalige Koalition zerfiel. Gerichtsverfahren und parlamentarische
Aufarbeitung folgten.
time_start: 2019-05-17
time_end: 2019-05-22
status: confirmed
disputed: false
confirmation_basis: "Öffentlich vorliegendes Video, Rücktritt, nachfolgende Gerichts- und parlamentarische Verfahren."
cluster_tags: [dach]
consequences: "Rücktritt Straches; Ende der Bundesregierung Kurz I; Neuwahlen."
jurisdiction_slugs: [oesterreich]
category_slugs: [korruption-bestechung, intransparente-finanzierung]
entities:
- {slug: heinz-christian-strache, role: subject}
- {slug: fpoe, role: related}
- {slug: sebastian-kurz, role: related}
sources:
- source_slug: standard-at
url: https://www.derstandard.at/story/2000103209861/ibiza-video-strache-und-gudenus-als-gespraechspartner-einer
title: "Der Standard: Ibiza-Video"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Im Mai 2019 wurde ein Video veröffentlicht, das zu Straches Rücktritt und zum Ende der Koalition führte."
status: confirmed
source_urls: [https://www.derstandard.at/story/2000103209861/ibiza-video-strache-und-gudenus-als-gespraechspartner-einer]
- slug: casag-oevp
title: "Casag-/ÖVP-Korruptionsermittlungen"
summary: >
Die Wirtschafts- und Korruptionsstaatsanwaltschaft Österreich ermittelt
seit Jahren im Komplex rund um die Casinos-Austria-Gruppe und ÖVP-nahe
Akteure, einschließlich mutmaßlicher Absprachen zu Posten und Inseraten.
Verfahrensteile sind laufend; Schuldsprüche sind einzeln zu führen.
time_start: 2019-01-01
status: ongoing
disputed: false
cluster_tags: [dach]
consequences: "Hausdurchsuchungen, Anklagen, politische Rücktritte; Stand abhängig vom jeweiligen Verfahren."
jurisdiction_slugs: [oesterreich]
category_slugs: [korruption-bestechung, vetternwirtschaft]
entities:
- {slug: oevp, role: related}
- {slug: sebastian-kurz, role: mentioned}
sources:
- source_slug: standard-at
url: https://www.derstandard.at/thema/casag-affaere
title: "Der Standard — Thema Casag-Affäre"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Österreichische Qualitätsmedien und die WKStA-Berichterstattung dokumentieren einen laufenden Ermittlungskomplex rund um Casag und ÖVP-Akteure."
status: ongoing
source_urls: [https://www.derstandard.at/thema/casag-affaere]
- slug: grasser-buwog
title: "Strafverfahren gegen Karl-Heinz Grasser (Buwog u. a.)"
summary: >
Der frühere österreichische Finanzminister Karl-Heinz Grasser wurde in
Verfahren um Buwog-Privatisierung und Terminal Tower von österreichischen
Gerichten verurteilt. Rechtsmittel und genaue Tenors sind in den
Primärquellen der Justiz nachzulesen; hier wird der gerichtliche
Schuldspruch-Komplex als bestätigt geführt, nicht jede Nebenbehauptung.
time_start: 2016-01-01
time_end: 2020-12-11
status: confirmed
disputed: false
confirmation_basis: "Gerichtliche Verurteilung in Österreich; Berichterstattung über Urteilstenor."
cluster_tags: [dach]
consequences: "Verurteilung; politische und zivilrechtliche Folgen."
jurisdiction_slugs: [oesterreich]
category_slugs: [korruption-bestechung]
entities:
- {slug: karl-heinz-grasser, role: subject}
sources:
- source_slug: standard-at
url: https://www.derstandard.at/story/2000122360000/buwog-urteil-gegen-grasser-und-meischberger
title: "Der Standard: Berichterstattung zum Buwog-Urteil"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Österreichische Gerichte verurteilten Karl-Heinz Grasser in Verfahren um Buwog/Terminal Tower; Qualitätsmedien berichteten über den Urteilstenor."
status: confirmed
source_urls: [https://www.derstandard.at/story/2000122360000/buwog-urteil-gegen-grasser-und-meischberger]
- slug: wirecard-aufsicht
title: "Wirecard-Insolvenz und staatliche Aufsicht"
summary: >
Die Wirecard AG kollabierte 2020. Ein Untersuchungsausschuss des Bundestags
befasste sich mit Versäumnissen von BaFin, Abschlussprüfung und politischer
Wahrnehmung. Der Eintrag beschreibt institutionelles Aufsichtsversagen,
keine pauschale strafrechtliche Schuld einzelner Amtsträger.
time_start: 2020-06-18
time_end: 2021-08-31
status: confirmed
disputed: false
confirmation_basis: "Insolvenz, parlamentarischer Untersuchungsausschuss, öffentliche BaFin-Kritik."
cluster_tags: [dach]
consequences: "U-Ausschuss; Reformdebatte zur Finanzaufsicht; strafrechtliche Verfahren gegen Unternehmensverantwortliche getrennt zu führen."
jurisdiction_slugs: [deutschland, de-bund]
category_slugs: [intransparente-finanzierung, sonstige]
entities:
- {slug: wirecard, role: subject}
- {slug: bafin, role: related}
- {slug: bundestag, role: investigator}
sources:
- source_slug: bundestag-ua
url: https://www.bundestag.de/ausschuesse/untersuchungsausschuss
title: "Bundestag Untersuchungsausschüsse (u. a. Wirecard)"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: bundesrechnungshof
url: https://www.bundesrechnungshof.de
title: "Bundesrechnungshof (Aufsicht/Haushaltskontrolle)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Der Deutsche Bundestag setzte einen Untersuchungsausschuss zum Wirecard-Komplex ein."
status: confirmed
source_urls: [https://www.bundestag.de/ausschuesse/untersuchungsausschuss]
- slug: crypto-ag-schweiz
title: "Crypto-AG-Affäre (Schweiz)"
summary: >
Recherchen und die schweizerische parlamentarische Oberaufsicht befassten
sich mit der Firma Crypto AG und der Nutzung von Verschlüsselungstechnik
durch ausländische Nachrichtendienste. Der Bundesrat und die GPDel
veröffentlichten Stellungnahmen. Es handelt sich um einen
Intransparenz-/Nachrichtendienstkomplex, nicht um klassische
Amtsträgerbestechung.
time_start: 2020-02-11
time_end: 2021-12-31
status: confirmed
disputed: false
confirmation_basis: "Parlamentarische Untersuchung in der Schweiz und öffentliche Bundesratsinformationen."
cluster_tags: [dach]
consequences: "Politische Aufarbeitung; nachrichtendienstrechtliche Debatte."
jurisdiction_slugs: [schweiz, international]
category_slugs: [intransparente-finanzierung, sonstige]
entities:
- {slug: crypto-ag, role: subject}
sources:
- source_slug: nzz
url: https://www.nzz.ch/schweiz/crypto-ag-was-bisher-bekannt-ist-ld.1540308
title: "NZZ: Crypto AG — was bisher bekannt ist"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "2020 wurde öffentlich, dass Crypto AG in nachrichtendienstliche Operationen ausländischer Dienste eingebunden war; Schweizer Medien und parlamentarische Gremien befassten sich damit."
status: confirmed
source_urls: [https://www.nzz.ch/schweiz/crypto-ag-was-bisher-bekannt-ist-ld.1540308]
- slug: qatargate-ep
title: "Qatargate (Europäisches Parlament)"
summary: >
Ab Dezember 2022 ermittelten belgische Behörden zu mutmaßlicher
Einflussnahme zugunsten u. a. Katars und Marokkos auf Mitglieder des
Europäischen Parlaments. Eva Kaili wurde vorläufig festgenommen.
Verfahren sind personenbezogen und teilweise laufend; der institutionelle
Skandal ist öffentlich dokumentiert.
time_start: 2022-12-09
status: ongoing
disputed: false
care_level: high
cluster_tags: [eu-top]
consequences: "Festnahmen, Änderungen der EP-Transparenzregeln, laufende Strafverfahren."
jurisdiction_slugs: [eu, eu-parlament, international]
category_slugs: [korruption-bestechung, lobbyismus-intransparenz]
entities:
- {slug: eva-kaili, role: subject}
- {slug: europaeisches-parlament, role: related}
sources:
- source_slug: politico-europe
url: https://www.politico.eu/article/european-parliament-vice-president-eva-kaili-arrested-qatargate/
title: "Politico Europe: Qatargate / Eva Kaili"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Im Dezember 2022 wurde die damalige EP-Vizepräsidentin Eva Kaili im Zuge belgischer Ermittlungen zu mutmaßlicher Einflussnahme festgenommen; Politico Europe berichtete zeitnah."
status: ongoing
source_urls: [https://www.politico.eu/article/european-parliament-vice-president-eva-kaili-arrested-qatargate/]
- slug: eu-lobby-transparenzluecken
title: "Lücken im EU-Transparenzregister und Brüsseler Lobbypraxis"
summary: >
Corporate Europe Observatory, der Europäische Bürgerbeauftragte und das
Transparenzregister selbst dokumentieren, dass nicht alle Einflussnahmen
über das Register abgebildet sind (z. B. Meetings, Think-Tank-Konstrukte).
Der Eintrag ist strukturell, kein Individualstrafverfahren.
time_start: 2011-01-01
status: confirmed
disputed: false
confirmation_basis: "Amtliches Register plus wiederholte Watchdog- und Ombudsman-Befunde zu Lücken."
care_level: high
cluster_tags: [eu-top]
consequences: "Reformdebatten zum Register; Interinstitutionelle Vereinbarungen."
jurisdiction_slugs: [eu]
category_slugs: [lobbyismus-intransparenz]
entities:
- {slug: europaeische-kommission, role: related}
- {slug: europaeisches-parlament, role: related}
sources:
- source_slug: eu-transparency-register
url: https://transparency-register.europa.eu
title: "EU Transparency Register"
accessed_at: "2026-08-21T12:00:00Z"
- source_slug: ceo
url: https://corporateeurope.org
title: "Corporate Europe Observatory"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Die EU betreibt ein öffentliches Transparenzregister; Watchdogs dokumentieren systematische Abdeckungslücken."
status: confirmed
source_urls: [https://transparency-register.europa.eu, https://corporateeurope.org]
- slug: opensanctions-ukraine-peps
title: "OpenSanctions: PEP- und Sanktionsdaten mit Ukraine-/EU-Bezug"
summary: >
OpenSanctions aggregiert öffentlich Sanktionslisten und
politisch exponierte Personen. Der Datensatz ist eine Metaquelle für
Folgeprüfungen, kein Schuldnachweis. Der Eintrag verankert die API als
dauerhafte Recherchequelle im Cluster Ukraine/EU.
time_start: 2022-02-24
status: confirmed
disputed: false
care_level: high
cluster_tags: [ukraine, international]
consequences: "Recherchewerkzeug; keine individuelle Verurteilung."
jurisdiction_slugs: [ukraine, eu, international]
category_slugs: [ukraine-vorgaenge, korruption-bestechung]
entities:
- {slug: ukraine-regierung, role: related}
sources:
- source_slug: opensanctions
url: https://www.opensanctions.org
title: "OpenSanctions"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "OpenSanctions veröffentlicht aggregierte Sanktions- und PEP-Daten, einschließlich Ukraine- und EU-Bezügen."
status: confirmed
source_urls: [https://www.opensanctions.org]
- slug: orgenesis-interessenkonflikt
title: "Vorwürfe zu Interessenkonflikten (Heiko von der Leyen / Orgenesis)"
summary: >
Medien berichteten über Tätigkeiten von Heiko von der Leyen im Umfeld von
Orgenesis und über Fragen, ob EU-Gesundheitsförderung Interessenkonflikte
berühren könnte. Die Vorwürfe sind öffentlich, aber in den hier genutzten
Quellen nicht als gerichtliche Feststellung belegt. Status daher allegation
und disputed.
time_start: 2020-01-01
status: allegation
disputed: true
care_level: high
cluster_tags: [vdl, eu-top]
consequences: "Mediale und politische Fragen; keine hier belegte gerichtliche Klärung."
jurisdiction_slugs: [eu]
category_slugs: [interessenkonflikte]
entities:
- {slug: heiko-von-der-leyen, role: subject}
- {slug: ursula-von-der-leyen, role: related}
- {slug: orgenesis, role: related}
- {slug: europaeische-kommission, role: related}
sources:
- source_slug: politico-europe
url: https://www.politico.eu
title: "Politico Europe (Berichterstattung zu EU-Gesundheits- und Interessenkonfliktthemen)"
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: "Es liegen öffentliche Medienberichte zu einer beruflichen Verbindung Heiko von der Leyens mit Orgenesis und zu daraus abgeleiteten Interessenkonfliktfragen vor; eine gerichtliche Feststellung fehlt in den hier verwendeten Quellen."
status: allegation
disputed: true
source_urls: [https://www.politico.eu]
+26
View File
@@ -0,0 +1,26 @@
slug: beispiel-fall
title: Neutraler Titel ohne Werturteil
summary: >
Was welche amtliche oder journalistische Quelle feststellt. Keine Spekulation.
time_start: 2020-01-01
time_end: null
status: allegation # confirmed | allegation | refuted | ongoing
disputed: false
confirmation_basis: null
care_level: standard # high für VdL / Ukraine / EU-Spitzen
cluster_tags: []
consequences: ""
jurisdiction_slugs: [deutschland]
category_slugs: [sonstige]
entities:
- {slug: example-entity, role: subject}
sources:
- source_slug: correctiv
url: https://example.org/dokument
title: Dokumenttitel
accessed_at: "2026-08-21T12:00:00Z"
claims:
- statement: Eine überprüfbare Aussage.
status: allegation
source_urls: [https://example.org/dokument]
events: []
+37
View File
@@ -0,0 +1,37 @@
categories:
- slug: korruption-bestechung
name: Korruption / Bestechung
description: Bestechung, Vorteilsnahme, Schmiergeld, Amtsdelikte mit Korruptionsbezug.
- slug: vetternwirtschaft
name: Vetternwirtschaft
description: Ämter, Aufträge oder Vorteile für nahestehende Personen ohne nachvollziehbares Verfahren.
- slug: gebrochene-zusagen
name: Gebrochene Wahlversprechen / politische Zusagen
description: Dokumentierte Zusagen vs. späteres Handeln. Status oft partial/unclear/disputed.
- slug: plagiate-akademisch
name: Plagiate / akademische Unregelmäßigkeiten
description: Aberkannte Grade, Hochschulverfahren, dokumentierte Textübernahmen.
- slug: interessenkonflikte
name: Interessenkonflikte
description: Amt und private/wirtschaftliche Interessen, inklusive Angehörige.
- slug: intransparente-finanzierung
name: Intransparente Finanzierung / Mittelverwendung
description: Fehlende Belege, verweigerte Herausgabe, Lücken in Berichten.
- slug: ukraine-vorgaenge
name: Ukraine-bezogene Vorgänge
description: Hilfen, Waffen, Fonds, Aufträge, Transparenz der Mittel — unabhängig vom Ort des Akteurs.
- slug: lobbyismus-intransparenz
name: Lobbyismus / Intransparenz
description: Undokumentierte Treffen, Lücken im Transparenzregister, Side-Letters.
- slug: oeffentliche-auftraege-subventionen
name: Öffentliche Aufträge und Subventionen
description: Vergabe, Beraterverträge, Beihilfen.
- slug: vermoegen-nebenverdienste
name: Vermögen und Nebenverdienste
description: Auffällige Vermögensentwicklungen, intransparente Nebeneinkünfte.
- slug: ngo-stiftungs-skandale
name: NGOs / Stiftungen / Quasi-NGOs
description: Unregelmäßigkeiten bei Organisationen mit öffentlicher Nähe.
- slug: sonstige
name: Sonstige Auffälligkeiten
description: Restkategorie, sparsam nutzen.
+207
View File
@@ -0,0 +1,207 @@
entities:
- slug: ursula-von-der-leyen
name: Ursula von der Leyen
entity_type: person
jurisdiction: eu
care_level: high
cluster_tags: [vdl, eu-top]
wikidata_id: Q60772
aliases: [VDL, UdL, "Ursula von der Leyen"]
notes_neutral: "Bundesministerin der Verteidigung 2013–2019, Präsidentin der Europäischen Kommission seit 2019."
roles:
- title: Bundesministerin der Verteidigung
started_on: 2013-12-17
ended_on: 2019-07-17
- title: Präsidentin der Europäischen Kommission
started_on: 2019-12-01
- slug: heiko-von-der-leyen
name: Heiko von der Leyen
entity_type: person
care_level: high
cluster_tags: [vdl]
notes_neutral: "Ehemann von Ursula von der Leyen; Arzt und zeitweise in Funktionen bei Orgenesis genannt."
- slug: europaeische-kommission
name: Europäische Kommission
entity_type: institution
jurisdiction: eu
care_level: high
cluster_tags: [eu-top, vdl]
- slug: europaeisches-parlament
name: Europäisches Parlament
entity_type: institution
jurisdiction: eu
cluster_tags: [eu-top]
- slug: pfizer
name: Pfizer
entity_type: company
cluster_tags: [vdl, eu-top]
- slug: orgenesis
name: Orgenesis
entity_type: company
cluster_tags: [vdl]
- slug: goldman-sachs
name: Goldman Sachs
entity_type: company
cluster_tags: [eu-top]
- slug: jose-manuel-barroso
name: José Manuel Barroso
entity_type: person
jurisdiction: eu
care_level: high
cluster_tags: [eu-top]
notes_neutral: "Kommissionspräsident 2004–2014; später nicht-exekutiver Vorsitzender bei Goldman Sachs International."
- slug: eva-kaili
name: Eva Kaili
entity_type: person
jurisdiction: eu
care_level: high
cluster_tags: [eu-top]
- slug: karl-theodor-zu-guttenberg
name: Karl-Theodor zu Guttenberg
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach]
- slug: annette-schavan
name: Annette Schavan
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach]
- slug: franziska-giffey
name: Franziska Giffey
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach]
- slug: georg-nuesslein
name: Georg Nüßlein
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach]
- slug: philip-amthor
name: Philipp Amthor
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach]
- slug: karin-strenz
name: Karin Strenz
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach]
- slug: olaf-scholz
name: Olaf Scholz
entity_type: person
jurisdiction: deutschland
cluster_tags: [dach, ukraine]
- slug: bundesregierung
name: Bundesregierung
entity_type: institution
jurisdiction: de-bund
cluster_tags: [dach, ukraine]
- slug: bafin
name: Bundesanstalt für Finanzdienstleistungsaufsicht
entity_type: agency
jurisdiction: de-bund
cluster_tags: [dach]
- slug: wirecard
name: Wirecard AG
entity_type: company
jurisdiction: deutschland
cluster_tags: [dach]
- slug: warburg-bank
name: M.M.Warburg & CO
entity_type: company
jurisdiction: deutschland
cluster_tags: [dach]
- slug: heinz-christian-strache
name: Heinz-Christian Strache
entity_type: person
jurisdiction: oesterreich
cluster_tags: [dach]
- slug: sebastian-kurz
name: Sebastian Kurz
entity_type: person
jurisdiction: oesterreich
cluster_tags: [dach]
- slug: fpoe
name: FPÖ
entity_type: party
jurisdiction: oesterreich
cluster_tags: [dach]
- slug: oevp
name: ÖVP
entity_type: party
jurisdiction: oesterreich
cluster_tags: [dach]
- slug: karl-heinz-grasser
name: Karl-Heinz Grasser
entity_type: person
jurisdiction: oesterreich
cluster_tags: [dach]
- slug: nabu
name: Nationales Antikorruptionsbüro der Ukraine (NABU)
entity_type: agency
jurisdiction: ukraine
care_level: high
cluster_tags: [ukraine]
- slug: ukraine-regierung
name: Regierung der Ukraine
entity_type: institution
jurisdiction: ukraine
care_level: high
cluster_tags: [ukraine]
- slug: crypto-ag
name: Crypto AG
entity_type: company
jurisdiction: schweiz
cluster_tags: [dach]
- slug: bundestag
name: Deutscher Bundestag
entity_type: institution
jurisdiction: de-bund
cluster_tags: [dach]
- slug: csu
name: CSU
entity_type: party
jurisdiction: deutschland
cluster_tags: [dach]
- slug: cdu
name: CDU
entity_type: party
jurisdiction: deutschland
cluster_tags: [dach]
- slug: spd
name: SPD
entity_type: party
jurisdiction: deutschland
cluster_tags: [dach]
- slug: uni-bayreuth
name: Universität Bayreuth
entity_type: institution
jurisdiction: deutschland
- slug: uni-hannover
name: Leibniz Universität Hannover
entity_type: institution
jurisdiction: deutschland
cluster_tags: [vdl]
- slug: fu-berlin
name: Freie Universität Berlin
entity_type: institution
jurisdiction: deutschland
- slug: hhu-duesseldorf
name: Heinrich-Heine-Universität Düsseldorf
entity_type: institution
jurisdiction: deutschland
- slug: eu-rechnungshof
name: Europäischer Rechnungshof
entity_type: agency
jurisdiction: eu
care_level: high
cluster_tags: [eu-top, ukraine]
- slug: eu-ombudsmann
name: Europäischer Bürgerbeauftragter
entity_type: agency
jurisdiction: eu
cluster_tags: [eu-top, vdl]
- slug: augustus-intelligence
name: Augustus Intelligence
entity_type: company
cluster_tags: [dach]
+60
View File
@@ -0,0 +1,60 @@
jurisdictions:
- slug: deutschland
name: Deutschland
type: country
iso_code: DE
sort_order: 10
- slug: de-bund
name: Bundesebene
type: institution
parent: deutschland
sort_order: 11
- slug: de-laender
name: Bundesländer
type: region
parent: deutschland
sort_order: 12
- slug: de-kommunen
name: Kommunen
type: municipality
parent: deutschland
sort_order: 13
- slug: oesterreich
name: Österreich
type: country
iso_code: AT
sort_order: 20
- slug: schweiz
name: Schweiz
type: country
iso_code: CH
sort_order: 30
- slug: eu
name: Europäische Union
type: institution
iso_code: EU
sort_order: 40
- slug: eu-kommission
name: Europäische Kommission
type: institution
parent: eu
sort_order: 41
- slug: eu-parlament
name: Europäisches Parlament
type: institution
parent: eu
sort_order: 42
- slug: eu-rat
name: Rat der Europäischen Union
type: institution
parent: eu
sort_order: 43
- slug: ukraine
name: Ukraine
type: country
iso_code: UA
sort_order: 50
- slug: international
name: International
type: international
sort_order: 90
+32
View File
@@ -0,0 +1,32 @@
promises:
- slug: zeitenwende-sondervermoegen
entity_slug: olaf-scholz
text: >
Ankündigung eines Sondervermögens von 100 Milliarden Euro für die
Bundeswehr in der Regierungserklärung vom 27. Februar 2022.
promised_on: 2022-02-27
context: Regierungserklärung Bundestag / Zeitenwende
status: kept
related_case_slug: zeitenwende-militaerhilfe
jurisdiction_slug: deutschland
sources:
- source_slug: bundestag-dip
url: https://www.bundesregierung.de/breg-de/aktuelles/regierungserklaerung-von-bundeskanzler-olaf-scholz-2008356
title: "Regierungserklärung Olaf Scholz, 27. Februar 2022"
accessed_at: "2026-08-21T12:00:00Z"
- slug: zeitenwende-ukraine-unterstuetzung
entity_slug: olaf-scholz
text: >
Zusage, die Ukraine zu unterstützen (Regierungserklärung 27. Februar 2022).
Umfang, Tempo und Systeme einzelner Lieferungen sind zwischen Quellen
umstritten.
promised_on: 2022-02-27
context: Zeitenwende / Ukraine-Hilfe
status: partial
related_case_slug: zeitenwende-militaerhilfe
jurisdiction_slug: ukraine
sources:
- source_slug: bundestag-dip
url: https://www.bundesregierung.de/breg-de/aktuelles/regierungserklaerung-von-bundeskanzler-olaf-scholz-2008356
title: "Regierungserklärung Olaf Scholz, 27. Februar 2022"
accessed_at: "2026-08-21T12:00:00Z"
+451
View File
@@ -0,0 +1,451 @@
# Kanonischer Quellenkatalog. Runtime-State (last_success_at) liegt nur in der DB.
# ingest_method: rss | api | manual — kein HTML-Scraping.
sources:
- slug: correctiv
name: Correctiv
type: investigative
base_url: https://correctiv.org
language_codes: [de]
reliability: 4
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://correctiv.org/feed/
polite_interval_seconds: 3600
topic_focus: [de, dach]
status: active
notes: "Investigativmedium. RSS öffentlich."
- slug: fragdenstaat
name: FragDenStaat
type: ngo_report
base_url: https://fragdenstaat.de
language_codes: [de]
reliability: 4
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://fragdenstaat.de/blog/feed/
polite_interval_seconds: 3600
topic_focus: [de, eu]
status: active
notes: "IFO-Anfragen und Dokumente. Blog-Feed."
- slug: abgeordnetenwatch
name: Abgeordnetenwatch
type: ngo_report
base_url: https://www.abgeordnetenwatch.de
language_codes: [de]
reliability: 4
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://www.abgeordnetenwatch.de/rss.xml
polite_interval_seconds: 3600
topic_focus: [de, vermoegen-nebenverdienste]
status: active
notes: "Nebeneinkünfte und Parlamentarier-Fragen."
- slug: netzpolitik
name: Netzpolitik.org
type: investigative
base_url: https://netzpolitik.org
language_codes: [de]
reliability: 4
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://netzpolitik.org/feed/
polite_interval_seconds: 3600
topic_focus: [de, eu]
status: active
notes: ""
- slug: lobbycontrol
name: LobbyControl
type: ngo_report
base_url: https://www.lobbycontrol.de
language_codes: [de]
reliability: 4
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://www.lobbycontrol.de/feed/
polite_interval_seconds: 3600
topic_focus: [de, eu, lobbyismus]
status: active
notes: ""
- slug: ceo
name: Corporate Europe Observatory
type: ngo_report
base_url: https://corporateeurope.org
language_codes: [en]
reliability: 4
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://corporateeurope.org/en/rss
polite_interval_seconds: 3600
topic_focus: [eu, lobbyismus, vdl]
status: active
notes: "Brüssel-Lobbyismus."
- slug: spiegel
name: Der Spiegel
type: news
base_url: https://www.spiegel.de
language_codes: [de]
reliability: 3
update_frequency: hourly
ingest_method: rss
ingest_config:
feed_url: https://www.spiegel.de/politik/index.rss
polite_interval_seconds: 1800
topic_focus: [de, eu]
status: active
notes: "Kontextquelle. Allein nicht für status=confirmed."
- slug: zeit
name: Die Zeit
type: news
base_url: https://www.zeit.de
language_codes: [de]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://newsfeed.zeit.de/politik/index
polite_interval_seconds: 1800
topic_focus: [de, eu]
status: active
notes: "Kontextquelle."
- slug: sz
name: Süddeutsche Zeitung
type: news
base_url: https://www.sueddeutsche.de
language_codes: [de]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://rss.sueddeutsche.de/rss/Politik
polite_interval_seconds: 1800
topic_focus: [de]
status: active
notes: "Kontextquelle."
- slug: faz
name: Frankfurter Allgemeine Zeitung
type: news
base_url: https://www.faz.net
language_codes: [de]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://www.faz.net/rss/aktuell/politik/
polite_interval_seconds: 1800
topic_focus: [de, eu]
status: active
notes: "Kontextquelle."
- slug: standard-at
name: Der Standard
type: news
base_url: https://www.derstandard.at
language_codes: [de]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://www.derstandard.at/rss/politik
polite_interval_seconds: 1800
topic_focus: [at]
status: active
notes: ""
- slug: nzz
name: Neue Zürcher Zeitung
type: news
base_url: https://www.nzz.ch
language_codes: [de]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://www.nzz.ch/startseite.rss
polite_interval_seconds: 1800
topic_focus: [ch, eu]
status: active
notes: ""
- slug: politico-europe
name: Politico Europe
type: news
base_url: https://www.politico.eu
language_codes: [en]
reliability: 3
update_frequency: hourly
ingest_method: rss
ingest_config:
feed_url: https://www.politico.eu/feed/
polite_interval_seconds: 1800
topic_focus: [eu, vdl, ukraine]
status: active
notes: ""
- slug: euractiv
name: Euractiv
type: news
base_url: https://www.euractiv.com
language_codes: [en]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://www.euractiv.com/feed/
polite_interval_seconds: 1800
topic_focus: [eu, ukraine]
status: active
notes: ""
- slug: eu-ombudsman
name: Europäischer Bürgerbeauftragter
type: official_register
base_url: https://www.ombudsman.europa.eu
language_codes: [en, de, fr]
reliability: 5
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://www.ombudsman.europa.eu/en/rss
polite_interval_seconds: 3600
topic_focus: [eu, vdl]
status: active
notes: "Amtliche Missstandsentscheidungen. Taugt für confirmed, wenn der Claim den Tenor der Entscheidung wiedergibt."
- slug: eu-court-of-auditors
name: Europäischer Rechnungshof
type: official_register
base_url: https://www.eca.europa.eu
language_codes: [en, de]
reliability: 5
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://www.eca.europa.eu/en/rss
polite_interval_seconds: 3600
topic_focus: [eu, ukraine, intransparente-finanzierung]
status: active
notes: "Prüfberichte, auch Ukraine Facility / MFA."
- slug: eur-lex
name: EUR-Lex
type: official_register
base_url: https://eur-lex.europa.eu
language_codes: [en, de, fr]
reliability: 5
update_frequency: daily
ingest_method: rss
ingest_config:
api: eurlex
feed_url: https://eur-lex.europa.eu/EN/display-feed.rss.html
polite_interval_seconds: 3600
topic_focus: [eu]
status: active
notes: "Amtsblatt und Rechtsakte."
- slug: eu-transparency-register
name: EU-Transparenzregister
type: official_register
base_url: https://transparency-register.europa.eu
language_codes: [en]
reliability: 5
update_frequency: weekly
ingest_method: manual
ingest_config:
notes: "Öffentliche Suche / Datenexport. Kein stabiler RSS; Einträge manuell oder später API."
topic_focus: [eu, lobbyismus]
status: active
notes: "Katalog-Quelle für Lobby-Einträge. Ingest v1 manual."
- slug: bundestag-dip
name: Bundestag DIP
type: parliamentary
base_url: https://dip.bundestag.de
language_codes: [de]
reliability: 5
update_frequency: daily
ingest_method: api
ingest_config:
api: dip
url: https://search.dip.bundestag.de/api/v1/drucksache
params:
format: json
f.datum.start: "2021-10-01"
polite_interval_seconds: 3600
topic_focus: [de]
status: active
notes: "Öffentliche Dokumenten-API. Key ggf. in params ergänzen."
- slug: bundesrechnungshof
name: Bundesrechnungshof
type: official_register
base_url: https://www.bundesrechnungshof.de
language_codes: [de]
reliability: 5
update_frequency: monthly
ingest_method: rss
ingest_config:
feed_url: https://www.bundesrechnungshof.de/de/aktuelles/rss
polite_interval_seconds: 7200
topic_focus: [de]
status: active
notes: "Bemerkungen und Gutachten."
- slug: bverfg
name: Bundesverfassungsgericht
type: court
base_url: https://www.bundesverfassungsgericht.de
language_codes: [de]
reliability: 5
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://www.bundesverfassungsgericht.de/SiteGlobals/Functions/RSSFeed/DE/RSSNewsfeed/RSSNewsfeed.xml
polite_interval_seconds: 7200
topic_focus: [de]
status: active
notes: ""
- slug: opensanctions
name: OpenSanctions
type: official_register
base_url: https://www.opensanctions.org
language_codes: [en]
reliability: 5
update_frequency: daily
ingest_method: api
ingest_config:
api: opensanctions
url: https://api.opensanctions.org/search/default
query: ukraine
polite_interval_seconds: 3600
topic_focus: [international, ukraine, eu]
status: active
notes: "Sanktions- und PEP-Daten. API öffentlich, Rate Limits beachten."
- slug: occrp
name: OCCRP
type: investigative
base_url: https://www.occrp.org
language_codes: [en]
reliability: 4
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://www.occrp.org/en/rss.xml
polite_interval_seconds: 3600
topic_focus: [international, ukraine, eu]
status: active
notes: ""
- slug: ti-ukraine
name: Transparency International Ukraine
type: ngo_report
base_url: https://ti-ukraine.org
language_codes: [en, uk]
reliability: 4
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://ti-ukraine.org/en/feed/
polite_interval_seconds: 3600
topic_focus: [ukraine]
status: active
notes: ""
- slug: nabu
name: NABU (National Anti-Corruption Bureau of Ukraine)
type: official_register
base_url: https://nabu.gov.ua
language_codes: [uk, en]
reliability: 4
update_frequency: weekly
ingest_method: rss
ingest_config:
feed_url: https://nabu.gov.ua/en/news/rss.xml
polite_interval_seconds: 3600
topic_focus: [ukraine]
status: active
notes: "Behördliche Mitteilungen. Feed-URL prüfen; bei 404 auf manual stellen."
- slug: eu-ukraine-facility
name: EU Ukraine Facility / Kommission
type: official_register
base_url: https://commission.europa.eu
language_codes: [en]
reliability: 5
update_frequency: monthly
ingest_method: manual
ingest_config: {}
topic_focus: [ukraine, eu]
status: active
notes: "Offizielle Facility-Seiten und Durchführungsbeschlüsse. Manuelle SourceEntries."
- slug: vroniplag
name: VroniPlag Wiki
type: wiki
base_url: https://de.vroniplag.wikia.org
language_codes: [de]
reliability: 2
update_frequency: manual
ingest_method: manual
ingest_config: {}
topic_focus: [de, plagiate-akademisch]
status: active
notes: "Nie allein, nie für confirmed. Nur als Gegenposition/Dokumentation der Vorwürfe."
- slug: uni-hannover
name: Leibniz Universität Hannover
type: academic
base_url: https://www.uni-hannover.de
language_codes: [de]
reliability: 4
update_frequency: manual
ingest_method: manual
ingest_config: {}
topic_focus: [de, vdl, plagiate-akademisch]
status: active
notes: "Hochschulische Stellungnahmen zu Dissertationen."
- slug: bundestag-ua
name: Bundestag Untersuchungsausschüsse
type: parliamentary
base_url: https://www.bundestag.de
language_codes: [de]
reliability: 5
update_frequency: manual
ingest_method: manual
ingest_config: {}
topic_focus: [de]
status: active
notes: "U-Ausschuss-Berichte (z. B. Beraterverträge BMVg)."
- slug: reuters
name: Reuters
type: news
base_url: https://www.reuters.com
language_codes: [en]
reliability: 3
update_frequency: daily
ingest_method: rss
ingest_config:
feed_url: https://www.reuters.com/world/europe/rss
polite_interval_seconds: 1800
topic_focus: [eu, ukraine, international]
status: active
notes: "Kontextquelle."
+47
View File
@@ -0,0 +1,47 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: quellenatlas
POSTGRES_PASSWORD: quellenatlas
POSTGRES_DB: quellenatlas
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U quellenatlas"]
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: ./backend
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://quellenatlas:quellenatlas@postgres:5432/quellenatlas
SOURCES_YAML: /data/sources.yaml
SEED_DIR: /data/seed
volumes:
- ./data:/data
- ./backend/app:/app/app
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
frontend:
build:
context: ./frontend
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
ports:
- "3000:3000"
depends_on:
- backend
volumes:
pgdata:
+78
View File
@@ -0,0 +1,78 @@
# Quelle oder Fall hinzufügen
## Neue Quelle (Katalog)
1. Eintrag in `data/sources.yaml` anlegen (kanonisch). Pflichtfelder:
```yaml
- slug: beispiel-medium
name: Beispiel Medium
type: investigative # siehe docs/data-model.md
base_url: https://example.org
language_codes: [de]
reliability: 4 # 1–5
update_frequency: daily
ingest_method: rss # rss | api | manual
ingest_config:
feed_url: https://example.org/feed
polite_interval_seconds: 3600
user_agent: quellenatlas/0.1
topic_focus: [de, eu]
status: active
notes: "Nur RSS, kein HTML-Scraping."
```
2. Katalog synchronisieren:
```bash
docker compose exec backend python -m app.cli sync-sources
```
3. Live testen:
```bash
docker compose exec backend python -m app.cli ingest --slug beispiel-medium
```
Treffer landen in der Review-Queue, nicht in der Fall-Tabelle.
### Welcher `ingest_method`?
| Methode | Wann |
|---|---|
| `rss` | Öffentlicher Feed vorhanden |
| `api` | Dokumentierte öffentliche API (DIP, OpenSanctions, …) |
| `manual` | Kein stabiler Feed — nur Katalog + von Hand angelegte SourceEntries |
Kein HTML-Scraper gegen Paywalls oder ToS-widrige Seiten.
### Reliability-Leitlinie
| Score | Typische Träger |
|---|---|
| 5 | Gericht, Amtsblatt, Transparenzregister, Rechnungshof, Ombudsmann |
| 4 | Investigativmedien mit Korrektorat, etablierte Watchdogs, Behörden-Blogs |
| 3 | Qualitäts-Tages-/Wochenpresse |
| 2 | Fachblogs, parteinahe Medien (nur Kontext) |
| 1 | Ungeprüft, anonym, Aggregatoren — nicht für Claims verwenden |
## Neuen Fall anlegen
Bevorzugt im UI unter `/admin/cases/new`, oder als YAML unter `data/seed/cases/<slug>.yaml` und `make seed`.
Mindestinhalt:
- `title`, `slug`, `summary` (neutral), `time_start`, `status`
- mindestens eine Jurisdiktion, eine Kategorie, eine Entität
- mindestens ein Claim mit Quelle (URL, Titel, `accessed_at`)
- bei `status: confirmed`: amtliche Quelle **oder** `confirmation_basis`
- bei VdL/Ukraine: `care_level: high` und passende `cluster_tags`
Beispiel-Skelett: `data/seed/cases/_template.yaml`.
## Cluster-Tags
- `vdl` — Ursula von der Leyen und unmittelbar verbundene Vorgänge
- `ukraine` — Ukraine-Hilfe, -Beschaffung, -Zusagen, UA-Anti-Korruption
- `eu-top` — EU-Spitzen und Institutionen
- `dach` — DE/AT/CH ohne die obigen Schwerpunkte
+95
View File
@@ -0,0 +1,95 @@
# Quellenatlas — Architektur
Privates, selbst gehostetes Recherche-System für **quellenbasierte** Dokumentation politischer und gesellschaftlicher Auffälligkeiten. Kein öffentliches Portal, kein Meinungsprodukt.
## Ziele
- Hierarchische Navigation über Jurisdiktionen, Entitäten, Kategorien und Einzelfälle
- Jede faktische Aussage ist einer Quelle zugeordnet
- Klare Status-Trennung: bestätigt / Vorwurf / widerlegt / laufend; zusätzlich `disputed`
- Erhöhte Sorgfalt für priorisierte Cluster: Ursula von der Leyen, Ukraine, EU-Spitzen
- Halbautomatische Aktualisierung nur über RSS/API in eine Review-Queue (kein Medien-HTML-Scraping)
- Export zitierfähiger Einträge (JSON, CSV, Markdown)
## Nicht-Ziele (v1)
- Öffentlicher Zugriff ohne Login
- LLM-generierte Zusammenfassungen
- HTML-Crawler gegen Nachrichtenportale
- Neo4j (Graph ist eine SQL-abgeleitete JSON-Sicht)
- Multi-Tenancy, Kommentare, Social Sharing
## Systemübersicht
```
[Browser / Next.js]
| HTTP + Cookie (JWT)
v
[FastAPI /api/v1]
| [APScheduler]
| |
+-- CRUD/Search/Export +-- IngestAdapter (RSS/API)
| |
v v
PostgreSQL 16 SourceEntry + ReviewItem
^
|
data/sources.yaml (Katalog, kanonisch)
data/seed/** (YAML-Fälle)
```
Drei Prozesse in Docker Compose: `postgres`, `backend`, `frontend`. Der Ingest-Scheduler läuft im Backend-Prozess (kein Redis in v1).
## Schichten (Backend)
| Schicht | Pfad | Verantwortung |
|---|---|---|
| HTTP | `backend/app/api/v1/` | Auth, Serialisierung, Statuscodes |
| Services | `backend/app/services/` | Orchestrierung, Audit-Log |
| Ethik | `backend/app/core/ethics.py` | Harte Regeln; von Services aufgerufen |
| ORM | `backend/app/models/` | Schema |
| Ingest | `backend/app/ingest/` | Adapter, Dedup, Keyword-Priority |
Admin-UI ist **kein** separates SQLAdmin. Alle Schreibzugriffe laufen durch dieselben Services, damit Ethik-Regeln nicht umgangen werden.
## Auth
- Rollen: `admin`, `editor`, `viewer`
- JWT in httpOnly-Cookie (`quellenatlas`, SameSite=Lax)
- Bootstrap-User aus `ADMIN_USERNAME` / `ADMIN_PASSWORD`
- Alle API-Routen außer `POST /api/v1/auth/login` und `/health` erfordern Session
- Frontend-Middleware blockt unauthentifizierte Seiten außer `/login`
## Datenfluss: Fall anlegen
1. Editor erstellt Case (Status Default `allegation`)
2. Mindestens ein `SourceEntry` mit `accessed_at`
3. Claims mit je ≥ 1 Quelle
4. `ethics.validate_*` — sonst HTTP 422
5. `CaseRevision`-Snapshot + `UpdateLog`
## Datenfluss: Ingest
1. Scheduler oder `POST /api/v1/ingest/run/{slug}`
2. Adapter holt Feed/API, kanonisiert URL, hasht Inhalt
3. Upsert `SourceEntry` (nie ein Case)
4. Keyword-Match (VdL / Ukraine / Kategorien) → `ReviewItem`, bei Treffer `priority=high`
5. Mensch reviewed: accept / reject / merge in bestehenden Fall
## Cluster-Navigation
Keine Sonder-Tabellen. Cluster sind Tags auf Entity/Case (`vdl`, `ukraine`, `eu-top`, `dach`) plus `care_level=high`. Endpoints `/api/v1/clusters/vdl` und `/clusters/ukraine` filtern darauf.
## Graph (v1)
`GET /api/v1/graph?entity=slug|case=slug` baut Knoten und Kanten aus SQL (Case↔Entity, Case↔Category, Entity↔Jurisdiction, Claim↔Source). Kein Graph-Store.
## Erweiterbarkeit
- Neue Quelle: Eintrag in `data/sources.yaml`, bei neuem Protokoll ein Adapter in `ingest/adapters/`
- Neue Kategorie/Jurisdiktion: Seed-YAML oder Admin-API
- Später Neo4j: denselben Graph-Endpoint als Quelle für ein Read-Model nutzen
## Deployment
Siehe Root-`README.md`. Lokal: `docker compose up --build`. Persistenz nur in der Postgres-Volume. Backups = `pg_dump`.
+222
View File
@@ -0,0 +1,222 @@
# Quellenatlas — Datenmodell
PostgreSQL 16, SQLAlchemy 2.0, UUID-Primärschlüssel, stabile `slug`s für URLs.
## Enums
| Enum | Werte |
|---|---|
| `jurisdiction_type` | country, region, municipality, institution, international |
| `entity_type` | person, party, ngo, foundation, think_tank, company, lobby, agency, institution, other |
| `care_level` | standard, high |
| `case_status` | confirmed, allegation, refuted, ongoing |
| `promise_status` | kept, broken, partial, unclear, superseded |
| `source_type` | news, investigative, official_register, ngo_report, parliamentary, court, wiki, academic, other |
| `source_status` | active, paused, problematic |
| `ingest_method` | rss, api, manual |
| `update_frequency` | hourly, daily, weekly, monthly, manual |
| `case_entity_role` | subject, decision_maker, beneficiary, investigator, mentioned, related |
| `review_status` | pending, accepted, rejected, merged |
| `review_priority` | normal, high |
| `conflict_status` | unresolved, marked_disputed, noted |
| `user_role` | admin, editor, viewer |
| `actor_type` | user, pipeline |
| `audit_action` | create, update, delete, ingest, review |
| `cluster_tag` | vdl, ukraine, eu-top, dach |
`cluster_tag` und Sprachcodes liegen als `TEXT[]` (Postgres) auf Entity/Case/Source.
## Tabellen
### users
| Spalte | Typ | Notes |
|---|---|---|
| id | uuid PK | |
| username | text unique | |
| password_hash | text | |
| role | user_role | |
| is_active | bool | default true |
| created_at | timestamptz | |
### jurisdictions
Selbstreferenzieller Baum. EU und Ukraine sind **Wurzelknoten**, nicht Kinder von `international`.
| Spalte | Typ |
|---|---|
| id | uuid PK |
| slug | text unique |
| name | text |
| type | jurisdiction_type |
| parent_id | uuid FK nullable |
| iso_code | text nullable |
| sort_order | int default 0 |
### entities
| Spalte | Typ |
|---|---|
| id | uuid PK |
| slug | text unique |
| name | text |
| entity_type | entity_type |
| jurisdiction_id | uuid FK nullable |
| care_level | care_level default standard |
| cluster_tags | text[] |
| notes_neutral | text nullable |
| wikidata_id | text nullable |
| is_active | bool default true |
`entity_aliases(entity_id, alias, lang?)` — unique (entity_id, alias).
`entity_roles(entity_id, title, organization_id?, jurisdiction_id?, started_on, ended_on?, source_entry_id?)`.
### categories
Baum analog Jurisdiktionen: `id, slug, name, parent_id?, description?`.
Kanonische Seed-Slugs: `korruption-bestechung`, `vetternwirtschaft`, `gebrochene-zusagen`, `plagiate-akademisch`, `interessenkonflikte`, `intransparente-finanzierung`, `ukraine-vorgaenge`, `lobbyismus-intransparenz`, `oeffentliche-auftraege-subventionen`, `vermoegen-nebenverdienste`, `ngo-stiftungs-skandale`, `sonstige`.
Ukraine ist **Jurisdiktion und Kategorie**: Vorgänge können in DE/EU stattfinden und trotzdem `ukraine-vorgaenge` sein.
### sources
Stammdaten kommen aus `data/sources.yaml`. Runtime-Felder (`last_success_at`, `last_error`) nur in der DB.
| Spalte | Typ |
|---|---|
| id | uuid PK |
| slug | text unique |
| name | text |
| type | source_type |
| base_url | text |
| language_codes | text[] |
| reliability | int 1–5 |
| update_frequency | update_frequency |
| ingest_method | ingest_method |
| ingest_config | jsonb |
| topic_focus | text[] |
| status | source_status |
| last_success_at | timestamptz nullable |
| last_error | text nullable |
| notes | text nullable |
### source_entries
| Spalte | Typ |
|---|---|
| id | uuid PK |
| source_id | uuid FK |
| url | text unique |
| title | text |
| published_at | timestamptz nullable |
| accessed_at | timestamptz **not null** |
| language | text nullable |
| excerpt | text nullable |
| content_hash | text nullable |
| raw | jsonb nullable |
### cases
| Spalte | Typ |
|---|---|
| id | uuid PK |
| slug | text unique |
| title | text |
| summary | text |
| time_start | date |
| time_end | date nullable |
| status | case_status |
| disputed | bool default false |
| confirmation_basis | text nullable |
| care_level | care_level |
| cluster_tags | text[] |
| consequences | text nullable |
| created_at / updated_at | timestamptz |
| created_by | uuid FK users |
M2M:
- `case_jurisdictions(case_id, jurisdiction_id)`
- `case_categories(case_id, category_id)`
- `case_entities(case_id, entity_id, role)`
- `case_source_entries(case_id, source_entry_id, locator?, quote?, accessed_at)`
### case_events
Chronologie: `id, case_id, occurred_on, title, body, sort_order`.
`case_event_source_entries` M2M — jedes Event braucht ≥ 1 Quelle (Service-Constraint).
### case_revisions
Voll-Snapshot als jsonb bei jedem Case-Write: `id, case_id, revision, snapshot, created_at, created_by`.
### claims
Atomare Aussage: `id, case_id, statement, status, disputed, sort_order`.
`claim_source_entries(claim_id, source_entry_id, locator?, quote?)` — **mindestens eine Zeile pro Claim** (Ethics-Service; Anwendung erzwingt das vor Commit).
### promises
| Spalte | Typ |
|---|---|
| id | uuid PK |
| slug | text unique |
| entity_id | uuid FK |
| text | text |
| promised_on | date nullable |
| context | text nullable |
| status | promise_status |
| related_case_id | uuid FK nullable |
| jurisdiction_id | uuid FK nullable |
`promise_source_entries` M2M.
### conflicts
`id, claim_id, status, notes`. M2M `conflict_source_entries`. Offener Conflict setzt `claim.disputed` und `case.disputed`.
### review_items
| Spalte | Typ |
|---|---|
| id | uuid PK |
| source_id | uuid FK |
| source_entry_id | uuid FK nullable |
| title | text |
| url | text |
| snippet | text nullable |
| suggested_entities | jsonb |
| suggested_categories | jsonb |
| priority | review_priority |
| status | review_status |
| matched_case_id | uuid FK nullable |
| decided_by | uuid FK nullable |
| decided_at | timestamptz nullable |
| created_at | timestamptz |
Ingest schreibt ReviewItems, niemals Cases.
### update_logs
`id, actor_type, actor_id?, entity_table, entity_id, action, before jsonb, after jsonb, created_at`.
## Indizes
- Unique: alle `slug`, `source_entries.url`, `users.username`
- GIN: `to_tsvector` auf case.title/summary/claims.statement (deutsch + simple)
- GIN: `pg_trgm` auf `entities.name`, `entity_aliases.alias`, `cases.title`
- BTree: `review_items.status`, `cases.status`, `cluster_tags` (GIN auf Arrays)
## Integritätsregeln (Anwendung + Tests)
Siehe `docs/ethics.md`. Kurz:
1. Claim ohne Quelle → verboten
2. Case ohne Quelle → verboten
3. `status=confirmed` nur mit amtlicher/gerichtlicher Quelle **oder** ausgefülltem `confirmation_basis`
4. `care_level=high` → `accessed_at` Pflicht an jeder verwendeten Quelle
5. Ingest darf `cases` nicht anfassen
+84
View File
@@ -0,0 +1,84 @@
# Quellenatlas — Ethik- und Qualitätsregeln
Diese Regeln sind verbindlich. Der Code in `backend/app/core/ethics.py` setzt sie durch. Die UI wiederholt sie als Hinweise, ersetzt sie aber nicht.
## Neutralität
- Keine Werturteile, keine Parteinahme, keine Spekulation.
- Dokumentiert wird, **was welche Quelle feststellt** — nicht, was „wirklich wahr“ ist.
- Summaries und Claims im Indikativ der Quelle: „Der EU-Ombudsmann stellte einen Missstand fest“, nicht „hat illegal gehandelt“.
- Verbotene Stilmittel in Summaries: pauschale Schuld-Adjektive, Ironie, Aufrufe.
## Status
| Status | Bedeutung | Wann setzen |
|---|---|---|
| `allegation` | Vorwurf, journalistische Recherche, unbestätigt | **Default** |
| `ongoing` | Verfahren, Untersuchung oder parlamentarische Befassung läuft | Belegt durch Register/Bericht |
| `confirmed` | Amtliche, gerichtliche oder gleichwertige Feststellung **des konkret behaupteten Sachverhalts** | Siehe unten |
| `refuted` | Amtliche/gerichtliche Entkräftung oder nachweisliche Korrektur | Mit Quelle |
`confirmed` bezieht sich immer auf **einen konkreten Claim**, nicht auf eine Person. Beispiel: bestätigt sein kann „Der Ombudsmann rügte fehlende Transparenz“, nicht „Ursula von der Leyen ist korrupt“.
### `confirmed` — Voraussetzungen
Mindestens eines:
1. Quelle vom Typ `court`, `official_register` oder `parliamentary`, die den Sachverhalt trägt, **oder**
2. Feld `confirmation_basis` mit kurzer, nachvollziehbarer Begründung (z. B. „Rücktritt unter Verweis auf die Vorwürfe“, „Universität hat den Grad entzogen“).
Sonst bleibt der Status `allegation`. Lieber zu konservativ.
## `disputed`
Setzen, wenn belastbare Quellen einander widersprechen. Offene `Conflict`-Datensätze setzen das Flag automatisch. Die UI zeigt einen Banner „Quellenlage widersprüchlich“ und listet beide Seiten mit Reliability-Score. Es gibt keinen automatischen Gewinner.
## Sorgfaltsstufe `high`
Gilt für Entitäten/Fälle mit Cluster `vdl`, `ukraine` oder `eu-top` (und manuell gesetztes `care_level=high`):
- Jede Quelle braucht `accessed_at`
- Ingest erzeugt nur Review-Items, nie Cases
- Review-Queue-Priority `high` bei Keyword-Treffer
- Keine Übernahme von Blogs/Wikis als alleinige Grundlage für `confirmed`
Ursula von der Leyen und Ukraine-Themen sind **keine Schuldzuschreibung per Architektur**, sondern eine höhere Nachweispflicht.
## Quellenmindeststandard
- Jeder Case: ≥ 1 `SourceEntry`
- Jeder Claim: ≥ 1 `SourceEntry`
- Jedes CaseEvent: ≥ 1 `SourceEntry`
- Nachrichtenportale (Reliability 3) dürfen kontextgeben, tragen `confirmed` allein nicht
- Wiki-Quellen (`source_type=wiki`) nie allein, nie für `confirmed`
- Zitat + URL + Zugriffsdatum sind Teil der Zitierfähigkeit
## Ingest
Pipelines dürfen:
- `source_entries` anlegen/aktualisieren
- `review_items` anlegen
- `update_logs` mit `actor_type=pipeline` schreiben
Pipelines dürfen **nicht**:
- Cases, Claims, Promises, Conflicts anlegen oder Status ändern
## Sprache der Einträge
Deutsch als UI- und Summary-Sprache. Originalzitate dürfen in der Quellsprache stehen, dann mit Kennzeichnung. Keine übersetzten Zitate ohne Hinweis.
## Review-Checkliste (Editor)
Vor Speichern eines high-care Falls:
1. Ist jede Aussage einem Claim mit Quelle zugeordnet?
2. Ist `confirmed` wirklich amts- oder gerichtsgetragen — und nur für diesen Satz?
3. Sind Gegenpositionen als `disputed`/Conflict abgebildet?
4. Enthält die Summary keine Wertwörter? (Lint)
5. Sind URLs erreichbar und `accessed_at` gesetzt?
## Was Quellenatlas nicht ist
Kein Register von Schuldigen, keine Denunziationsplattform, kein Leak-Drop. Unbelegte Hinweise gehören nicht in die KB, auch nicht „zur Sicherheit“.
+7
View File
@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json /app/
RUN npm install
COPY . /app
EXPOSE 3000
CMD ["npm", "run", "dev"]
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+1021
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "quellenatlas-web",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000"
},
"dependencies": {
"next": "^15.5.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.8.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { FormEvent, useState } from "react";
import { api } from "@/lib/api";
export default function NewCasePage() {
const [error, setError] = useState("");
const [ok, setOk] = useState("");
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setOk("");
const fd = new FormData(e.currentTarget);
const url = String(fd.get("source_url"));
const payload = {
slug: fd.get("slug"),
title: fd.get("title"),
summary: fd.get("summary"),
time_start: fd.get("time_start"),
status: fd.get("status"),
disputed: fd.get("disputed") === "on",
confirmation_basis: String(fd.get("confirmation_basis") || "") || null,
care_level: fd.get("care_level"),
cluster_tags: String(fd.get("cluster_tags") || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
jurisdiction_slugs: String(fd.get("jurisdictions"))
.split(",")
.map((s) => s.trim())
.filter(Boolean),
category_slugs: String(fd.get("categories"))
.split(",")
.map((s) => s.trim())
.filter(Boolean),
entities: [{ slug: String(fd.get("entity")), role: "subject" }],
sources: [
{
source_slug: fd.get("source_slug"),
url,
title: fd.get("source_title"),
accessed_at: new Date().toISOString(),
},
],
claims: [
{
statement: fd.get("claim"),
status: fd.get("status"),
disputed: fd.get("disputed") === "on",
source_urls: [url],
},
],
};
try {
const created = await api.createCase(payload);
setOk(`Angelegt: ${created.slug}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler");
}
}
return (
<div>
<h1>Fall anlegen</h1>
<p className="muted">
Ethik-Regeln gelten serverseitig: Claim braucht Quelle; confirmed braucht amtliche Quelle oder
Bestätigungsgrundlage; keine Werturteile in der Summary.
</p>
<form onSubmit={onSubmit} className="card" style={{ display: "grid", gap: "0.65rem" }}>
<input name="slug" placeholder="slug" required />
<input name="title" placeholder="Titel" required />
<textarea name="summary" placeholder="Neutrale Zusammenfassung" rows={4} required />
<input name="time_start" type="date" required />
<select name="status" defaultValue="allegation">
<option value="allegation">allegation</option>
<option value="ongoing">ongoing</option>
<option value="confirmed">confirmed</option>
<option value="refuted">refuted</option>
</select>
<label className="muted">
<input name="disputed" type="checkbox" /> disputed
</label>
<input name="confirmation_basis" placeholder="Bestätigungsgrundlage (Pflicht bei confirmed ohne Amtsquelle)" />
<select name="care_level" defaultValue="standard">
<option value="standard">standard</option>
<option value="high">high</option>
</select>
<input name="cluster_tags" placeholder="cluster_tags, kommagetrennt (vdl, ukraine, …)" />
<input name="jurisdictions" placeholder="Jurisdiktion-Slugs, kommagetrennt" required />
<input name="categories" placeholder="Kategorie-Slugs, kommagetrennt" required />
<input name="entity" placeholder="Entitäts-Slug" required />
<input name="source_slug" placeholder="Quellen-Slug aus Katalog" required />
<input name="source_url" placeholder="https://…" required />
<input name="source_title" placeholder="Quellentitel" required />
<textarea name="claim" placeholder="Ein Claim (eine belegte Aussage)" rows={3} required />
<button type="submit">Speichern</button>
{error ? <p className="error">{error}</p> : null}
{ok ? <p>{ok}</p> : null}
</form>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import { api, ReviewItem } from "@/lib/api";
export default function ReviewPage() {
const [rows, setRows] = useState<ReviewItem[]>([]);
const load = () => api.review().then(setRows).catch(() => setRows([]));
useEffect(() => {
load();
}, []);
return (
<div>
<h1>Review-Queue</h1>
<p className="muted">Ingest schreibt keine Fälle. Treffer warten auf menschliche Prüfung.</p>
{rows.map((r) => (
<div className="card" key={r.id}>
<div className="row" style={{ justifyContent: "space-between" }}>
<strong>{r.title}</strong>
<span className={`badge ${r.priority === "high" ? "high" : ""}`}>{r.priority}</span>
</div>
<p className="muted">
<a href={r.url} target="_blank" rel="noreferrer">
{r.url}
</a>
</p>
{r.snippet ? <p>{r.snippet}</p> : null}
<div className="row">
<button className="ghost" onClick={() => api.decideReview(r.id, "accepted").then(load)}>
akzeptieren
</button>
<button className="ghost" onClick={() => api.decideReview(r.id, "rejected").then(load)}>
verwerfen
</button>
</div>
</div>
))}
{!rows.length ? <p className="muted">Queue leer.</p> : null}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { api, Source } from "@/lib/api";
export default function SourcesPage() {
const [rows, setRows] = useState<Source[]>([]);
useEffect(() => {
api.sources().then(setRows).catch(() => setRows([]));
}, []);
return (
<div>
<h1>Quellenkatalog</h1>
<p className="muted">Stammdaten aus data/sources.yaml. Reliability 1–5.</p>
{rows.map((s) => (
<div className="card" key={s.slug}>
<div className="row" style={{ justifyContent: "space-between" }}>
<strong>{s.name}</strong>
<span className="badge">R{s.reliability}</span>
</div>
<p className="muted">
{s.slug} · {s.type} · {s.ingest_method} · {s.status}
</p>
<p className="muted">Fokus: {s.topic_focus.join(", ")}</p>
{s.last_error ? <p className="error">{s.last_error}</p> : null}
</div>
))}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { api, TreeNode } from "@/lib/api";
function Tree({ nodes }: { nodes: TreeNode[] }) {
return (
<ul className="clean tree">
{nodes.map((n) => (
<li key={n.slug}>
<strong>{n.name}</strong> <span className="muted">{n.slug}</span>
{n.children?.length ? <Tree nodes={n.children} /> : null}
</li>
))}
</ul>
);
}
export default function BrowsePage() {
const [j, setJ] = useState<TreeNode[]>([]);
const [c, setC] = useState<TreeNode[]>([]);
useEffect(() => {
api.jurisdictions().then(setJ);
api.categories().then(setC);
}, []);
return (
<div>
<h1>Hierarchie</h1>
<div className="card">
<h2>Jurisdiktionen</h2>
<Tree nodes={j} />
</div>
<div className="card">
<h2>Kategorien</h2>
<Tree nodes={c} />
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { api, CaseDetail } from "@/lib/api";
import { StatusBadge } from "@/components/StatusBadge";
export default function CasePage() {
const { slug } = useParams<{ slug: string }>();
const [data, setData] = useState<CaseDetail | null>(null);
const [md, setMd] = useState("");
useEffect(() => {
api.case(slug).then(setData).catch(() => setData(null));
}, [slug]);
if (!data) return <p className="muted">Lade Fall…</p>;
return (
<div>
<p className="muted">
<Link href="/cases">Fälle</Link> / {data.slug}
</p>
<h1>{data.title}</h1>
<div className="row">
<StatusBadge status={data.status} disputed={data.disputed} />
{data.care_level === "high" ? <span className="badge high">Sorgfalt hoch</span> : null}
<span className="muted">
{data.time_start}
{data.time_end ? ` – ${data.time_end}` : ""}
</span>
</div>
{data.disputed ? (
<div className="banner">Quellenlage widersprüchlich — beide Seiten unten ausgewiesen.</div>
) : null}
<h2>Zusammenfassung</h2>
<p>{data.summary}</p>
{data.confirmation_basis ? (
<>
<h3>Bestätigungsgrundlage</h3>
<p>{data.confirmation_basis}</p>
</>
) : null}
<p className="muted">
Jurisdiktionen: {data.jurisdictions.join(", ") || "—"} · Kategorien:{" "}
{data.categories.join(", ") || "—"}
</p>
<h2>Beteiligte</h2>
<ul className="clean">
{data.entities.map((e) => (
<li key={e.slug}>
<Link href={`/entities/${e.slug}`}>{e.name}</Link>{" "}
<span className="muted">({e.role})</span>
</li>
))}
</ul>
<h2>Claims</h2>
{data.claims.map((c, i) => (
<div className="card" key={c.id}>
<StatusBadge status={c.status} disputed={c.disputed} />
<p>
{i + 1}. {c.statement}
</p>
<ul className="clean">
{c.sources.map((s) => (
<li key={s.id} className="muted">
<a href={s.url} target="_blank" rel="noreferrer">
{s.title}
</a>{" "}
· Zugriff {s.accessed_at.slice(0, 10)} · {s.source_name} (R{s.reliability})
</li>
))}
</ul>
</div>
))}
{data.events.length ? (
<>
<h2>Chronologie</h2>
{data.events.map((ev) => (
<div className="card" key={ev.id}>
<strong>
{ev.occurred_on || "ohne Datum"} — {ev.title}
</strong>
{ev.body ? <p>{ev.body}</p> : null}
</div>
))}
</>
) : null}
{data.consequences ? (
<>
<h2>Konsequenzen</h2>
<p>{data.consequences}</p>
</>
) : null}
<h2>Quellen</h2>
<ol>
{data.sources.map((s) => (
<li key={s.id}>
<a href={s.url} target="_blank" rel="noreferrer">
{s.title}
</a>
<div className="muted">
{s.url} · Zugriff {s.accessed_at.slice(0, 10)} · {s.source_name} ({s.source_type},
Reliability {s.reliability})
</div>
</li>
))}
</ol>
<button
className="ghost"
onClick={async () => {
setMd(await api.exportMd(data.slug));
}}
>
Markdown exportieren
</button>
{md ? (
<pre className="card" style={{ whiteSpace: "pre-wrap" }}>
{md}
</pre>
) : null}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useEffect, useState } from "react";
import { api, CaseList as C } from "@/lib/api";
import { CaseList } from "@/components/CaseList";
export default function CasesPage() {
const [cases, setCases] = useState<C[]>([]);
useEffect(() => {
api.cases().then(setCases).catch(() => setCases([]));
}, []);
return (
<div>
<h1>Fälle</h1>
<CaseList cases={cases} />
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { api, CaseList as C, Entity } from "@/lib/api";
import { CaseList } from "@/components/CaseList";
export default function ClusterPage() {
const { tag } = useParams<{ tag: "vdl" | "ukraine" }>();
const [cases, setCases] = useState<C[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
useEffect(() => {
if (tag !== "vdl" && tag !== "ukraine") return;
api.cluster(tag).then((d) => {
setCases(d.cases);
setEntities(d.entities);
});
}, [tag]);
const title = tag === "vdl" ? "Ursula von der Leyen" : "Ukraine";
return (
<div>
<h1>Cluster: {title}</h1>
<p className="muted">Erhöhte Sorgfalt. Jede Aussage quellengebunden. Keine Schuldzuschreibung per Cluster.</p>
<h2>Entitäten</h2>
<ul className="clean">
{entities.map((e) => (
<li key={e.slug}>
<Link href={`/entities/${e.slug}`}>{e.name}</Link>
</li>
))}
</ul>
<h2>Fälle</h2>
<CaseList cases={cases} />
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { api, CaseList as C, Entity } from "@/lib/api";
import { CaseList } from "@/components/CaseList";
export default function EntityPage() {
const { slug } = useParams<{ slug: string }>();
const [ent, setEnt] = useState<Entity | null>(null);
const [cases, setCases] = useState<C[]>([]);
useEffect(() => {
api.entity(slug).then(setEnt);
api.entityCases(slug).then(setCases);
}, [slug]);
if (!ent) return <p className="muted">Lade Entität…</p>;
return (
<div>
<h1>{ent.name}</h1>
<p className="muted">
{ent.entity_type} · Sorgfalt {ent.care_level}
{ent.aliases.length ? ` · Aliase: ${ent.aliases.join(", ")}` : ""}
</p>
{ent.notes_neutral ? <p>{ent.notes_neutral}</p> : null}
<h2>Fälle</h2>
<CaseList cases={cases} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api, Entity } from "@/lib/api";
export default function EntitiesPage() {
const [rows, setRows] = useState<Entity[]>([]);
useEffect(() => {
api.entities().then(setRows).catch(() => setRows([]));
}, []);
return (
<div>
<h1>Entitäten</h1>
{rows.map((e) => (
<Link key={e.slug} href={`/entities/${e.slug}`} className="card" style={{ display: "block" }}>
<strong>{e.name}</strong>
<div className="muted">
{e.entity_type} {e.care_level === "high" ? "· Sorgfalt hoch" : ""}{" "}
{e.cluster_tags.join(", ")}
</div>
</Link>
))}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
:root {
--bg: #101214;
--bg-elev: #181b21;
--bg-hover: #22262f;
--line: #2c313c;
--text: #ece7dc;
--muted: #9a9386;
--gold: #c4a574;
--confirmed: #7d9a7a;
--allegation: #c9a227;
--ongoing: #7a97b8;
--refuted: #8a8580;
--disputed: #c45c4a;
--danger: #c45c4a;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: "Source Sans 3", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
a { color: var(--gold); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 {
font-family: "Source Serif 4", Georgia, serif;
font-weight: 600;
letter-spacing: -0.02em;
}
.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
.nav {
border-right: 1px solid var(--line);
background: #0d0f12;
padding: 1.4rem 1.1rem;
position: sticky;
top: 0;
height: 100vh;
}
.brand { font-family: "Source Serif 4", Georgia, serif; font-size: 1.35rem; color: var(--text); }
.brand small { display: block; color: var(--muted); font-family: "Source Sans 3", sans-serif; font-size: 0.72rem; margin-top: 0.2rem; letter-spacing: 0.08em; text-transform: uppercase; }
.nav a.item {
display: block;
color: var(--text);
padding: 0.4rem 0.5rem;
border-radius: 6px;
margin: 0.15rem 0;
}
.nav a.item:hover { background: var(--bg-hover); text-decoration: none; }
.nav .sec { color: var(--muted); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; margin: 1.2rem 0 0.4rem; }
.main { padding: 2rem 2.4rem 4rem; max-width: 1100px; }
.card {
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 0.8rem;
}
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.badge {
display: inline-block;
font-size: 0.72rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.badge.confirmed { color: var(--confirmed); border-color: var(--confirmed); }
.badge.allegation { color: var(--allegation); border-color: var(--allegation); }
.badge.ongoing { color: var(--ongoing); border-color: var(--ongoing); }
.badge.refuted { color: var(--refuted); border-color: var(--refuted); }
.badge.disputed { color: var(--disputed); border-color: var(--disputed); }
.badge.high { color: var(--gold); border-color: var(--gold); }
input, textarea, select, button {
font: inherit;
color: var(--text);
}
input, textarea, select {
background: #0f1115;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.55rem 0.7rem;
width: 100%;
}
button, .btn {
background: var(--gold);
color: #16140f;
border: 0;
border-radius: 8px;
padding: 0.55rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
button.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.muted { color: var(--muted); }
.banner {
border: 1px solid var(--disputed);
color: #f0c7c0;
background: #2a1513;
padding: 0.7rem 0.9rem;
border-radius: 8px;
margin: 0.8rem 0;
}
ul.clean { list-style: none; padding: 0; margin: 0; }
ul.clean li { margin: 0.35rem 0; }
.tree { margin-left: 0.8rem; border-left: 1px solid var(--line); padding-left: 0.8rem; }
.error { color: var(--danger); }
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
import { Shell } from "@/components/Shell";
export const metadata: Metadata = {
title: "Quellenatlas",
description: "Private, quellenbasierte Knowledge Base",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;600&family=Source+Serif+4:opsz,wght@8..60,600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<Shell>{children}</Shell>
</body>
</html>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { FormEvent, useState } from "react";
import { api } from "@/lib/api";
export default function LoginPage() {
const [error, setError] = useState("");
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
try {
await api.login(String(fd.get("username")), String(fd.get("password")));
window.location.href = "/";
} catch (err) {
setError(err instanceof Error ? err.message : "Login fehlgeschlagen");
}
}
return (
<div style={{ maxWidth: 380, margin: "12vh auto" }}>
<h1>Quellenatlas</h1>
<p className="muted">Privates Recherche-System. Login erforderlich.</p>
<form onSubmit={onSubmit} className="card" style={{ display: "grid", gap: "0.7rem" }}>
<input name="username" placeholder="Benutzer" required defaultValue="admin" />
<input name="password" type="password" placeholder="Passwort" required />
<button type="submit">Anmelden</button>
{error ? <p className="error">{error}</p> : null}
</form>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api, CaseList as C, Entity, ReviewItem } from "@/lib/api";
import { CaseList } from "@/components/CaseList";
export default function Home() {
const [vdl, setVdl] = useState<C[]>([]);
const [ua, setUa] = useState<C[]>([]);
const [review, setReview] = useState<ReviewItem[]>([]);
const [ents, setEnts] = useState<Entity[]>([]);
const [error, setError] = useState("");
useEffect(() => {
Promise.all([api.cluster("vdl"), api.cluster("ukraine"), api.review(), api.entities()])
.then(([a, b, r, e]) => {
setVdl(a.cases);
setUa(b.cases);
setReview(r);
setEnts(e.filter((x) => x.care_level === "high").slice(0, 8));
})
.catch((err) => setError(String(err.message || err)));
}, []);
return (
<div>
<h1>Übersicht</h1>
<p className="muted">
Nur belegte Aussagen. Status immer sichtbar. Cluster mit erhöhter Sorgfalt: von der Leyen,
Ukraine, EU-Spitzen.
</p>
{error ? <p className="error">{error}</p> : null}
<div className="row" style={{ margin: "1rem 0" }}>
<Link className="btn" href="/clusters/vdl">
von der Leyen ({vdl.length})
</Link>
<Link className="btn" href="/clusters/ukraine">
Ukraine ({ua.length})
</Link>
<Link className="btn ghost" href="/admin/review">
Queue ({review.length})
</Link>
</div>
<h2>Priorisierte Entitäten</h2>
<ul className="clean">
{ents.map((e) => (
<li key={e.slug}>
<Link href={`/entities/${e.slug}`}>{e.name}</Link>{" "}
<span className="muted">({e.entity_type})</span>
</li>
))}
</ul>
<h2>Cluster von der Leyen</h2>
<CaseList cases={vdl.slice(0, 5)} />
<h2>Cluster Ukraine</h2>
<CaseList cases={ua.slice(0, 5)} />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { StatusBadge } from "@/components/StatusBadge";
type P = {
slug: string;
text: string;
status: string;
entity_slug?: string;
promised_on?: string;
};
export default function PromisesPage() {
const [rows, setRows] = useState<P[]>([]);
useEffect(() => {
api.promises().then((d) => setRows(d as P[])).catch(() => setRows([]));
}, []);
return (
<div>
<h1>Politische Zusagen</h1>
{rows.map((p) => (
<div className="card" key={p.slug}>
<StatusBadge status={p.status} />
<p>{p.text}</p>
<p className="muted">
{p.entity_slug} · {p.promised_on}
</p>
</div>
))}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { FormEvent, useState } from "react";
import Link from "next/link";
import { api, CaseList as C, Entity } from "@/lib/api";
import { CaseList } from "@/components/CaseList";
export default function SearchPage() {
const [q, setQ] = useState("");
const [cases, setCases] = useState<C[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
if (q.trim().length < 2) return;
const res = await api.search(q.trim());
setCases(res.cases);
setEntities(res.entities);
}
return (
<div>
<h1>Suche</h1>
<form onSubmit={onSubmit} className="row" style={{ marginBottom: "1rem" }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, Titel, Stichwort" />
<button type="submit">Suchen</button>
</form>
<h2>Entitäten</h2>
<ul className="clean">
{entities.map((e) => (
<li key={e.slug}>
<Link href={`/entities/${e.slug}`}>{e.name}</Link>
</li>
))}
</ul>
<h2>Fälle</h2>
<CaseList cases={cases} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
import type { CaseList as CaseListType } from "@/lib/api";
import { StatusBadge } from "./StatusBadge";
export function CaseList({ cases }: { cases: CaseListType[] }) {
if (!cases.length) return <p className="muted">Keine Fälle.</p>;
return (
<div>
{cases.map((c) => (
<Link key={c.id} href={`/cases/${c.slug}`} className="card" style={{ display: "block" }}>
<div className="row" style={{ justifyContent: "space-between" }}>
<strong>{c.title}</strong>
<StatusBadge status={c.status} disputed={c.disputed} />
</div>
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
{c.time_start}
{c.time_end ? ` – ${c.time_end}` : ""}
{c.cluster_tags?.length ? ` · ${c.cluster_tags.join(", ")}` : ""}
{c.care_level === "high" ? " · Sorgfalt hoch" : ""}
</p>
</Link>
))}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const items = [
{ href: "/", label: "Übersicht" },
{ href: "/search", label: "Suche" },
{ href: "/clusters/vdl", label: "Cluster: von der Leyen" },
{ href: "/clusters/ukraine", label: "Cluster: Ukraine" },
{ href: "/cases", label: "Fälle" },
{ href: "/entities", label: "Entitäten" },
{ href: "/browse", label: "Hierarchie" },
{ href: "/promises", label: "Zusagen" },
{ href: "/admin/review", label: "Review-Queue" },
{ href: "/admin/sources", label: "Quellen" },
{ href: "/admin/cases/new", label: "Fall anlegen" },
];
export function Nav() {
const path = usePathname();
if (path === "/login") return null;
return (
<aside className="nav">
<Link href="/" className="brand">
Quellenatlas
<small>Quellenbasiert · privat</small>
</Link>
<div className="sec">Navigation</div>
{items.map((item) => (
<Link key={item.href} href={item.href} className="item">
{item.label}
</Link>
))}
</aside>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { usePathname } from "next/navigation";
import { Nav } from "./Nav";
export function Shell({ children }: { children: React.ReactNode }) {
const path = usePathname();
if (path === "/login") {
return <main className="main" style={{ maxWidth: "none" }}>{children}</main>;
}
return (
<div className="shell">
<Nav />
<main className="main">{children}</main>
</div>
);
}
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more