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.
385 lines
13 KiB
385 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 = 'speech_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"speech_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 the user guide as plain text — the single source for all guide links. |
|
|
|
Resolved through CONTENT_DIR, so a deployment can ship its own guide without |
|
editing the repository. |
|
""" |
|
guide = PARLIAMENT.read_content("guide_file") |
|
if not guide: |
|
raise HTTPException(status_code=404, detail="No guide configured") |
|
return guide |
|
|
|
|
|
@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/{speech_id}") |
|
def get_talk(speech_id: str) -> dict: |
|
""" |
|
Fetch a single talk document by its ID. |
|
|
|
Accepts either: |
|
- A full id like "speeches/H40911" |
|
- Just the key like "H40911" |
|
|
|
Returns the talk with person info merged in, plus previous/next navigation. |
|
""" |
|
bare_id = speech_id.split("/", 1)[-1] # strip "speeches/" prefix if present |
|
|
|
rows = pg.execute( |
|
""" |
|
SELECT |
|
t.id, t.text, t.speaker_name, t.party, |
|
t.date::text AS date, t.activity_type, t.section_title, |
|
t.title, t.sequence, t.is_reply, |
|
COALESCE(t.url_session, t.url_video) AS url_session, |
|
t.url_audio, t.summary, t.person_id, |
|
p.image_url_medium, p.first_name, p.last_name, p.constituency, p.status AS person_status |
|
FROM speeches t |
|
LEFT JOIN people p ON t.person_id = p.person_id |
|
WHERE t.id = %s |
|
""", |
|
(bare_id,), |
|
) |
|
|
|
if not rows: |
|
raise HTTPException(status_code=404, detail=f"Talk not found: {speech_id}") |
|
|
|
row = rows[0] |
|
num = row.get("sequence") |
|
|
|
# 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 speeches |
|
WHERE date = %s::date AND activity_type = %s AND sequence = %s |
|
LIMIT 1 |
|
""", |
|
(row["date"], row["activity_type"], num - 1), |
|
) |
|
next_rows = pg.execute( |
|
""" |
|
SELECT id FROM speeches |
|
WHERE date = %s::date AND activity_type = %s AND sequence = %s |
|
LIMIT 1 |
|
""", |
|
(row["date"], row["activity_type"], num + 1), |
|
) |
|
|
|
person = None |
|
if row.get("first_name") or row.get("last_name"): |
|
person = { |
|
"image_url_medium": row.get("image_url_medium"), |
|
"first_name": row.get("first_name"), |
|
"last_name": row.get("last_name"), |
|
"constituency": row.get("constituency"), |
|
"status": row.get("person_status"), |
|
} |
|
|
|
return { |
|
"text": row.get("text"), |
|
"speaker_name": row.get("speaker_name"), |
|
"party": row.get("party"), |
|
"date": row.get("date"), |
|
"activity_type": row.get("activity_type"), |
|
"section_title": row.get("section_title"), |
|
"title": row.get("title"), |
|
"sequence": num, |
|
"is_reply": row.get("is_reply"), |
|
"url_session": row.get("url_session"), |
|
"url_audio": row.get("url_audio"), |
|
"summary": row.get("summary"), |
|
"person": person, |
|
"navigation": { |
|
"previous": f"speeches/{prev_rows[0]['id']}" if prev_rows else None, |
|
"next": f"speeches/{next_rows[0]['id']}" if next_rows else None, |
|
}, |
|
} |
|
|
|
|
|
# Fields surfaced per yrkande from the raw dokforslag JSON. |
|
_MOTION_YRKANDE_KEYS = ("number", "text", "committee_recommendation", "chamber_decision", "handled_in") |
|
|
|
|
|
@app.get("/api/motion/{doc_id}") |
|
def get_motion(doc_id: str) -> dict: |
|
""" |
|
Fetch a single motion document by its doc_id. |
|
|
|
Accepts either a full id like "documents/HD02846" or just the key "HD02846". |
|
Returns a shape parallel to /api/talk (speaker_name/party/date/title/text |
|
populated for shared rendering) plus motion-specific fields: authors, |
|
yrkanden with committee/chamber outcomes, pdf/document links. |
|
""" |
|
bare_id = doc_id.split("/", 1)[-1] # strip "documents/" prefix if present |
|
|
|
rows = pg.execute( |
|
""" |
|
SELECT doc_id, session_label, designation, subtype, committee, status, |
|
date::text AS date, title, subtitle, text, has_text, |
|
parties, author_names, proposals_raw, url_pdf, url_html |
|
FROM documents |
|
WHERE doc_id = %s |
|
""", |
|
(bare_id,), |
|
) |
|
if not rows: |
|
raise HTTPException(status_code=404, detail=f"Motion not found: {doc_id}") |
|
row = rows[0] |
|
|
|
# Authors joined with people for portraits / MP-page links. |
|
author_rows = pg.execute( |
|
""" |
|
SELECT a.ordinal, a.name, a.party, a.role, a.person_id, |
|
p.image_url_medium, p.first_name, p.last_name, p.constituency, |
|
p.status AS person_status |
|
FROM document_authors a |
|
LEFT JOIN people p ON a.person_id = p.person_id |
|
WHERE a.doc_id = %s |
|
ORDER BY a.ordinal |
|
""", |
|
(bare_id,), |
|
) |
|
authors = [ |
|
{ |
|
"name": a.get("name"), |
|
"party": a.get("party"), |
|
"role": a.get("role"), |
|
"person_id": a.get("person_id"), |
|
"first_name": a.get("first_name"), |
|
"image_url_medium": (a.get("image_url_medium") or "").replace("http://", "https://") or None, |
|
"constituency": a.get("constituency"), |
|
"status": a.get("person_status"), |
|
} |
|
for a in author_rows |
|
] |
|
|
|
# Primary author becomes the "person" card, mirroring speeches' speaker. |
|
person = None |
|
if authors and (authors[0].get("person_id") or authors[0].get("image_url_medium")): |
|
first = authors[0] |
|
person = { |
|
"image_url_medium": first.get("image_url_medium"), |
|
"first_name": first.get("first_name"), |
|
"person_id": first.get("person_id"), |
|
"constituency": first.get("constituency"), |
|
"status": first.get("status"), |
|
} |
|
|
|
proposals_raw = row.get("proposals_raw") or [] |
|
if isinstance(proposals_raw, str): |
|
import json as _json |
|
proposals_raw = _json.loads(proposals_raw) |
|
yrkanden = [ |
|
{k: f.get(k) for k in _MOTION_YRKANDE_KEYS if f.get(k) is not None} |
|
for f in proposals_raw |
|
if isinstance(f, dict) |
|
] |
|
|
|
author_names = row.get("author_names") or [] |
|
speaker_name = ", ".join(author_names[:3]) + (" m.fl." if len(author_names) > 3 else "") |
|
|
|
return { |
|
"kind": "motion", |
|
"doc_id": row.get("doc_id"), |
|
# Shared-shape fields so talk-oriented UI code renders without changes: |
|
"speaker_name": speaker_name, |
|
"party": "/".join(row.get("parties") or []), |
|
"date": row.get("date"), |
|
"title": row.get("title"), |
|
"text": row.get("text"), |
|
"summary": None, |
|
"person": person, |
|
# Motion-specific: |
|
"subtitle": row.get("subtitle"), |
|
"session_label": row.get("session_label"), |
|
"designation": row.get("designation"), |
|
"subtype": row.get("subtype"), |
|
"committee": row.get("committee"), |
|
"status": row.get("status"), |
|
"has_text": row.get("has_text"), |
|
"parties": row.get("parties") or [], |
|
"authors": authors, |
|
"yrkanden": yrkanden, |
|
"url_pdf": row.get("url_pdf"), |
|
"url_html": row.get("url_html"), |
|
"navigation": {"previous": None, "next": None}, |
|
}
|
|
|