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.
 
 
 
 
 

329 lines
11 KiB

from __future__ import annotations
import json
from typing import Literal, List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from postgres_client import pg
from backend.services.auth import get_current_user, get_optional_user
router = APIRouter(prefix="/api", tags=["sessions"])
class SessionUpsertRequest(BaseModel):
session_type: Literal["general", "mp"]
person_id: Optional[str] = None
initial_speech_id: Optional[str] = None
llm_messages: List[dict] = []
turns: List[dict] = []
focus_ids: List[str] = []
# Owned sessions: all content (messages/turns/focus_ids AND the MP identity)
# arrives as one client-encrypted blob; plaintext fields above stay empty.
enc_payload: Optional[str] = None
enc_title: Optional[str] = None
class SessionResponse(BaseModel):
id: str
session_type: str
person_id: Optional[str]
initial_speech_id: Optional[str]
llm_messages: List[dict]
turns: List[dict]
focus_ids: List[str]
enc_payload: Optional[str] = None
def _session_owner(session_id: str) -> Optional[str]:
"""user_id of an existing session ('' when unowned), None when missing."""
rows = pg.execute(
"SELECT user_id::text AS user_id FROM chat_sessions WHERE id = %s",
(session_id,),
)
if not rows:
return None
return rows[0]["user_id"] or ""
@router.get("/sessions/{session_id}", response_model=SessionResponse)
def get_session(
session_id: UUID,
user: Optional[dict] = Depends(get_optional_user),
) -> SessionResponse:
rows = pg.execute(
"""
SELECT id::text, session_type, person_id, initial_speech_id,
llm_messages, turns, focus_ids, user_id::text AS user_id, enc_payload
FROM chat_sessions
WHERE id = %s
AND (user_id IS NOT NULL OR last_activity > NOW() - INTERVAL '7 days')
""",
(str(session_id),),
)
if not rows:
raise HTTPException(status_code=404, detail="Session not found or expired")
row = rows[0]
# Owned sessions are only served to their owner. 404 (not 403) so the
# existence of someone else's session never leaks.
if row.get("user_id") and (user is None or user["user_id"] != row["user_id"]):
raise HTTPException(status_code=404, detail="Session not found or expired")
return SessionResponse(
id=row["id"],
session_type=row["session_type"],
person_id=row.get("person_id"),
initial_speech_id=row.get("initial_speech_id"),
llm_messages=row["llm_messages"] or [],
turns=row["turns"] or [],
focus_ids=list(row["focus_ids"] or []),
enc_payload=row.get("enc_payload"),
)
@router.put("/sessions/{session_id}", status_code=204, response_model=None)
def upsert_session(
session_id: UUID,
payload: SessionUpsertRequest,
user: Optional[dict] = Depends(get_optional_user),
) -> None:
if payload.enc_payload is not None and user is None:
raise HTTPException(status_code=401, detail="Inloggning krävs för krypterade sessioner")
owner = _session_owner(str(session_id))
if owner and (user is None or user["user_id"] != owner):
# Never let an anonymous/mismatched PUT overwrite an owned session.
raise HTTPException(status_code=404, detail="Session not found or expired")
if user is not None and payload.enc_payload is not None:
pg.execute_void(
"""
INSERT INTO chat_sessions
(id, session_type, user_id, enc_payload, enc_title, last_activity)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (id) DO UPDATE SET
user_id = EXCLUDED.user_id,
enc_payload = EXCLUDED.enc_payload,
enc_title = EXCLUDED.enc_title,
-- Claiming a previously anonymous session: scrub the plaintext.
llm_messages = '[]'::jsonb,
turns = '[]'::jsonb,
focus_ids = '{}',
person_id = NULL,
initial_speech_id = NULL,
last_activity = NOW()
""",
(
str(session_id),
payload.session_type,
user["user_id"],
payload.enc_payload,
payload.enc_title,
),
)
else:
pg.execute_void(
"""
INSERT INTO chat_sessions
(id, session_type, person_id, initial_speech_id,
llm_messages, turns, focus_ids, last_activity)
VALUES (%s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, NOW())
ON CONFLICT (id) DO UPDATE SET
llm_messages = EXCLUDED.llm_messages,
turns = EXCLUDED.turns,
focus_ids = EXCLUDED.focus_ids,
last_activity = NOW()
""",
(
str(session_id),
payload.session_type,
payload.person_id,
payload.initial_speech_id,
json.dumps(payload.llm_messages),
json.dumps(payload.turns),
payload.focus_ids,
),
)
# Opportunistically clean up expired ANONYMOUS sessions (owned ones persist)
try:
pg.execute_void(
"""
DELETE FROM chat_sessions
WHERE user_id IS NULL AND last_activity < NOW() - INTERVAL '7 days'
""",
)
except Exception:
pass
# ── My chats (owned sessions; titles are ciphertext, decrypted client-side) ──
class MyChatRow(BaseModel):
id: str
session_type: str
enc_title: Optional[str]
created_at: str
last_activity: str
@router.get("/me/chats", response_model=List[MyChatRow])
def list_my_chats(user: dict = Depends(get_current_user)) -> List[MyChatRow]:
rows = pg.execute(
"""
SELECT id::text, session_type, enc_title,
created_at::text AS created_at, last_activity::text AS last_activity
FROM chat_sessions
WHERE user_id = %s
ORDER BY last_activity DESC
LIMIT 200
""",
(user["user_id"],),
)
return [MyChatRow(**row) for row in rows]
@router.delete("/me/chats/{session_id}", status_code=204, response_model=None)
def delete_my_chat(session_id: UUID, user: dict = Depends(get_current_user)) -> None:
rows = pg.execute(
"DELETE FROM chat_sessions WHERE id = %s AND user_id = %s RETURNING id",
(str(session_id), user["user_id"]),
)
if not rows:
raise HTTPException(status_code=404, detail="Chat not found")
# ── Snapshots (frozen, shareable read-only views — plaintext by choice) ─────
class SnapshotCreateRequest(BaseModel):
session_type: Literal["general", "mp"]
person_id: Optional[str] = None
initial_speech_id: Optional[str] = None
llm_messages: List[dict] = []
turns: List[dict] = []
focus_ids: List[str] = []
class SnapshotCreateResponse(BaseModel):
id: str
class SnapshotResponse(BaseModel):
id: str
session_type: str
person_id: Optional[str]
initial_speech_id: Optional[str] = None
turns: List[dict]
llm_messages: List[dict] = []
focus_ids: List[str] = []
created_at: str
@router.post("/snapshots", response_model=SnapshotCreateResponse, status_code=201)
def create_snapshot(payload: SnapshotCreateRequest) -> SnapshotCreateResponse:
import uuid as uuid_lib
snapshot_id = str(uuid_lib.uuid4())
pg.execute_void(
"""
INSERT INTO chat_snapshots
(id, session_type, person_id, initial_speech_id, llm_messages, turns, focus_ids, last_activity)
VALUES (%s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, NOW())
""",
(
snapshot_id,
payload.session_type,
payload.person_id,
payload.initial_speech_id,
json.dumps(payload.llm_messages),
json.dumps(payload.turns),
payload.focus_ids,
),
)
try:
pg.execute_void(
"DELETE FROM chat_snapshots WHERE last_activity < NOW() - INTERVAL '7 days'",
)
except Exception:
pass
return SnapshotCreateResponse(id=snapshot_id)
class ForkSnapshotResponse(BaseModel):
session_id: str
@router.post("/snapshots/{snapshot_id}/fork", response_model=ForkSnapshotResponse, status_code=201)
def fork_snapshot(snapshot_id: UUID) -> ForkSnapshotResponse:
"""Server-side fork for anonymous visitors. Logged-in clients fork locally
instead (fetch → encrypt → PUT) so the copy is encrypted from the start."""
import uuid as uuid_lib
rows = pg.execute(
"""
SELECT session_type, person_id, initial_speech_id, llm_messages, turns, focus_ids
FROM chat_snapshots
WHERE id = %s
""",
(str(snapshot_id),),
)
if not rows:
raise HTTPException(status_code=404, detail="Snapshot not found")
row = rows[0]
session_id = str(uuid_lib.uuid4())
pg.execute_void(
"""
INSERT INTO chat_sessions
(id, session_type, person_id, initial_speech_id,
llm_messages, turns, focus_ids, last_activity)
VALUES (%s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, NOW())
""",
(
session_id,
row["session_type"],
row.get("person_id"),
row.get("initial_speech_id"),
json.dumps(row["llm_messages"] or []),
json.dumps(row["turns"] or []),
list(row["focus_ids"] or []),
),
)
try:
pg.execute_void(
"UPDATE chat_snapshots SET last_activity = NOW() WHERE id = %s",
(str(snapshot_id),),
)
except Exception:
pass
return ForkSnapshotResponse(session_id=session_id)
@router.get("/snapshots/{snapshot_id}", response_model=SnapshotResponse)
def get_snapshot(snapshot_id: UUID) -> SnapshotResponse:
rows = pg.execute(
"""
SELECT id::text, session_type, person_id, initial_speech_id,
turns, llm_messages, focus_ids, created_at::text
FROM chat_snapshots
WHERE id = %s
""",
(str(snapshot_id),),
)
if not rows:
raise HTTPException(status_code=404, detail="Snapshot not found")
row = rows[0]
return SnapshotResponse(
id=row["id"],
session_type=row["session_type"],
person_id=row.get("person_id"),
initial_speech_id=row.get("initial_speech_id"),
turns=row["turns"] or [],
llm_messages=row["llm_messages"] or [],
focus_ids=list(row["focus_ids"] or []),
created_at=row["created_at"],
)