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:
commit
872f836a47
103 files changed
+8444
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()],
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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])
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 = ["."]
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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: []
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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“.
|
||||
@@ -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"]
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1021
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user