Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

382 lines
13 KiB

from __future__ import annotations
import asyncio
import os
from datetime import datetime
import httpx
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse
from postgres_client import pg
from parliament import PARLIAMENT
from .schemas import (
ChatRequest,
ChatResponse,
FeedbackRequest,
FeedbackResponse,
SearchRequest,
SearchResponse,
TalkHit,
)
from .services import ChatService, SearchService
from backend.routes.auth import router as auth_router
from backend.routes.chat import router as chat_router
from backend.routes.research import router as research_router
from backend.routes.sessions import router as sessions_router
from backend.routes.settings import router as settings_router
from .services.names_autocomplete import router as names_autocomplete_router
app = FastAPI(title="Riksdagen API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
search_service = SearchService()
chat_service = ChatService()
app.include_router(auth_router)
app.include_router(chat_router)
app.include_router(research_router)
app.include_router(sessions_router)
app.include_router(settings_router)
app.include_router(names_autocomplete_router)
@app.on_event("startup")
def _verify_database_matches_config() -> None:
"""Refuse to serve if the database disagrees with parliament.yaml.
Both mismatches below fail silently rather than loudly, which is why they are
checked here:
* A wrong text-search configuration makes every full-text query return almost
nothing, with no error — the app looks empty rather than broken.
* A vector column narrower or wider than `embeddings.dimension` fails inside
pgvector with a message that never mentions configuration.
"""
from parliament import PARLIAMENT
try:
rows = pg.execute("SELECT current_setting('app.fts_config', true) AS cfg")
configured = PARLIAMENT.language.fts_config
actual = (rows[0]["cfg"] if rows else None) or None
if actual and actual != configured:
raise RuntimeError(
f"Database app.fts_config is {actual!r} but parliament.yaml declares "
f"{configured!r}. Fix with: "
f"ALTER DATABASE <db> SET app.fts_config = '{configured}';"
)
rows = pg.execute(
"SELECT a.atttypmod AS typmod FROM pg_attribute a "
"JOIN pg_class c ON c.oid = a.attrelid "
"WHERE c.relname = 'chunks' AND a.attname = 'embedding'"
)
if rows and rows[0]["typmod"] and rows[0]["typmod"] > 0:
actual_dim = rows[0]["typmod"]
if actual_dim != PARLIAMENT.embeddings.dimension:
raise RuntimeError(
f"chunks.embedding is vector({actual_dim}) but parliament.yaml "
f"declares embeddings.dimension={PARLIAMENT.embeddings.dimension}. "
f"Re-embedding is required to change this."
)
except RuntimeError:
raise
except Exception as exc:
# A database that is merely unreachable is not a configuration error;
# let the request path report that in its own terms.
print(f"[startup] could not verify database configuration: {exc}")
@app.on_event("startup")
def _reap_abandoned_jobs() -> None:
"""Finalize research jobs whose child died while the API was down."""
from backend.services.research.jobs import reap_stale_jobs
try:
reaped = reap_stale_jobs()
if reaped:
print(f"[startup] reaped {reaped} abandoned research job(s)")
except Exception as exc:
print(f"[startup] job reaper failed: {exc}")
@app.get("/api/guide", response_class=PlainTextResponse)
def get_guide() -> str:
"""Serve user-guide.md as plain text. Single source of truth for all guide links."""
guide_path = os.path.join(os.path.dirname(__file__), "..", "user-guide.md")
if not os.path.exists(guide_path):
raise HTTPException(status_code=404, detail="Guide not found")
with open(guide_path, encoding="utf-8") as f:
return f.read()
@app.get("/api/meta")
def meta():
return PARLIAMENT.public_meta()
@app.post("/api/search", response_model=SearchResponse)
def search(payload: SearchRequest):
results, stats, limit_reached = search_service.search(
payload, include_snippets=payload.include_snippets
)
# Try to convert results to TalkHit objects
hits = []
for idx, hit in enumerate(results):
try:
talk_hit = TalkHit(**hit)
# Serialize using alias so 'id' is sent to frontend, not '_id'
hit_dict = talk_hit.dict(by_alias=True)
hits.append(hit_dict)
except Exception as e:
print(f"Error converting result {idx} to TalkHit: {e}")
print(f"Problematic result: {hit}")
# Continue with other results instead of failing completely
continue
return {
"results": hits,
"stats": stats,
"active_filters": {
"parties": payload.parties,
"people": payload.people,
"debates": payload.debates,
"from_year": payload.from_year,
"to_year": payload.to_year,
"speaker_ids": payload.speaker_ids,
"speaker": payload.speaker,
},
"limit_reached": limit_reached,
}
@app.post("/api/chat", response_model=ChatResponse)
def chat(payload: ChatRequest) -> ChatResponse:
"""
Generate a chat answer plus citations via the retrieval-aware ChatService.
Args:
payload (ChatRequest): Chat history, retrieval strategy, and result limit.
Returns:
ChatResponse: Assistant reply and supporting sources.
"""
if not payload.messages:
raise HTTPException(status_code=400, detail="messages cannot be empty")
messages = [message.dict() for message in payload.messages]
limit = getattr(payload, "top_k", None)
if limit is None:
limit = getattr(payload, "limit", None)
top_k = limit or 5
chat_result = chat_service.get_chat_response(
messages=messages,
top_k=top_k,
)
return ChatResponse(answer=chat_result["answer"], sources=chat_result["sources"])
@app.get("/api/talk/{talk_id}")
def get_talk(talk_id: str) -> dict:
"""
Fetch a single talk document by its ID.
Accepts either:
- A full id like "talks/H40911"
- Just the key like "H40911"
Returns the talk with person info merged in, plus previous/next navigation.
"""
bare_id = talk_id.split("/", 1)[-1] # strip "talks/" prefix if present
rows = pg.execute(
"""
SELECT
t.id, t.anforandetext, t.talare, t.parti,
t.datum::text AS datum, t.kammaraktivitet, t.avsnittsrubrik,
t.titel, t.anforande_nummer, t.replik,
COALESCE(t.url_session, t.debateurl) AS url_session,
t.url_audio, t.summary, t.intressent_id,
p.bild_url_192, p.tilltalsnamn, p.efternamn, p.valkrets, p.status AS person_status
FROM talks t
LEFT JOIN people p ON t.intressent_id = p.intressent_id
WHERE t.id = %s
""",
(bare_id,),
)
if not rows:
raise HTTPException(status_code=404, detail=f"Talk not found: {talk_id}")
row = rows[0]
num = row.get("anforande_nummer")
# Previous / next navigation within the same date + debate type
prev_rows, next_rows = [], []
if num is not None:
prev_rows = pg.execute(
"""
SELECT id FROM talks
WHERE datum = %s::date AND kammaraktivitet = %s AND anforande_nummer = %s
LIMIT 1
""",
(row["datum"], row["kammaraktivitet"], num - 1),
)
next_rows = pg.execute(
"""
SELECT id FROM talks
WHERE datum = %s::date AND kammaraktivitet = %s AND anforande_nummer = %s
LIMIT 1
""",
(row["datum"], row["kammaraktivitet"], num + 1),
)
person = None
if row.get("tilltalsnamn") or row.get("efternamn"):
person = {
"bild_url_192": row.get("bild_url_192"),
"tilltalsnamn": row.get("tilltalsnamn"),
"efternamn": row.get("efternamn"),
"valkrets": row.get("valkrets"),
"status": row.get("person_status"),
}
return {
"anforandetext": row.get("anforandetext"),
"talare": row.get("talare"),
"parti": row.get("parti"),
"datum": row.get("datum"),
"kammaraktivitet": row.get("kammaraktivitet"),
"avsnittsrubrik": row.get("avsnittsrubrik"),
"titel": row.get("titel"),
"anforande_nummer": num,
"replik": row.get("replik"),
"url_session": row.get("url_session"),
"url_audio": row.get("url_audio"),
"summary": row.get("summary"),
"person": person,
"navigation": {
"previous": f"talks/{prev_rows[0]['id']}" if prev_rows else None,
"next": f"talks/{next_rows[0]['id']}" if next_rows else None,
},
}
# Fields surfaced per yrkande from the raw dokforslag JSON.
_MOTION_YRKANDE_KEYS = ("nummer", "lydelse", "utskottet", "kammaren", "behandlas_i")
@app.get("/api/motion/{dok_id}")
def get_motion(dok_id: str) -> dict:
"""
Fetch a single motion document by its dok_id.
Accepts either a full id like "motions/HD02846" or just the key "HD02846".
Returns a shape parallel to /api/talk (talare/parti/datum/titel/anforandetext
populated for shared rendering) plus motion-specific fields: authors,
yrkanden with committee/chamber outcomes, pdf/document links.
"""
bare_id = dok_id.split("/", 1)[-1] # strip "motions/" prefix if present
rows = pg.execute(
"""
SELECT dok_id, rm, beteckning, subtyp, organ, status,
datum::text AS datum, titel, undertitel, text, has_text,
parties, author_names, forslag, pdf_url, dokument_url_html
FROM motions
WHERE dok_id = %s
""",
(bare_id,),
)
if not rows:
raise HTTPException(status_code=404, detail=f"Motion not found: {dok_id}")
row = rows[0]
# Authors joined with people for portraits / MP-page links.
author_rows = pg.execute(
"""
SELECT a.ordinal, a.namn, a.partibet, a.roll, a.intressent_id,
p.bild_url_192, p.tilltalsnamn, p.efternamn, p.valkrets,
p.status AS person_status
FROM motion_authors a
LEFT JOIN people p ON a.intressent_id = p.intressent_id
WHERE a.dok_id = %s
ORDER BY a.ordinal
""",
(bare_id,),
)
authors = [
{
"namn": a.get("namn"),
"partibet": a.get("partibet"),
"roll": a.get("roll"),
"intressent_id": a.get("intressent_id"),
"tilltalsnamn": a.get("tilltalsnamn"),
"bild_url_192": (a.get("bild_url_192") or "").replace("http://", "https://") or None,
"valkrets": a.get("valkrets"),
"status": a.get("person_status"),
}
for a in author_rows
]
# Primary author becomes the "person" card, mirroring talks' speaker.
person = None
if authors and (authors[0].get("intressent_id") or authors[0].get("bild_url_192")):
first = authors[0]
person = {
"bild_url_192": first.get("bild_url_192"),
"tilltalsnamn": first.get("tilltalsnamn"),
"intressent_id": first.get("intressent_id"),
"valkrets": first.get("valkrets"),
"status": first.get("status"),
}
forslag = row.get("forslag") or []
if isinstance(forslag, str):
import json as _json
forslag = _json.loads(forslag)
yrkanden = [
{k: f.get(k) for k in _MOTION_YRKANDE_KEYS if f.get(k) is not None}
for f in forslag
if isinstance(f, dict)
]
author_names = row.get("author_names") or []
talare = ", ".join(author_names[:3]) + (" m.fl." if len(author_names) > 3 else "")
return {
"kind": "motion",
"dok_id": row.get("dok_id"),
# Shared-shape fields so talk-oriented UI code renders without changes:
"talare": talare,
"parti": "/".join(row.get("parties") or []),
"datum": row.get("datum"),
"titel": row.get("titel"),
"anforandetext": row.get("text"),
"summary": None,
"person": person,
# Motion-specific:
"undertitel": row.get("undertitel"),
"rm": row.get("rm"),
"beteckning": row.get("beteckning"),
"subtyp": row.get("subtyp"),
"organ": row.get("organ"),
"status": row.get("status"),
"has_text": row.get("has_text"),
"parties": row.get("parties") or [],
"authors": authors,
"yrkanden": yrkanden,
"pdf_url": row.get("pdf_url"),
"dokument_url_html": row.get("dokument_url_html"),
"navigation": {"previous": None, "next": None},
}