`python -m ingest.cli fetch|load|sync` now exists — README, PORTING.md, the systemd
example and pyproject's console script have all been referencing it.
This replaces six scripts that the rename had left genuinely broken: the speech
loader's INSERT listed `year, year` as two columns, and both loaders read the source
JSON using plenum's column names rather than the Riksdag's field names, so a sync
would have written empty rows without erroring. Rewriting on top of the adapter was
safer than repairing them, because the adapter makes the source-name/column-name
boundary explicit rather than leaving it to be remembered.
Two source quirks found by running it over real archives rather than reasoning about
it: a few records carry a bare string where a child element belongs, which took the
whole load down on an AttributeError; and party codes appear in both cases, so
`parties` held {S,s} and any filter on 'S' would have silently missed half the
documents. Both are handled in the adapter, where source messiness belongs.
Also adds doc_type to schema.sql. It was only in the migration, so a fresh install
lacked a column the loader writes.
Verified end to end against real archives: 400 speeches and 400 documents ingested
into a clean database, producing 3,742 authors and 2,229 proposals, with search
vectors built by the triggers and full-text queries returning hits.
Post-load steps (debate ids, embeddings, summaries) still run separately via
scripts/debates.py and scripts/make_embeddings.py; wiring them into `sync` is
tracked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
parent
bef343f4ab
commit
e2b71c64b1
15 changed files with 309 additions and 1029 deletions
@ -0,0 +1,78 @@ |
|||||||
|
"""Command-line entry point for ingesting a parliament's open data. |
||||||
|
|
||||||
|
python -m ingest.cli fetch --source documents --range 2022-2025 |
||||||
|
python -m ingest.cli load --source documents |
||||||
|
python -m ingest.cli sync --source all |
||||||
|
|
||||||
|
`fetch` downloads, `load` adapts and upserts what is on disk, `sync` does both. |
||||||
|
Everything is resumable: archives already unpacked are skipped, and inserts use |
||||||
|
ON CONFLICT DO NOTHING, so re-running after an interruption is safe. |
||||||
|
""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import argparse |
||||||
|
import sys |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
||||||
|
|
||||||
|
from ingest import pipeline # noqa: E402 |
||||||
|
from ingest.adapters import load_adapter # noqa: E402 |
||||||
|
from parliament import PARLIAMENT # noqa: E402 |
||||||
|
|
||||||
|
SOURCES = ("speeches", "documents", "people") |
||||||
|
|
||||||
|
|
||||||
|
def _adapter(): |
||||||
|
module_path = PARLIAMENT.sources.get("adapter") |
||||||
|
if not module_path: |
||||||
|
raise SystemExit("parliament.yaml has no `sources.adapter`. See docs/PORTING.md.") |
||||||
|
return load_adapter(module_path) |
||||||
|
|
||||||
|
|
||||||
|
def _resolve(source: str) -> list[str]: |
||||||
|
if source == "all": |
||||||
|
return [s for s in SOURCES if s in PARLIAMENT.sources] |
||||||
|
if source not in SOURCES: |
||||||
|
raise SystemExit(f"Unknown source {source!r}; expected one of {SOURCES + ('all',)}") |
||||||
|
return [source] |
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int: |
||||||
|
parser = argparse.ArgumentParser(prog="ingest", description=__doc__) |
||||||
|
parser.add_argument("command", choices=("fetch", "load", "sync")) |
||||||
|
parser.add_argument("--source", default="all", |
||||||
|
help=f"one of {', '.join(SOURCES)}, or 'all' (default)") |
||||||
|
parser.add_argument("--range", dest="ranges", action="append", |
||||||
|
help="archive range to fetch, e.g. 2022-2025. Repeatable. " |
||||||
|
"Defaults to the ranges in parliament.yaml.") |
||||||
|
parser.add_argument("--limit", type=int, |
||||||
|
help="stop after this many records — useful for a smoke test") |
||||||
|
args = parser.parse_args(argv) |
||||||
|
|
||||||
|
adapter = _adapter() |
||||||
|
print(f"parliament: {PARLIAMENT.meta.get('name')} adapter: {PARLIAMENT.sources['adapter']}") |
||||||
|
|
||||||
|
exit_code = 0 |
||||||
|
for source in _resolve(args.source): |
||||||
|
print(f"\n── {source} ──") |
||||||
|
try: |
||||||
|
if args.command in ("fetch", "sync"): |
||||||
|
pipeline.fetch(source, args.ranges) |
||||||
|
if args.command in ("load", "sync"): |
||||||
|
counts = pipeline.load(source, adapter.adapt, limit=args.limit) |
||||||
|
print(f" read {counts['read']}, wrote {counts['written']}, " |
||||||
|
f"skipped {counts['skipped']}") |
||||||
|
except Exception as exc: |
||||||
|
# One failing source should not abandon the others; a daily sync that |
||||||
|
# aborts because one archive moved is worse than a partial one. |
||||||
|
print(f" FAILED: {type(exc).__name__}: {exc}") |
||||||
|
exit_code = 1 |
||||||
|
|
||||||
|
if args.command in ("load", "sync"): |
||||||
|
print("\nNext: python scripts/make_embeddings.py") |
||||||
|
return exit_code |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
sys.exit(main()) |
||||||
@ -0,0 +1,209 @@ |
|||||||
|
"""Generic fetch → adapt → upsert pipeline. |
||||||
|
|
||||||
|
Country-neutral: every source-specific decision comes from `sources:` in |
||||||
|
parliament.yaml and from the adapter named there. |
||||||
|
""" |
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import io |
||||||
|
import json |
||||||
|
import zipfile |
||||||
|
from pathlib import Path |
||||||
|
from typing import Callable, Iterator, Optional |
||||||
|
|
||||||
|
import requests |
||||||
|
|
||||||
|
from bootstrap import DATA_DIR |
||||||
|
from parliament import PARLIAMENT |
||||||
|
from postgres_client import pg |
||||||
|
|
||||||
|
# Rows per INSERT. Large enough that round-trips are not the bottleneck, small |
||||||
|
# enough that one bad batch is cheap to retry. |
||||||
|
BATCH = 500 |
||||||
|
|
||||||
|
|
||||||
|
# ── fetching ────────────────────────────────────────────────────────────────── |
||||||
|
|
||||||
|
|
||||||
|
def source_config(name: str) -> dict: |
||||||
|
sources = PARLIAMENT.sources |
||||||
|
if name not in sources: |
||||||
|
available = [k for k in sources if k != "adapter"] |
||||||
|
raise ValueError(f"No source {name!r} in parliament.yaml; have {available}") |
||||||
|
return sources[name] |
||||||
|
|
||||||
|
|
||||||
|
def dest_dir(name: str) -> Path: |
||||||
|
return DATA_DIR / source_config(name).get("dest_dir", name) |
||||||
|
|
||||||
|
|
||||||
|
def fetch(name: str, ranges: Optional[list[str]] = None) -> list[Path]: |
||||||
|
"""Download a source's bulk archives and unpack them under DATA_DIR. |
||||||
|
|
||||||
|
Archives already unpacked are skipped, so this is safe to re-run and cheap to |
||||||
|
resume after an interruption — which matters when a full download is tens of GB. |
||||||
|
""" |
||||||
|
cfg = source_config(name) |
||||||
|
kind = cfg.get("kind", "zip-dataset") |
||||||
|
target = dest_dir(name) |
||||||
|
target.mkdir(parents=True, exist_ok=True) |
||||||
|
|
||||||
|
if kind == "json": |
||||||
|
out = target / f"{name}.json" |
||||||
|
out.write_bytes(requests.get(cfg["url"], timeout=120).content) |
||||||
|
print(f" fetched {out.relative_to(DATA_DIR)}") |
||||||
|
return [out] |
||||||
|
|
||||||
|
if kind != "zip-dataset": |
||||||
|
raise ValueError(f"Unsupported source kind {kind!r} for {name}") |
||||||
|
|
||||||
|
wanted = ranges or cfg.get("ranges") |
||||||
|
if not wanted: |
||||||
|
raise ValueError( |
||||||
|
f"Source {name!r} has no `ranges:` in parliament.yaml and none was given. " |
||||||
|
f"Pass --range, or list them in the config." |
||||||
|
) |
||||||
|
|
||||||
|
written = [] |
||||||
|
for rng in wanted: |
||||||
|
folder = target / rng |
||||||
|
if folder.exists() and any(folder.iterdir()): |
||||||
|
print(f" {rng}: already present, skipping") |
||||||
|
continue |
||||||
|
url = cfg["url_template"].format(range=rng) |
||||||
|
print(f" {rng}: downloading {url}") |
||||||
|
resp = requests.get(url, timeout=1800) |
||||||
|
if resp.status_code != 200: |
||||||
|
print(f" {rng}: HTTP {resp.status_code}, skipping") |
||||||
|
continue |
||||||
|
folder.mkdir(parents=True, exist_ok=True) |
||||||
|
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf: |
||||||
|
zf.extractall(folder) |
||||||
|
written.append(folder) |
||||||
|
print(f" {rng}: unpacked {len(list(folder.rglob('*.json')))} files") |
||||||
|
return written |
||||||
|
|
||||||
|
|
||||||
|
def read_records(name: str) -> Iterator[dict]: |
||||||
|
"""Yield every source record on disk for a source.""" |
||||||
|
root = dest_dir(name) |
||||||
|
if not root.exists(): |
||||||
|
raise FileNotFoundError(f"No data at {root}. Run `ingest.cli fetch --source {name}` first.") |
||||||
|
for path in sorted(root.rglob("*.json")): |
||||||
|
try: |
||||||
|
# utf-8-sig: the archives carry a byte-order mark. |
||||||
|
with open(path, encoding="utf-8-sig") as fh: |
||||||
|
payload = json.load(fh) |
||||||
|
except (json.JSONDecodeError, OSError) as exc: |
||||||
|
print(f" skipping {path.name}: {exc}") |
||||||
|
continue |
||||||
|
# A people listing is one file holding many records. |
||||||
|
if isinstance(payload, dict) and "personlista" in payload: |
||||||
|
for person in payload["personlista"].get("person", []): |
||||||
|
yield person |
||||||
|
else: |
||||||
|
yield payload |
||||||
|
|
||||||
|
|
||||||
|
# ── loading ─────────────────────────────────────────────────────────────────── |
||||||
|
|
||||||
|
|
||||||
|
def _insert(table: str, columns: list[str], rows: list[tuple], conflict: str) -> int: |
||||||
|
if not rows: |
||||||
|
return 0 |
||||||
|
sql = ( |
||||||
|
f"INSERT INTO {table} ({', '.join(columns)}) VALUES %s " |
||||||
|
f"ON CONFLICT ({conflict}) DO NOTHING" |
||||||
|
) |
||||||
|
pg.execute_values(sql, rows) |
||||||
|
return len(rows) |
||||||
|
|
||||||
|
|
||||||
|
def _flush(table: str, columns: list[str], conflict: str, buffer: list[dict]) -> int: |
||||||
|
"""Insert and clear a buffer. Argument order matches `_target()` unpacking.""" |
||||||
|
rows = [tuple(r.get(c) for c in columns) for r in buffer] |
||||||
|
n = _insert(table, columns, rows, conflict) |
||||||
|
buffer.clear() |
||||||
|
return n |
||||||
|
|
||||||
|
|
||||||
|
SPEECH_COLUMNS = [ |
||||||
|
"id", "source_speech_id", "source_doc_id", "text", "section_title", "sequence", |
||||||
|
"activity_type", "speaker_name", "party", "person_id", "date", "source_datetime", |
||||||
|
"year", "session_year", "related_doc_id", "source_doc_number", "source_record_id", |
||||||
|
"title", "is_reply", |
||||||
|
] |
||||||
|
DOCUMENT_COLUMNS = [ |
||||||
|
"doc_id", "doc_type", "source_record_id", "session_label", "designation", "subtype", |
||||||
|
"committee", "status", "date", "source_updated_at", "published_at", "session_year", |
||||||
|
"title", "subtitle", "text", "proposals_text", "has_text", "url_text", "url_html", |
||||||
|
"url_pdf", "parties", "author_names", "proposals_raw", "attachments", "num_proposals", |
||||||
|
] |
||||||
|
AUTHOR_COLUMNS = ["doc_id", "ordinal", "person_id", "name", "party", "role"] |
||||||
|
PROPOSAL_COLUMNS = [ |
||||||
|
"id", "doc_id", "ordinal", "number", "text", |
||||||
|
"committee_recommendation", "chamber_decision", "handled_in", |
||||||
|
] |
||||||
|
PERSON_COLUMNS = [ |
||||||
|
"person_id", "source_record_id", "source_record_guid", "source_id", "birth_year", |
||||||
|
"gender", "last_name", "first_name", "sort_name", "home_town", "party", |
||||||
|
"constituency", "status", "source_url", "image_url_small", "image_url_medium", |
||||||
|
"image_url_large", "assignments", "contact_details", "name", "active", |
||||||
|
] |
||||||
|
|
||||||
|
|
||||||
|
def _json_columns(rows: list[dict], columns: Iterator[str]) -> None: |
||||||
|
"""psycopg2 cannot adapt dicts/lists destined for JSONB; serialise them.""" |
||||||
|
for row in rows: |
||||||
|
for col in columns: |
||||||
|
if isinstance(row.get(col), (dict, list)): |
||||||
|
row[col] = json.dumps(row[col], ensure_ascii=False) |
||||||
|
|
||||||
|
|
||||||
|
def load(name: str, adapt: Callable[[str, dict], Optional[dict]], limit: Optional[int] = None) -> dict: |
||||||
|
"""Adapt every record on disk for a source and upsert it.""" |
||||||
|
counts = {"read": 0, "written": 0, "skipped": 0} |
||||||
|
buf: list[dict] = [] |
||||||
|
authors: list[dict] = [] |
||||||
|
proposals: list[dict] = [] |
||||||
|
|
||||||
|
for payload in read_records(name): |
||||||
|
if limit and counts["read"] >= limit: |
||||||
|
break |
||||||
|
counts["read"] += 1 |
||||||
|
row = adapt(name, payload) |
||||||
|
if row is None: |
||||||
|
counts["skipped"] += 1 |
||||||
|
continue |
||||||
|
|
||||||
|
if name == "documents": |
||||||
|
_json_columns([row["document"]], ("proposals_raw", "attachments")) |
||||||
|
buf.append(row["document"]) |
||||||
|
authors.extend(row["authors"]) |
||||||
|
proposals.extend(row["proposals"]) |
||||||
|
else: |
||||||
|
if name == "people": |
||||||
|
_json_columns([row], ("assignments", "contact_details")) |
||||||
|
buf.append(row) |
||||||
|
|
||||||
|
if len(buf) >= BATCH: |
||||||
|
counts["written"] += _flush(*_target(name), buf) |
||||||
|
if authors: |
||||||
|
_flush("document_authors", AUTHOR_COLUMNS, "doc_id, ordinal", authors) |
||||||
|
if proposals: |
||||||
|
_flush("document_proposals", PROPOSAL_COLUMNS, "id", proposals) |
||||||
|
|
||||||
|
counts["written"] += _flush(*_target(name), buf) |
||||||
|
if authors: |
||||||
|
_flush("document_authors", AUTHOR_COLUMNS, "doc_id, ordinal", authors) |
||||||
|
if proposals: |
||||||
|
_flush("document_proposals", PROPOSAL_COLUMNS, "id", proposals) |
||||||
|
return counts |
||||||
|
|
||||||
|
|
||||||
|
def _target(name: str) -> tuple[str, list[str], str]: |
||||||
|
return { |
||||||
|
"speeches": ("speeches", SPEECH_COLUMNS, "id"), |
||||||
|
"documents": ("documents", DOCUMENT_COLUMNS, "doc_id"), |
||||||
|
"people": ("people", PERSON_COLUMNS, "person_id"), |
||||||
|
}[name] |
||||||
@ -1,29 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
# One-time backfill of motioner 1990→idag. |
|
||||||
# |
|
||||||
# Kör i tre steg (alla resumérbara — avbrott är ofarligt, kör bara om): |
|
||||||
# 1. Ladda ned alla mot-arkiv som saknas (hoppar över ifyllda mappar) |
|
||||||
# 2. Parsa alla mappar i motioner/ till Postgres (ON CONFLICT DO NOTHING) |
|
||||||
# 3. Bygg embeddings år för år, nyast först |
|
||||||
# |
|
||||||
# Starta frikopplat: nohup scripts/backfill_motions.sh > backfill_motions.log 2>&1 & |
|
||||||
set -e |
|
||||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
|
||||||
PY=.venv/bin/python |
|
||||||
|
|
||||||
echo "=== Step 1: download all ranges ===" |
|
||||||
$PY scripts/download_motions.py |
|
||||||
|
|
||||||
echo "=== Step 2: parse all folders into Postgres ===" |
|
||||||
$PY scripts/motions_to_postgres.py |
|
||||||
|
|
||||||
echo "=== Step 3: full-text embeddings, newest year first ===" |
|
||||||
for year in $(seq 2025 -1 1990); do |
|
||||||
echo "--- embeddings for year $year ---" |
|
||||||
$PY scripts/make_embeddings.py motions "$year" |
|
||||||
done |
|
||||||
|
|
||||||
echo "=== Step 4: yrkande (condensed proposal) embeddings ===" |
|
||||||
$PY scripts/make_embeddings.py yrkanden |
|
||||||
|
|
||||||
echo "=== Backfill complete ===" |
|
||||||
@ -1,167 +0,0 @@ |
|||||||
""" |
|
||||||
Läser in anföranden från JSON-filer till PostgreSQL. |
|
||||||
|
|
||||||
Ersätter scripts/documents_to_arango.py. |
|
||||||
|
|
||||||
Används av sync_talks.py (update_folder) och kan köras direkt för att |
|
||||||
(om)ladda alla mappar i speeches/: |
|
||||||
|
|
||||||
python scripts/documents_to_postgres.py |
|
||||||
""" |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
import json |
|
||||||
import logging |
|
||||||
import os |
|
||||||
import re |
|
||||||
import sys |
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
||||||
import bootstrap # noqa: E402,F401 — sets cwd and sys.path to the project root |
|
||||||
|
|
||||||
from postgres_client import pg |
|
||||||
|
|
||||||
logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s") |
|
||||||
|
|
||||||
|
|
||||||
def clean_text(text: str) -> str: |
|
||||||
if text is None: |
|
||||||
return "" |
|
||||||
text = text.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n") |
|
||||||
text = text.replace("</p>", "\n") |
|
||||||
text = re.sub(r"\n+", "\n", text) |
|
||||||
text = text.strip() |
|
||||||
text = re.sub(r"<.*?>", "", text) |
|
||||||
return text |
|
||||||
|
|
||||||
|
|
||||||
def clean_speaker_name(name: str) -> str: |
|
||||||
if name is None: |
|
||||||
return "" |
|
||||||
name = name.strip() |
|
||||||
name = re.sub(r"\s*\(.*?\)\s*$", "", name) |
|
||||||
return name.strip() |
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(s: str) -> str | None: |
|
||||||
if not s: |
|
||||||
return None |
|
||||||
return str(s).split(" ")[0][:10] or None |
|
||||||
|
|
||||||
|
|
||||||
def process_folder(folder_path: str, already_processed: set[str] = frozenset()) -> list[dict]: |
|
||||||
"""Parse JSON files in folder_path, return list of talk dicts (skipping known IDs).""" |
|
||||||
docs = [] |
|
||||||
for file in os.listdir(folder_path): |
|
||||||
if not file.endswith(".json"): |
|
||||||
continue |
|
||||||
try: |
|
||||||
with open(os.path.join(folder_path, file), "r", encoding="utf-8-sig") as f: |
|
||||||
data = json.load(f) |
|
||||||
doc = data["anforande"] |
|
||||||
source_doc_id = doc.get("dok_id", "") |
|
||||||
sequence = doc.get("sequence", "") |
|
||||||
speech_id = f"{source_doc_id}-{sequence}" # unique per speech, e.g. "GH09116-16" |
|
||||||
if speech_id in already_processed: |
|
||||||
continue |
|
||||||
doc["year"] = int(doc.get("dok_rm", "0000")[:4]) |
|
||||||
doc.pop("dok_rm", None) |
|
||||||
doc["text"] = clean_text(doc.get("text", "")) |
|
||||||
doc["speaker_name"] = clean_speaker_name(doc.get("speaker_name", "")) |
|
||||||
doc["id"] = speech_id |
|
||||||
doc["dok_id"] = source_doc_id |
|
||||||
doc["source_speech_id"] = doc.get("source_speech_id", "") # UUID, kept for reference |
|
||||||
doc["date"] = _parse_date(doc.get("source_datetime", "")) |
|
||||||
doc["source_datetime"] = doc.get("source_datetime", "") |
|
||||||
doc["title"] = doc.get("dok_titel", "") |
|
||||||
doc.pop("dok_titel", None) |
|
||||||
doc["sequence"] = int(sequence) if sequence else 0 |
|
||||||
doc["source_record_id"] = doc.get("dok_hangar_id", "") |
|
||||||
doc.pop("dok_hangar_id", None) |
|
||||||
doc["is_reply"] = doc.get("is_reply", "N") == "Y" |
|
||||||
doc.pop("source_updated_at", None) |
|
||||||
doc.pop("underrubrik", None) |
|
||||||
year = doc.get("year") or (int(doc["date"][:4]) if doc.get("date") else None) |
|
||||||
doc["year"] = year |
|
||||||
docs.append(doc) |
|
||||||
except (json.JSONDecodeError, KeyError, ValueError) as e: |
|
||||||
logging.warning(f"Skipping {file}: {e}") |
|
||||||
return docs |
|
||||||
|
|
||||||
|
|
||||||
_UPSERT_SQL = """ |
|
||||||
INSERT INTO speeches ( |
|
||||||
id, source_speech_id, source_doc_id, |
|
||||||
text, section_title, |
|
||||||
sequence, activity_type, |
|
||||||
speaker_name, party, person_id, |
|
||||||
date, source_datetime, year, year, |
|
||||||
related_doc_id, source_doc_number, source_record_id, title, |
|
||||||
is_reply |
|
||||||
) VALUES %s |
|
||||||
ON CONFLICT (id) DO NOTHING |
|
||||||
""" |
|
||||||
|
|
||||||
|
|
||||||
def _doc_to_row(doc: dict) -> tuple: |
|
||||||
return ( |
|
||||||
doc.get("id"), # source_speech_id UUID — primary key |
|
||||||
doc.get("source_speech_id"), # same value, kept for reference |
|
||||||
doc.get("dok_id"), # debate/protocol document id |
|
||||||
doc.get("text"), |
|
||||||
doc.get("section_title"), |
|
||||||
doc.get("sequence"), |
|
||||||
doc.get("activity_type"), |
|
||||||
doc.get("speaker_name"), |
|
||||||
doc.get("party"), |
|
||||||
doc.get("person_id"), |
|
||||||
doc.get("date"), |
|
||||||
doc.get("source_datetime"), |
|
||||||
doc.get("year"), |
|
||||||
doc.get("year"), |
|
||||||
doc.get("related_doc_id"), |
|
||||||
doc.get("source_doc_number"), |
|
||||||
doc.get("source_record_id"), |
|
||||||
doc.get("title"), |
|
||||||
doc.get("is_reply", False), |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
def insert_docs(docs: list[dict]) -> None: |
|
||||||
if not docs: |
|
||||||
return |
|
||||||
rows = [_doc_to_row(d) for d in docs if d.get("id")] |
|
||||||
if rows: |
|
||||||
pg.execute_values(_UPSERT_SQL, rows) |
|
||||||
|
|
||||||
|
|
||||||
def update_folder(path: str, already_processed: set[str] = None) -> int: |
|
||||||
""" |
|
||||||
Upsert talk documents from JSON files in path into PostgreSQL. |
|
||||||
Returns the number of new speeches inserted. |
|
||||||
""" |
|
||||||
if already_processed is None: |
|
||||||
rows = pg.execute("SELECT id FROM speeches") |
|
||||||
already_processed = {row["id"] for row in rows} |
|
||||||
|
|
||||||
docs = process_folder(path, already_processed) |
|
||||||
insert_docs(docs) |
|
||||||
return len(docs) |
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
# Load all folders in speeches/ into PostgreSQL |
|
||||||
existing = {row["id"] for row in pg.execute("SELECT id FROM speeches")} |
|
||||||
total = 0 |
|
||||||
for folder in sorted(os.listdir("speeches")): |
|
||||||
path = str(bootstrap.DATA_DIR / 'speeches' / folder) |
|
||||||
if not os.path.isdir(path): |
|
||||||
continue |
|
||||||
print(f"Processing {folder} …", end=" ", flush=True) |
|
||||||
docs = process_folder(path, already_processed=existing) |
|
||||||
insert_docs(docs) |
|
||||||
new_ids = {d["id"] for d in docs if d.get("id")} |
|
||||||
existing |= new_ids |
|
||||||
total += len(new_ids) |
|
||||||
print(f"{len(new_ids)} inserted") |
|
||||||
print(f"\nTotal: {total} new speeches inserted") |
|
||||||
@ -1,91 +0,0 @@ |
|||||||
""" |
|
||||||
Laddar ned motioner från riksdagens öppna data som ZIP-arkiv med JSON-filer. |
|
||||||
|
|
||||||
Arkiven är uppdelade i fyraårsperioder (förankrade vid 1998; allt före ligger |
|
||||||
i 1990-1997) och uppdateras dagligen: |
|
||||||
|
|
||||||
https://data.riksdagen.se/dataset/dokument/mot-{range}.json.zip |
|
||||||
|
|
||||||
Varje arkiv innehåller en JSON-fil per motion (dokumentstatus-kuvert med |
|
||||||
metadata + fulltext som HTML). Extraheras till motioner/mot-{range}/. |
|
||||||
|
|
||||||
Backfill (alla perioder 1990→): python scripts/download_motions.py |
|
||||||
Används av sync_motions.py för daglig uppdatering av aktuell year. |
|
||||||
""" |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
import logging |
|
||||||
import os |
|
||||||
import sys |
|
||||||
from io import BytesIO |
|
||||||
from urllib.request import urlopen |
|
||||||
from zipfile import ZipFile |
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
||||||
import bootstrap # noqa: E402,F401 — sets cwd and sys.path to the project root |
|
||||||
|
|
||||||
logging.basicConfig( |
|
||||||
level=logging.INFO, |
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s", |
|
||||||
) |
|
||||||
logger = logging.getLogger(__name__) |
|
||||||
|
|
||||||
RANGES = [ |
|
||||||
"1990-1997", |
|
||||||
"1998-2001", |
|
||||||
"2002-2005", |
|
||||||
"2006-2009", |
|
||||||
"2010-2013", |
|
||||||
"2014-2017", |
|
||||||
"2018-2021", |
|
||||||
"2022-2025", |
|
||||||
] |
|
||||||
URL_TEMPLATE = "https://data.riksdagen.se/dataset/dokument/mot-{r}.json.zip" |
|
||||||
MOTIONS_DIR = "motioner" |
|
||||||
|
|
||||||
|
|
||||||
def get_current_range(year: int) -> str: |
|
||||||
"""Fyraårsperiod förankrad vid 1998; allt före 1998 ligger i 1990-1997.""" |
|
||||||
if year < 1998: |
|
||||||
return "1990-1997" |
|
||||||
start = year - ((year - 1998) % 4) |
|
||||||
return f"{start}-{start + 3}" |
|
||||||
|
|
||||||
|
|
||||||
def download_range(r: str, force: bool = False) -> str: |
|
||||||
""" |
|
||||||
Laddar ned och extraherar arkivet för en year till motioner/mot-{r}/. |
|
||||||
Hoppar över om mappen redan är ifylld, om inte force=True (töms först). |
|
||||||
""" |
|
||||||
dir_path = os.path.join(MOTIONS_DIR, f"mot-{r}") |
|
||||||
|
|
||||||
if os.path.exists(dir_path) and os.listdir(dir_path): |
|
||||||
if not force: |
|
||||||
logger.info(f"Skipping {dir_path}, already exists and is not empty.") |
|
||||||
return dir_path |
|
||||||
for f in os.listdir(dir_path): |
|
||||||
os.remove(os.path.join(dir_path, f)) |
|
||||||
|
|
||||||
os.makedirs(dir_path, exist_ok=True) |
|
||||||
url = URL_TEMPLATE.format(r=r) |
|
||||||
logger.info(f"Downloading {url} → {dir_path}") |
|
||||||
|
|
||||||
with urlopen(url) as resp: |
|
||||||
with ZipFile(BytesIO(resp.read())) as zf: |
|
||||||
zf.extractall(dir_path) |
|
||||||
|
|
||||||
count = len(os.listdir(dir_path)) |
|
||||||
logger.info(f"Extracted {count} files to {dir_path}") |
|
||||||
return dir_path |
|
||||||
|
|
||||||
|
|
||||||
def download_all(force: bool = False) -> None: |
|
||||||
for r in RANGES: |
|
||||||
download_range(r, force=force) |
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
if len(sys.argv) > 1: |
|
||||||
download_range(sys.argv[1], force="--force" in sys.argv) |
|
||||||
else: |
|
||||||
download_all() |
|
||||||
@ -1,63 +0,0 @@ |
|||||||
import requests |
|
||||||
from bs4 import BeautifulSoup |
|
||||||
from io import BytesIO |
|
||||||
from urllib.request import urlopen |
|
||||||
from zipfile import ZipFile |
|
||||||
import os |
|
||||||
from time import sleep |
|
||||||
|
|
||||||
|
|
||||||
def download(all=False, year=None): |
|
||||||
if all: |
|
||||||
for year in range(1999, 2026): |
|
||||||
first_part = str(year) |
|
||||||
second_part = str(year + 1)[2:] |
|
||||||
if first_part == '1999': |
|
||||||
url = 'https://data.riksdagen.se/dataset/anforande/anforande-19992000.json.zip' |
|
||||||
else: |
|
||||||
url = f"https://data.riksdagen.se/dataset/anforande/anforande-{first_part}{second_part}.json.zip" |
|
||||||
print(url) |
|
||||||
|
|
||||||
# Ensure the 'speeches' directory exists |
|
||||||
talks_dir = "speeches" |
|
||||||
os.makedirs(talks_dir, exist_ok=True) |
|
||||||
|
|
||||||
# Create a subdirectory for the current year range |
|
||||||
dir_name = os.path.join(talks_dir, f"anforande-20{first_part}{second_part}") |
|
||||||
if os.path.exists(dir_name) and os.listdir(dir_name): |
|
||||||
print(f"Skipping {dir_name}, already exists and is not empty.") |
|
||||||
continue |
|
||||||
|
|
||||||
os.makedirs(dir_name, exist_ok=True) |
|
||||||
|
|
||||||
# Download and extract the zip file directly into the subdirectory |
|
||||||
with urlopen(url) as zipresp: |
|
||||||
with ZipFile(BytesIO(zipresp.read())) as zfile: |
|
||||||
zfile.extractall(dir_name) |
|
||||||
elif year: |
|
||||||
first_part = str(year) |
|
||||||
second_part = str(year + 1)[2:] |
|
||||||
url = f"https://data.riksdagen.se/dataset/anforande/anforande-{first_part}{second_part}.json.zip" |
|
||||||
print(url) |
|
||||||
|
|
||||||
# Ensure the 'speeches' directory exists |
|
||||||
talks_dir = "speeches" |
|
||||||
os.makedirs(talks_dir, exist_ok=True) |
|
||||||
|
|
||||||
# Create a subdirectory for the current year range |
|
||||||
dir_name = os.path.join(talks_dir, f"anforande-20{first_part}{second_part}") |
|
||||||
if os.path.exists(dir_name) and os.listdir(dir_name): |
|
||||||
print(f"Skipping {dir_name}, already exists and is not empty.") |
|
||||||
return |
|
||||||
|
|
||||||
os.makedirs(dir_name, exist_ok=True) |
|
||||||
|
|
||||||
# Download and extract the zip file directly into the subdirectory |
|
||||||
with urlopen(url) as zipresp: |
|
||||||
with ZipFile(BytesIO(zipresp.read())) as zfile: |
|
||||||
zfile.extractall(dir_name) |
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
while True: |
|
||||||
new_files = download() |
|
||||||
|
|
||||||
@ -1,182 +0,0 @@ |
|||||||
""" |
|
||||||
Fix speeches.is_reply by reading the ground-truth value from the JSON source files. |
|
||||||
|
|
||||||
Background: |
|
||||||
documents_to_postgres.py uses ON CONFLICT DO NOTHING, so speeches ingested before |
|
||||||
is_reply was handled correctly were never updated. This script does the corrective |
|
||||||
pass: reads every JSON file, finds speeches where the stored is_reply differs from the |
|
||||||
file, updates the DB, then re-assigns debate IDs for the affected dates so that |
|
||||||
proper multi-speaker debates are created. |
|
||||||
|
|
||||||
Steps: |
|
||||||
1. Scan all speeches/ subfolders and build {speech_id → replik_bool} from JSON files. |
|
||||||
2. Query DB for speeches where is_reply is currently False. |
|
||||||
3. Update any talk whose JSON says True. |
|
||||||
4. For each affected date, clear speeches.debate and re-run make_debate_ids(). |
|
||||||
5. Delete stale single-talk debate rows whose IDs no longer exist in speeches.debate. |
|
||||||
|
|
||||||
Usage: |
|
||||||
python scripts/fix_replik_from_json.py |
|
||||||
""" |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
import logging |
|
||||||
import os |
|
||||||
import sys |
|
||||||
from collections import defaultdict |
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
||||||
import bootstrap # noqa: E402,F401 — sets cwd and sys.path to the project root |
|
||||||
|
|
||||||
from postgres_client import pg |
|
||||||
from scripts.documents_to_postgres import process_folder |
|
||||||
from scripts.debates import assign_debate_ids |
|
||||||
|
|
||||||
logging.basicConfig( |
|
||||||
level=logging.INFO, |
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s", |
|
||||||
) |
|
||||||
logger = logging.getLogger(__name__) |
|
||||||
|
|
||||||
TALKS_DIR = str(bootstrap.DATA_DIR / "speeches") |
|
||||||
BATCH_SIZE = 500 |
|
||||||
|
|
||||||
|
|
||||||
def build_replik_map() -> dict[str, bool]: |
|
||||||
"""Return {speech_id: is_reply} for every JSON file in speeches/.""" |
|
||||||
replik_map: dict[str, bool] = {} |
|
||||||
folders = sorted(os.listdir(TALKS_DIR)) |
|
||||||
for i, folder in enumerate(folders): |
|
||||||
path = os.path.join(TALKS_DIR, folder) |
|
||||||
if not os.path.isdir(path): |
|
||||||
continue |
|
||||||
docs = process_folder(path) |
|
||||||
for doc in docs: |
|
||||||
tid = doc.get("id") |
|
||||||
if tid: |
|
||||||
replik_map[tid] = bool(doc.get("is_reply", False)) |
|
||||||
if (i + 1) % 5 == 0 or (i + 1) == len(folders): |
|
||||||
logger.info(f" Scanned {i+1}/{len(folders)} folders ({len(replik_map):,} speeches)") |
|
||||||
return replik_map |
|
||||||
|
|
||||||
|
|
||||||
def fix_replik(replik_map: dict[str, bool]) -> set[str]: |
|
||||||
""" |
|
||||||
Update speeches where DB is_reply=False but JSON says True. |
|
||||||
Returns the set of affected talk IDs. |
|
||||||
""" |
|
||||||
# Only need to check speeches where DB currently says False |
|
||||||
db_false = pg.execute("SELECT id FROM speeches WHERE is_reply = false") |
|
||||||
db_false_ids = {row["id"] for row in db_false} |
|
||||||
logger.info(f"Talks with is_reply=False in DB: {len(db_false_ids):,}") |
|
||||||
|
|
||||||
to_fix = [tid for tid in db_false_ids if replik_map.get(tid) is True] |
|
||||||
logger.info(f"Talks to correct (JSON says True, DB says False): {len(to_fix):,}") |
|
||||||
|
|
||||||
if not to_fix: |
|
||||||
return set() |
|
||||||
|
|
||||||
for i in range(0, len(to_fix), BATCH_SIZE): |
|
||||||
batch = to_fix[i : i + BATCH_SIZE] |
|
||||||
pg.execute_many( |
|
||||||
"UPDATE speeches SET is_reply = true WHERE id = %s", |
|
||||||
[(tid,) for tid in batch], |
|
||||||
) |
|
||||||
if (i + BATCH_SIZE) % 5000 == 0 or i + BATCH_SIZE >= len(to_fix): |
|
||||||
logger.info(f" Updated {min(i+BATCH_SIZE, len(to_fix)):,}/{len(to_fix):,}") |
|
||||||
|
|
||||||
return set(to_fix) |
|
||||||
|
|
||||||
|
|
||||||
def get_affected_dates(fixed_ids: set[str]) -> list[str]: |
|
||||||
"""Return distinct dates for the speeches whose is_reply was corrected.""" |
|
||||||
if not fixed_ids: |
|
||||||
return [] |
|
||||||
rows = pg.execute( |
|
||||||
"SELECT DISTINCT date::text AS date FROM speeches WHERE id = ANY(%s::text[])", |
|
||||||
(list(fixed_ids),), |
|
||||||
) |
|
||||||
return [row["date"] for row in rows if row.get("date")] |
|
||||||
|
|
||||||
|
|
||||||
def reassign_debate_ids(dates: list[str]) -> None: |
|
||||||
""" |
|
||||||
For each date, clear speeches.debate and re-assign using assign_debate_ids(). |
|
||||||
This is the same logic as make_debate_ids() but scoped to the given dates. |
|
||||||
""" |
|
||||||
logger.info(f"Re-assigning debate IDs for {len(dates):,} dates …") |
|
||||||
for i, date in enumerate(sorted(dates)): |
|
||||||
# Clear existing debate assignments for this date |
|
||||||
pg.execute_void( |
|
||||||
"UPDATE speeches SET debate = NULL WHERE date = %s::date", |
|
||||||
(date,), |
|
||||||
) |
|
||||||
|
|
||||||
speeches = pg.execute( |
|
||||||
""" |
|
||||||
SELECT id, is_reply |
|
||||||
FROM speeches |
|
||||||
WHERE date = %s::date |
|
||||||
ORDER BY sequence ASC |
|
||||||
""", |
|
||||||
(date,), |
|
||||||
) |
|
||||||
if not speeches: |
|
||||||
continue |
|
||||||
|
|
||||||
updated = assign_debate_ids(list(speeches), date) |
|
||||||
pg.execute_many( |
|
||||||
"UPDATE speeches SET debate = %s WHERE id = %s", |
|
||||||
[(doc["debate"], doc["id"]) for doc in updated], |
|
||||||
) |
|
||||||
|
|
||||||
if (i + 1) % 100 == 0 or (i + 1) == len(dates): |
|
||||||
logger.info(f" {i+1}/{len(dates)} dates processed") |
|
||||||
|
|
||||||
|
|
||||||
def remove_stale_debate_rows() -> int: |
|
||||||
""" |
|
||||||
Delete debate rows whose debate ID no longer appears in speeches.debate. |
|
||||||
This cleans up the old one-per-talk debate rows that became invalid after |
|
||||||
debate IDs were re-assigned. |
|
||||||
""" |
|
||||||
result = pg.execute( |
|
||||||
""" |
|
||||||
DELETE FROM debates |
|
||||||
WHERE debate NOT IN (SELECT DISTINCT debate FROM speeches WHERE debate IS NOT NULL) |
|
||||||
RETURNING debate |
|
||||||
""" |
|
||||||
) |
|
||||||
count = len(result) if result else 0 |
|
||||||
logger.info(f"Removed {count:,} stale debate rows") |
|
||||||
return count |
|
||||||
|
|
||||||
|
|
||||||
def main() -> None: |
|
||||||
logger.info("=== Step 1: Build is_reply map from JSON files ===") |
|
||||||
replik_map = build_replik_map() |
|
||||||
logger.info(f"Total speeches in JSON files: {len(replik_map):,}") |
|
||||||
|
|
||||||
logger.info("=== Step 2: Fix is_reply in DB ===") |
|
||||||
fixed_ids = fix_replik(replik_map) |
|
||||||
if not fixed_ids: |
|
||||||
logger.info("Nothing to fix — is_reply is already correct.") |
|
||||||
return |
|
||||||
|
|
||||||
logger.info("=== Step 3: Find affected dates ===") |
|
||||||
dates = get_affected_dates(fixed_ids) |
|
||||||
logger.info(f"Affected dates: {len(dates):,}") |
|
||||||
|
|
||||||
logger.info("=== Step 4: Re-assign debate IDs ===") |
|
||||||
reassign_debate_ids(dates) |
|
||||||
|
|
||||||
logger.info("=== Step 5: Remove stale debate rows ===") |
|
||||||
remove_stale_debate_rows() |
|
||||||
|
|
||||||
logger.info("=== Done! ===") |
|
||||||
logger.info("Next: run 'python scripts/debates.py' (or deploy_debate_embeddings.sh)") |
|
||||||
logger.info("to generate summaries for the newly grouped multi-talk debates.") |
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
main() |
|
||||||
@ -1,29 +0,0 @@ |
|||||||
#!/bin/bash |
|
||||||
# Progress of the motion embedding backfills. Run anytime: |
|
||||||
# bash scripts/motions_progress.sh |
|
||||||
set -e |
|
||||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
|
||||||
export PGPASSWORD=$(grep '^PG_PASSWORD=' .env | cut -d= -f2) |
|
||||||
PGDB=$(grep '^PG_DB=' .env | cut -d= -f2) |
|
||||||
PGUSER=$(grep '^PG_USER=' .env | cut -d= -f2) |
|
||||||
PGHOST=$(grep '^PG_HOST=' .env | cut -d= -f2) |
|
||||||
|
|
||||||
echo "=== Motion embedding coverage ($(date '+%H:%M:%S')) ===" |
|
||||||
psql -h "$PGHOST" -U "$PGUSER" -d "$PGDB" -tA -F $'\t' <<'SQL' | column -t -s $'\t' |
|
||||||
SELECT 'full-text chunks (motions)' AS what, |
|
||||||
count(*) FILTER (WHERE c.id IS NOT NULL) AS done, |
|
||||||
count(*) AS total, |
|
||||||
round(100.0*count(*) FILTER (WHERE c.id IS NOT NULL)/nullif(count(*),0),1)||'%' AS pct |
|
||||||
FROM (SELECT dok_id FROM motions WHERE has_text) m |
|
||||||
LEFT JOIN LATERAL (SELECT 1 AS id FROM motion_chunks c WHERE c.motion_id = m.dok_id LIMIT 1) c ON true |
|
||||||
UNION ALL |
|
||||||
SELECT 'yrkande embeddings', |
|
||||||
count(*) FILTER (WHERE embedding IS NOT NULL), |
|
||||||
count(*), |
|
||||||
round(100.0*count(*) FILTER (WHERE embedding IS NOT NULL)/nullif(count(*),0),1)||'%' |
|
||||||
FROM motion_yrkanden; |
|
||||||
SQL |
|
||||||
|
|
||||||
echo |
|
||||||
echo "=== Backfill processes ===" |
|
||||||
pgrep -af "make_embeddings.py|backfill_motions.sh" | grep -v pgrep || echo " (none running — backfills finished)" |
|
||||||
@ -1,268 +0,0 @@ |
|||||||
""" |
|
||||||
Läser in motioner från JSON-filer (dokumentstatus-kuvert) till PostgreSQL. |
|
||||||
|
|
||||||
Används av sync_motions.py (update_folder) och kan köras direkt för att |
|
||||||
(om)ladda alla mappar i motioner/: |
|
||||||
|
|
||||||
python scripts/motions_to_postgres.py |
|
||||||
""" |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
import json |
|
||||||
import logging |
|
||||||
import os |
|
||||||
import sys |
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
||||||
import bootstrap # noqa: E402,F401 — sets cwd and sys.path to the project root |
|
||||||
|
|
||||||
from bs4 import BeautifulSoup |
|
||||||
from postgres_client import pg |
|
||||||
from scripts.documents_to_postgres import _parse_date, clean_speaker_name |
|
||||||
|
|
||||||
logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s") |
|
||||||
|
|
||||||
# Rader kortare än så räknas som textlösa (inskannad PDF med stubb-HTML) |
|
||||||
MIN_TEXT_LEN = 200 |
|
||||||
|
|
||||||
|
|
||||||
def _as_list(v) -> list: |
|
||||||
"""Riksdagens XML→JSON: ett barn blir dict, flera blir list, inget blir None.""" |
|
||||||
if v is None: |
|
||||||
return [] |
|
||||||
return v if isinstance(v, list) else [v] |
|
||||||
|
|
||||||
|
|
||||||
def _clean(v): |
|
||||||
"""API:et serialiserar null som strängen 'None'.""" |
|
||||||
if v is None or v == "None": |
|
||||||
return None |
|
||||||
return v |
|
||||||
|
|
||||||
|
|
||||||
def html_to_text(html: str) -> str: |
|
||||||
"""Extraherar ren text ur dokumentets html-fält (inleds med stort <style>-block).""" |
|
||||||
if not html: |
|
||||||
return "" |
|
||||||
soup = BeautifulSoup(html, "html.parser") |
|
||||||
for tag in soup(["style", "script"]): |
|
||||||
tag.decompose() |
|
||||||
text = soup.get_text("\n") |
|
||||||
lines = [line.strip() for line in text.split("\n")] |
|
||||||
out = [] |
|
||||||
for line in lines: |
|
||||||
if line: |
|
||||||
out.append(line) |
|
||||||
elif out and out[-1] != "": |
|
||||||
out.append("") |
|
||||||
return "\n".join(out).strip() |
|
||||||
|
|
||||||
|
|
||||||
def parse_file(path: str) -> dict | None: |
|
||||||
"""Parsar en documents-JSON till en dict med rader för documents + document_authors.""" |
|
||||||
with open(path, "r", encoding="utf-8-sig") as f: |
|
||||||
data = json.load(f) |
|
||||||
ds = data["dokumentstatus"] |
|
||||||
dok = ds["dokument"] |
|
||||||
|
|
||||||
doc_id = _clean(dok.get("doc_id")) |
|
||||||
if not doc_id: |
|
||||||
return None |
|
||||||
if _clean(dok.get("doktyp")) not in (None, "mot"): |
|
||||||
return None # defensivt; mot-arkiven ska bara innehålla motioner |
|
||||||
|
|
||||||
session_label = _clean(dok.get("session_label")) or "" |
|
||||||
try: |
|
||||||
year = int(session_label[:4]) |
|
||||||
except (ValueError, TypeError): |
|
||||||
year = None |
|
||||||
|
|
||||||
text = html_to_text(_clean(dok.get("html")) or "") |
|
||||||
has_text = len(text) >= MIN_TEXT_LEN |
|
||||||
|
|
||||||
authors = [] |
|
||||||
parties: list[str] = [] |
|
||||||
author_names: list[str] = [] |
|
||||||
intressenter = _as_list((ds.get("dokintressent") or {}).get("intressent")) |
|
||||||
for i, x in enumerate(intressenter): |
|
||||||
if not isinstance(x, dict): |
|
||||||
continue |
|
||||||
name = clean_speaker_name(_clean(x.get("name")) or "") |
|
||||||
party = _clean(x.get("party")) |
|
||||||
authors.append( |
|
||||||
(doc_id, i, _clean(x.get("person_id")), name, party, _clean(x.get("role"))) |
|
||||||
) |
|
||||||
if name: |
|
||||||
author_names.append(name) |
|
||||||
if party and party not in parties: |
|
||||||
parties.append(party) |
|
||||||
|
|
||||||
proposals_raw = _as_list((ds.get("dokforslag") or {}).get("proposals_raw")) |
|
||||||
# Condensed, high-signal yrkanden: one row per <proposals_raw> for FTS + embeddings. |
|
||||||
yrkanden = [] |
|
||||||
lydelser = [] |
|
||||||
for i, f in enumerate(proposals_raw): |
|
||||||
if not isinstance(f, dict): |
|
||||||
continue |
|
||||||
text = _clean(f.get("text")) |
|
||||||
if not text: |
|
||||||
continue |
|
||||||
lydelser.append(text) |
|
||||||
yrkanden.append(( |
|
||||||
f"{doc_id}:{i}", doc_id, i, _clean(f.get("number")), text, |
|
||||||
_clean(f.get("committee_recommendation")), _clean(f.get("chamber_decision")), _clean(f.get("handled_in")), |
|
||||||
)) |
|
||||||
proposals_text = " ".join(lydelser) or None |
|
||||||
|
|
||||||
attachments = _as_list((ds.get("dokbilaga") or {}).get("bilaga")) |
|
||||||
url_pdf = None |
|
||||||
for b in attachments: |
|
||||||
if isinstance(b, dict) and (b.get("filtyp") or "").lower() == "pdf": |
|
||||||
url_pdf = _clean(b.get("fil_url")) |
|
||||||
break |
|
||||||
|
|
||||||
return { |
|
||||||
"doc_id": doc_id, |
|
||||||
"source_record_id": _clean(dok.get("source_record_id")), |
|
||||||
"session_label": session_label or None, |
|
||||||
"designation": _clean(dok.get("designation")), |
|
||||||
"subtype": _clean(dok.get("subtype")), |
|
||||||
"committee": _clean(dok.get("committee")), |
|
||||||
"status": _clean(dok.get("status")), |
|
||||||
"date": _parse_date(_clean(dok.get("date")) or ""), |
|
||||||
"source_updated_at": _clean(dok.get("source_updated_at")), |
|
||||||
"published_at": _clean(dok.get("published_at")), |
|
||||||
"year": year, |
|
||||||
"title": _clean(dok.get("title")), |
|
||||||
"subtitle": _clean(dok.get("subtitel")) or _clean(dok.get("subtitle")), |
|
||||||
"text": text, |
|
||||||
"proposals_text": proposals_text, |
|
||||||
"has_text": has_text, |
|
||||||
"url_text": _clean(dok.get("url_text")), |
|
||||||
"url_html": _clean(dok.get("url_html")), |
|
||||||
"url_pdf": url_pdf, |
|
||||||
"parties": parties, |
|
||||||
"author_names": author_names, |
|
||||||
"proposals_raw": json.dumps(proposals_raw, ensure_ascii=False) if proposals_raw else None, |
|
||||||
"attachments": json.dumps(attachments, ensure_ascii=False) if attachments else None, |
|
||||||
"num_proposals": len(proposals_raw), |
|
||||||
"authors": authors, |
|
||||||
"yrkanden": yrkanden, |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
def process_folder(folder_path: str, already_processed: set[str] = frozenset()) -> list[dict]: |
|
||||||
"""Parsar JSON-filer i folder_path, returnerar documents-dicts (hoppar över kända ID:n).""" |
|
||||||
docs = [] |
|
||||||
for file in os.listdir(folder_path): |
|
||||||
if not file.endswith(".json"): |
|
||||||
continue |
|
||||||
# Filnamnet är doc_id (gemener) — hoppa över kända utan att parsa |
|
||||||
if file[:-5].upper() in already_processed: |
|
||||||
continue |
|
||||||
try: |
|
||||||
doc = parse_file(os.path.join(folder_path, file)) |
|
||||||
if doc is None or doc["doc_id"] in already_processed: |
|
||||||
continue |
|
||||||
docs.append(doc) |
|
||||||
except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e: |
|
||||||
logging.warning(f"Skipping {file}: {e}") |
|
||||||
return docs |
|
||||||
|
|
||||||
|
|
||||||
_UPSERT_SQL = """ |
|
||||||
INSERT INTO documents ( |
|
||||||
doc_id, source_record_id, session_label, designation, subtype, committee, status, |
|
||||||
date, source_updated_at, published_at, session_year, |
|
||||||
title, subtitle, text, proposals_text, has_text, |
|
||||||
url_text, url_html, url_pdf, |
|
||||||
parties, author_names, proposals_raw, attachments, num_proposals |
|
||||||
) VALUES %s |
|
||||||
ON CONFLICT (doc_id) DO NOTHING |
|
||||||
""" |
|
||||||
# Framtida förbättring: DO UPDATE ... WHERE EXCLUDED.source_updated_at > documents.source_updated_at |
|
||||||
# för att plocka upp utskottsutfall på redan inlästa motioner. |
|
||||||
|
|
||||||
_AUTHORS_SQL = """ |
|
||||||
INSERT INTO document_authors (doc_id, ordinal, person_id, name, party, role) |
|
||||||
VALUES %s |
|
||||||
ON CONFLICT (doc_id, ordinal) DO NOTHING |
|
||||||
""" |
|
||||||
|
|
||||||
_YRKANDEN_SQL = """ |
|
||||||
INSERT INTO document_proposals (id, doc_id, ordinal, number, text, committee_recommendation, chamber_decision, handled_in) |
|
||||||
VALUES %s |
|
||||||
ON CONFLICT (id) DO NOTHING |
|
||||||
""" |
|
||||||
|
|
||||||
|
|
||||||
def _doc_to_row(doc: dict) -> tuple: |
|
||||||
return ( |
|
||||||
doc["doc_id"], |
|
||||||
doc["source_record_id"], |
|
||||||
doc["session_label"], |
|
||||||
doc["designation"], |
|
||||||
doc["subtype"], |
|
||||||
doc["committee"], |
|
||||||
doc["status"], |
|
||||||
doc["date"], |
|
||||||
doc["source_updated_at"], |
|
||||||
doc["published_at"], |
|
||||||
doc["year"], |
|
||||||
doc["title"], |
|
||||||
doc["subtitle"], |
|
||||||
doc["text"], |
|
||||||
doc["proposals_text"], |
|
||||||
doc["has_text"], |
|
||||||
doc["url_text"], |
|
||||||
doc["url_html"], |
|
||||||
doc["url_pdf"], |
|
||||||
doc["parties"], |
|
||||||
doc["author_names"], |
|
||||||
doc["proposals_raw"], |
|
||||||
doc["attachments"], |
|
||||||
doc["num_proposals"], |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
def insert_docs(docs: list[dict]) -> None: |
|
||||||
if not docs: |
|
||||||
return |
|
||||||
rows = [_doc_to_row(d) for d in docs] |
|
||||||
pg.execute_values(_UPSERT_SQL, rows) |
|
||||||
author_rows = [row for d in docs for row in d["authors"]] |
|
||||||
if author_rows: |
|
||||||
pg.execute_values(_AUTHORS_SQL, author_rows) |
|
||||||
yrkande_rows = [row for d in docs for row in d["yrkanden"]] |
|
||||||
if yrkande_rows: |
|
||||||
pg.execute_values(_YRKANDEN_SQL, yrkande_rows) |
|
||||||
|
|
||||||
|
|
||||||
def update_folder(path: str, already_processed: set[str] = None) -> int: |
|
||||||
""" |
|
||||||
Upsertar motioner från JSON-filer i path till PostgreSQL. |
|
||||||
Returnerar antalet nya motioner. |
|
||||||
""" |
|
||||||
if already_processed is None: |
|
||||||
rows = pg.execute("SELECT doc_id FROM documents") |
|
||||||
already_processed = {row["doc_id"] for row in rows} |
|
||||||
|
|
||||||
docs = process_folder(path, already_processed) |
|
||||||
insert_docs(docs) |
|
||||||
return len(docs) |
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
existing = {row["doc_id"] for row in pg.execute("SELECT doc_id FROM documents")} |
|
||||||
total = 0 |
|
||||||
for folder in sorted(os.listdir("motioner")): |
|
||||||
path = str(bootstrap.DATA_DIR / 'motioner' / folder) |
|
||||||
if not os.path.isdir(path): |
|
||||||
continue |
|
||||||
print(f"Processing {folder} …", end=" ", flush=True) |
|
||||||
docs = process_folder(path, already_processed=existing) |
|
||||||
insert_docs(docs) |
|
||||||
existing |= {d["doc_id"] for d in docs} |
|
||||||
total += len(docs) |
|
||||||
print(f"{len(docs)} inserted") |
|
||||||
print(f"\nTotal: {total} new documents inserted") |
|
||||||
@ -1,62 +0,0 @@ |
|||||||
""" |
|
||||||
Synkroniserar nya motioner från riksdagen.se till PostgreSQL-databasen. |
|
||||||
|
|
||||||
Pipeline (körs dagligen via systemd timer, se etc/riksdagen-documents-sync.*): |
|
||||||
1. Ladda ned arkivet för aktuell fyraårsperiod (ersätter tidigare nerladdning) |
|
||||||
2. Infoga nya motioner i PostgreSQL (hoppar över redan existerande via ON CONFLICT) |
|
||||||
3. Bygg embeddings för motioner som saknar speech_chunks |
|
||||||
|
|
||||||
Alternativ till steg 1 om den dagliga nerladdningen (~50-110 MB) blir ett problem: |
|
||||||
dokumentlista-API:et (https://data.riksdagen.se/dokumentlista/?doktyp=mot&sort=source_updated_at |
|
||||||
&sortorder=desc&utformat=json) kan pagineras tills redan inlästa doc_id påträffas. |
|
||||||
|
|
||||||
Kör manuellt: python scripts/sync_motions.py |
|
||||||
""" |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
import logging |
|
||||||
import os |
|
||||||
import sys |
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
||||||
import bootstrap # noqa: E402,F401 — sets cwd and sys.path to the project root |
|
||||||
|
|
||||||
logging.basicConfig( |
|
||||||
level=logging.INFO, |
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s", |
|
||||||
) |
|
||||||
logger = logging.getLogger(__name__) |
|
||||||
|
|
||||||
|
|
||||||
def sync() -> None: |
|
||||||
"""Kör hela sync-pipelinen för motioner.""" |
|
||||||
logger.info("=== Starting daily documents sync ===") |
|
||||||
|
|
||||||
# --- Steg 1: Ladda ned aktuell year --- |
|
||||||
from scripts.download_motions import download_range, get_current_range |
|
||||||
from scripts.sync_talks import get_current_session_year |
|
||||||
|
|
||||||
current_range = get_current_range(get_current_session_year()) |
|
||||||
logger.info(f"Current range: {current_range}") |
|
||||||
dir_path = download_range(current_range, force=True) |
|
||||||
|
|
||||||
# --- Steg 2: Infoga nya motioner --- |
|
||||||
logger.info("Stage 2: Inserting new documents into PostgreSQL...") |
|
||||||
from scripts.motions_to_postgres import update_folder |
|
||||||
|
|
||||||
new_motions = update_folder(os.path.abspath(dir_path)) |
|
||||||
logger.info(f"Stage 2 complete: {new_motions} new documents inserted") |
|
||||||
|
|
||||||
# --- Steg 3: Chunk + bygg embeddings (fulltext + yrkanden) --- |
|
||||||
logger.info("Stage 3: Chunking and embedding new documents...") |
|
||||||
from scripts.make_embeddings import make_motion_embeddings, make_yrkande_embeddings |
|
||||||
|
|
||||||
total_chunks = make_motion_embeddings() |
|
||||||
total_yrkanden = make_yrkande_embeddings() |
|
||||||
logger.info(f"Stage 3 complete: {total_chunks} speech_chunks + {total_yrkanden} yrkanden embedded") |
|
||||||
|
|
||||||
logger.info("=== Motions sync complete ===") |
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
sync() |
|
||||||
@ -1,130 +0,0 @@ |
|||||||
""" |
|
||||||
Synkroniserar nya anföranden från riksdagen.se till PostgreSQL-databasen. |
|
||||||
|
|
||||||
Ersätter den ArangoDB-baserade versionen. |
|
||||||
|
|
||||||
Pipeline (körs dagligen via systemd timer): |
|
||||||
1. Ladda ned årets anföranden från riksdagen.se (ersätter tidigare nerladdning) |
|
||||||
2. Infoga nya anföranden i PostgreSQL (hoppar över redan existerande via ON CONFLICT) |
|
||||||
3. Tilldela debatt-ID:n till anföranden som saknar det |
|
||||||
4. Bygg embeddings för anföranden som saknar speech_chunks |
|
||||||
5. Generera sammanfattningar för date som saknar summary |
|
||||||
|
|
||||||
Kör manuellt: python scripts/sync_talks.py |
|
||||||
""" |
|
||||||
from pathlib import Path |
|
||||||
|
|
||||||
import logging |
|
||||||
import os |
|
||||||
import sys |
|
||||||
from datetime import datetime |
|
||||||
from io import BytesIO |
|
||||||
from urllib.request import urlopen |
|
||||||
from zipfile import ZipFile |
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
|
||||||
import bootstrap # noqa: E402,F401 — sets cwd and sys.path to the project root |
|
||||||
|
|
||||||
from postgres_client import pg |
|
||||||
|
|
||||||
logging.basicConfig( |
|
||||||
level=logging.INFO, |
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s", |
|
||||||
) |
|
||||||
logger = logging.getLogger(__name__) |
|
||||||
|
|
||||||
SYSTEM_MESSAGE = """Din uppgift är att sammanfatta debatter i Sveriges riksdag. |
|
||||||
Du kommer först att få enskilda tal som du ska sammanfatta var för sig, efter det ska du sammanfatta hela debatten. |
|
||||||
Sammanfattningarna ska vara på svenska och vara koncisa och informativa. |
|
||||||
Det är viktigt att du förstår vad som är kärnan i varje tal och debatt, fokusera därför på de argument och sakförhållanden som framförs. |
|
||||||
""" |
|
||||||
|
|
||||||
|
|
||||||
def get_current_session_year() -> int: |
|
||||||
""" |
|
||||||
Returnerar startåret för aktuell riksdagssession. |
|
||||||
Riksdagssessionen löper september–augusti. |
|
||||||
""" |
|
||||||
now = datetime.now() |
|
||||||
return now.year if now.month >= 9 else now.year - 1 |
|
||||||
|
|
||||||
|
|
||||||
def download_current_year(year: int) -> str: |
|
||||||
"""Laddar ned och extraherar ZIP-arkivet för angiven riksdagssession.""" |
|
||||||
second_part = str(year + 1)[2:] |
|
||||||
url = f"https://data.riksdagen.se/dataset/anforande/anforande-{year}{second_part}.json.zip" |
|
||||||
folder_name = f"anforande-{year}{second_part}" |
|
||||||
dir_path = os.path.join("speeches", folder_name) |
|
||||||
|
|
||||||
logger.info(f"Downloading {url} → {dir_path}") |
|
||||||
os.makedirs(dir_path, exist_ok=True) |
|
||||||
|
|
||||||
for f in os.listdir(dir_path): |
|
||||||
os.remove(os.path.join(dir_path, f)) |
|
||||||
|
|
||||||
with urlopen(url) as resp: |
|
||||||
with ZipFile(BytesIO(resp.read())) as zf: |
|
||||||
zf.extractall(dir_path) |
|
||||||
|
|
||||||
count = len(os.listdir(dir_path)) |
|
||||||
logger.info(f"Extracted {count} files to {dir_path}") |
|
||||||
return dir_path |
|
||||||
|
|
||||||
|
|
||||||
def get_unsummarized_dates() -> list[str]: |
|
||||||
"""Hämtar date som har anföranden utan sammanfattning.""" |
|
||||||
rows = pg.execute( |
|
||||||
"SELECT DISTINCT date::text AS date FROM speeches WHERE summary IS NULL ORDER BY date" |
|
||||||
) |
|
||||||
dates = sorted(row["date"] for row in rows if row.get("date")) |
|
||||||
logger.info(f"Found {len(dates)} dates with unsummarized speeches") |
|
||||||
return dates |
|
||||||
|
|
||||||
|
|
||||||
def sync() -> None: |
|
||||||
"""Kör hela sync-pipelinen.""" |
|
||||||
logger.info("=== Starting daily riksdagen sync ===") |
|
||||||
|
|
||||||
# --- Steg 1: Ladda ned --- |
|
||||||
year = get_current_session_year() |
|
||||||
logger.info(f"Current session year: {year}/{year + 1}") |
|
||||||
dir_path = download_current_year(year) |
|
||||||
|
|
||||||
# --- Steg 2: Infoga nya anföranden --- |
|
||||||
logger.info("Stage 2: Inserting new speeches into PostgreSQL...") |
|
||||||
from scripts.documents_to_postgres import update_folder |
|
||||||
|
|
||||||
new_talks = update_folder(os.path.abspath(dir_path)) |
|
||||||
logger.info(f"Stage 2 complete: {new_talks} new speeches inserted") |
|
||||||
|
|
||||||
# --- Steg 3: Tilldela debatt-ID:n --- |
|
||||||
logger.info("Stage 3: Assigning debate IDs to speeches missing them...") |
|
||||||
from scripts.debates import make_debate_ids |
|
||||||
|
|
||||||
make_debate_ids() |
|
||||||
logger.info("Stage 3 complete") |
|
||||||
|
|
||||||
# --- Steg 4: Chunk + bygg embeddings --- |
|
||||||
logger.info("Stage 4: Chunking and embedding new speeches...") |
|
||||||
from scripts.make_embeddings import make_embeddings |
|
||||||
|
|
||||||
total_chunks = make_embeddings() |
|
||||||
logger.info(f"Stage 4 complete: {total_chunks} speech_chunks created") |
|
||||||
|
|
||||||
# --- Steg 5: Generera sammanfattningar --- |
|
||||||
new_dates = get_unsummarized_dates() |
|
||||||
if new_dates: |
|
||||||
logger.info(f"Stage 5: Generating summaries for {len(new_dates)} dates...") |
|
||||||
from scripts.debates import process_debate_date |
|
||||||
|
|
||||||
for date in new_dates: |
|
||||||
process_debate_date(date, SYSTEM_MESSAGE) |
|
||||||
logger.info(f"Stage 5 complete: summaries generated for {len(new_dates)} dates") |
|
||||||
else: |
|
||||||
logger.info("Stage 5: No unsummarized dates, skipping") |
|
||||||
|
|
||||||
logger.info("=== Sync complete ===") |
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": |
|
||||||
sync() |
|
||||||
Loading…
Reference in new issue