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.
 
 
 
 
 

473 lines
19 KiB

"""Deep-research API: boards, threads, and their background jobs.
All LLM work happens in a child process (see backend/services/research/jobs.py);
these routes only write rows, spawn/cancel jobs, and serve polls.
An optional ``llm`` provider override may accompany any request that spawns a
job. It rides the same stdin ``secrets`` channel as the board key — never the
persisted ``params`` column — so the user's API key exists in the request, the
spawn pipe and the child's memory, nowhere else. Because a job outlives its
request, that key stays in the detached child until the job finishes (capped by
MAX_JOB_RUNTIME_SECS, one hour). The UI says so before the user supplies one.
Encrypted boards (logged-in users): the client sends the raw board key with
every request that spawns or writes content. The key is used transiently to
encrypt inserts and is forwarded to the job child via spawn_job(secrets=…) —
it is never written to any table. Poll routes serve ciphertext as stored; the
client decrypts.
"""
from __future__ import annotations
import base64
import os
import time
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
# Importing handlers populates the job registry (register() side effects).
import backend.services.research.handlers # noqa: F401
from backend.services.auth import get_optional_user
from backend.services.llm_override import ProviderOverride, resolve as resolve_provider
from backend.services.research import board as board_mod
from backend.services.research import jobs
router = APIRouter(prefix="/api", tags=["research"])
# Global cap on jobs running on the server's own key — a token-budget guard.
RESEARCH_MAX_RUNNING_JOBS = int(os.getenv("RESEARCH_MAX_RUNNING_JOBS", "1"))
# Jobs on a user's own key don't touch that budget, so they get their own
# per-owner cap instead of queueing behind everyone else.
RESEARCH_MAX_BYO_JOBS_PER_OWNER = int(os.getenv("RESEARCH_MAX_BYO_JOBS_PER_OWNER", "2"))
def _require_owner(board_id: str, session: Optional[str],
user: Optional[dict] = None) -> dict:
"""404 unless the board exists AND belongs to the caller. Returns the
board's {"owner_session", "user_id"} so callers can budget jobs per owner.
Account-owned boards (user_id set) require a matching Bearer token.
Anonymous boards belong to the browser's X-Session-Id (a localStorage
UUID). Returning 404 (not 403) means a board's existence isn't leaked.
A missing session id / token never matches."""
access = board_mod.board_access(board_id)
if access is None:
raise HTTPException(status_code=404, detail="Research not found")
if access.get("user_id"):
if user is None or user["user_id"] != access["user_id"]:
raise HTTPException(status_code=404, detail="Research not found")
return access
owner = access.get("owner_session") or ""
if not session or owner != session:
raise HTTPException(status_code=404, detail="Research not found")
return access
def _board_key_bytes(board_id: str, board_key: Optional[str]) -> Optional[bytes]:
"""Raw board key for an encrypted board; None for plaintext boards.
400s when the key is missing/garbled — content writes must never fall back
to plaintext on an encrypted board."""
board = board_mod.get_board(board_id)
if board is None or not board.get("enc"):
return None
if not board_key:
raise HTTPException(status_code=400, detail="Krypterad research kräver boardnyckel")
try:
key = base64.b64decode(board_key)
except Exception:
key = b""
if len(key) != 32:
raise HTTPException(status_code=400, detail="Ogiltig boardnyckel")
return key
# Opportunistic reaper: at most once a minute, piggybacked on GET polls —
# the poll path is hot exactly when jobs are running.
_last_reap = 0.0
def _maybe_reap() -> None:
global _last_reap
now = time.time()
if now - _last_reap < 60:
return
_last_reap = now
try:
jobs.reap_stale_jobs()
except Exception:
pass
def _check_llm(llm: Optional[ProviderOverride]) -> None:
"""400 on an unknown provider, at request time.
A bad override must not cost the user a job that only fails half a minute
later in the child — by then the board has already flipped to 'failed'.
"""
if llm is None:
return
try:
resolve_provider(llm)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
def _spawn_secrets(board_key: Optional[str], llm: Optional[ProviderOverride],
**extra) -> Optional[dict]:
"""Job inputs that must never reach the persisted ``jobs.params`` column.
``board_key`` is popped by execute_spec; everything else is merged into the
child's params in memory. Note this returns a dict for plaintext boards too
whenever an override is present — the override travels independently of
whether the board itself is encrypted.
"""
secrets: dict = {}
if board_key:
secrets["board_key"] = board_key
if llm is not None:
secrets["llm"] = llm.model_dump()
secrets.update({k: v for k, v in extra.items() if v is not None})
return secrets or None
def _byo_params(llm: Optional[ProviderOverride]) -> dict:
"""The persisted half of an override: a flag, never the key or the models.
Lets the job cap tell whose tokens a running job is spending."""
return {"byo": True} if llm is not None else {}
def _has_job_slot(llm: Optional[ProviderOverride],
access: Optional[dict] = None) -> bool:
"""Is there room to start a job right now?
Jobs on a user's own key spend no server tokens, so they are budgeted per
owner instead of against the single global slot everyone else shares.
"""
if llm is not None:
access = access or {}
running = jobs.count_running_byo_jobs(
access.get("owner_session"), access.get("user_id")
)
return running < RESEARCH_MAX_BYO_JOBS_PER_OWNER
return jobs.count_running_jobs(server_key_only=True) < RESEARCH_MAX_RUNNING_JOBS
def _guard_spawn(board_id: Optional[str] = None,
llm: Optional[ProviderOverride] = None,
access: Optional[dict] = None) -> None:
if board_id and jobs.running_job_for_board(board_id):
raise HTTPException(status_code=409, detail="Ett jobb kör redan för denna research")
if _has_job_slot(llm, access):
return
raise HTTPException(
status_code=409,
detail=("Du har redan max antal research-jobb igång — vänta tills ett blir klart"
if llm is not None else
"Max antal samtidiga research-jobb kör redan — försök igen om en stund"),
)
class CreateBoardRequest(BaseModel):
topic: str = Field(..., min_length=3, max_length=2000)
title: Optional[str] = None
# Logged-in users: raw per-board key (base64, 32 bytes) + the same key
# wrapped by the user's DEK. The raw key is used transiently and forwarded
# to the job via stdin; only the wrapped copy is stored.
board_key: Optional[str] = None
wrapped_board_key: Optional[str] = None
# Optional user-supplied provider. Rides the secrets channel; see _spawn_secrets.
llm: Optional[ProviderOverride] = None
class SeedThreadRequest(BaseModel):
text: str = Field(..., min_length=3, max_length=2000)
board_key: Optional[str] = None
llm: Optional[ProviderOverride] = None
class DeepenRequest(BaseModel):
thread_id: Optional[str] = None
lead: Optional[dict] = None
sweep: bool = False
board_key: Optional[str] = None
llm: Optional[ProviderOverride] = None
class ThreadSelection(BaseModel):
thread_id: str
guidance: Optional[str] = Field(default=None, max_length=2000)
class ActivateThreadsRequest(BaseModel):
selections: List[ThreadSelection] = Field(..., min_length=1, max_length=20)
dig: bool = True
board_key: Optional[str] = None
llm: Optional[ProviderOverride] = None
class ReportRequest(BaseModel):
board_key: Optional[str] = None
llm: Optional[ProviderOverride] = None
@router.post("/research")
def create_research(
payload: CreateBoardRequest,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Create a board and start the scout job: a few grounding search rounds,
then thread proposals. Digging waits for the user's selection
(POST /research/{id}/threads/activate)."""
if not x_session_id and user is None:
raise HTTPException(status_code=400, detail="Saknar sessions-id")
_check_llm(payload.llm)
key: Optional[bytes] = None
user_id: Optional[str] = None
owner_session: Optional[str] = x_session_id
if user is not None and payload.board_key:
if not payload.wrapped_board_key:
raise HTTPException(status_code=400, detail="Saknar wrapped_board_key")
try:
key = base64.b64decode(payload.board_key)
except Exception:
key = b""
if len(key) != 32:
raise HTTPException(status_code=400, detail="Ogiltig boardnyckel")
user_id = user["user_id"]
owner_session = None # account-owned, not browser-owned
_guard_spawn(llm=payload.llm,
access={"owner_session": owner_session, "user_id": user_id})
board = board_mod.create_board(
payload.topic, payload.title,
owner_session=owner_session, user_id=user_id,
wrapped_board_key=payload.wrapped_board_key if key else None, key=key,
)
board_mod.set_board_status(board["id"], "scouting")
spawned = jobs.spawn_job(
kind="research_scout", board_id=board["id"],
params={"board_id": board["id"], **_byo_params(payload.llm)},
secrets=_spawn_secrets(payload.board_key if key else None, payload.llm),
)
if spawned.get("status") == "failed":
board_mod.set_board_status(board["id"], "failed")
raise HTTPException(status_code=500, detail="Kunde inte starta research-jobbet")
return {"board_id": board["id"], "job_id": spawned["job_id"]}
@router.get("/research")
def list_research(
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> List[dict]:
_maybe_reap()
return board_mod.list_boards(
owner_session=x_session_id,
user_id=user["user_id"] if user else None,
)
@router.get("/research/{board_id}")
def get_research(
board_id: UUID,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""The poll target: board + threads + the running job (if any).
Encrypted boards are served as stored (ciphertext); the client decrypts."""
_maybe_reap()
_require_owner(str(board_id), x_session_id, user)
board = board_mod.get_board(str(board_id))
if board is None:
raise HTTPException(status_code=404, detail="Research not found")
board["threads"] = board_mod.get_threads(str(board_id))
board["job"] = jobs.running_job_for_board(str(board_id))
return board
@router.get("/research/{board_id}/events")
def get_research_events(
board_id: UUID,
job_id: str,
offset: int = 0,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Incremental activity ticker for a running job."""
_require_owner(str(board_id), x_session_id, user)
return jobs.get_events(job_id, offset)
@router.post("/research/{board_id}/threads")
def seed_thread(
board_id: UUID,
payload: SeedThreadRequest,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Add a user-seeded thread. Instant INSERT; if no job is running, spawn a
deepen job so the thread gets researched right away. If a dig is already
running it picks the new depth-0 thread up next (shallowest-first)."""
access = _require_owner(str(board_id), x_session_id, user)
_check_llm(payload.llm)
key = _board_key_bytes(str(board_id), payload.board_key)
thread = board_mod.insert_seed_thread(str(board_id), payload.text, key=key)
job_id = None
if not jobs.running_job_for_board(str(board_id)) and _has_job_slot(payload.llm, access):
board_mod.set_board_status(str(board_id), "digging")
spawned = jobs.spawn_job(
kind="research_deepen", board_id=str(board_id),
params={"board_id": str(board_id), "thread_id": thread["id"],
**_byo_params(payload.llm)},
secrets=_spawn_secrets(payload.board_key if key else None, payload.llm),
)
job_id = spawned.get("job_id")
return {"thread": thread, "job_id": job_id}
@router.post("/research/{board_id}/deepen")
def deepen_research(
board_id: UUID,
payload: DeepenRequest,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Spawn a deepen job: one targeted trip (thread/lead) and/or a sweep."""
access = _require_owner(str(board_id), x_session_id, user)
_check_llm(payload.llm)
_guard_spawn(str(board_id), payload.llm, access)
key = _board_key_bytes(str(board_id), payload.board_key)
params = {
"board_id": str(board_id),
"thread_id": payload.thread_id,
"sweep": payload.sweep or not (payload.thread_id or payload.lead),
**_byo_params(payload.llm),
}
if key is not None:
# The lead carries content (its text may quote findings) — for
# encrypted boards it must ride the secrets channel, never the
# persisted params.
secrets = _spawn_secrets(payload.board_key, payload.llm, lead=payload.lead)
else:
params["lead"] = payload.lead
secrets = _spawn_secrets(None, payload.llm)
board_mod.set_board_status(str(board_id), "digging")
spawned = jobs.spawn_job(
kind="research_deepen", board_id=str(board_id),
params=params, secrets=secrets,
)
if spawned.get("status") == "failed":
board_mod.set_board_status(str(board_id), "failed")
raise HTTPException(status_code=500, detail="Kunde inte starta jobbet")
return {"job_id": spawned["job_id"]}
@router.post("/research/{board_id}/threads/activate")
def activate_threads(
board_id: UUID,
payload: ActivateThreadsRequest,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Approve proposed threads (optionally with per-thread guidance) and, by
default, dig them: a sweep picks up the newly activated depth-0 threads
shallowest-first."""
access = _require_owner(str(board_id), x_session_id, user)
_check_llm(payload.llm)
key = _board_key_bytes(str(board_id), payload.board_key)
# Reserve the job slot before any write, so a 409 leaves the proposals
# untouched (the user can retry without losing their selection).
if payload.dig:
_guard_spawn(str(board_id), payload.llm, access)
activated: List[str] = []
for sel in payload.selections:
guidance = " ".join((sel.guidance or "").split()).strip() or None
if board_mod.activate_thread(sel.thread_id, str(board_id), guidance=guidance, key=key):
activated.append(sel.thread_id)
if not activated:
raise HTTPException(status_code=400, detail="Inga förslag att gräva i")
board_mod.bump_revision(str(board_id))
job_id = None
if payload.dig:
board_mod.set_board_status(str(board_id), "digging")
spawned = jobs.spawn_job(
kind="research_deepen", board_id=str(board_id),
params={"board_id": str(board_id), "sweep": True, **_byo_params(payload.llm)},
secrets=_spawn_secrets(payload.board_key if key else None, payload.llm),
)
if spawned.get("status") == "failed":
board_mod.set_board_status(str(board_id), "awaiting")
raise HTTPException(status_code=500, detail="Kunde inte starta jobbet")
job_id = spawned["job_id"]
return {"activated": activated, "job_id": job_id}
@router.post("/research/{board_id}/threads/{thread_id}/archive")
def archive_thread(
board_id: UUID,
thread_id: UUID,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Dismiss a thread (typically an unwanted proposal). Status-only write —
no board key needed; the row is kept, just hidden."""
_require_owner(str(board_id), x_session_id, user)
return {"archived": board_mod.archive_thread(str(thread_id), str(board_id))}
@router.post("/research/{board_id}/report")
def generate_report(
board_id: UUID,
payload: ReportRequest,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
"""Write (or rewrite) the single board report from the thread answers."""
access = _require_owner(str(board_id), x_session_id, user)
_check_llm(payload.llm)
_guard_spawn(str(board_id), payload.llm, access)
key = _board_key_bytes(str(board_id), payload.board_key)
board_mod.set_board_status(str(board_id), "reporting")
spawned = jobs.spawn_job(
kind="research_report", board_id=str(board_id),
params={"board_id": str(board_id), **_byo_params(payload.llm)},
secrets=_spawn_secrets(payload.board_key if key else None, payload.llm),
)
if spawned.get("status") == "failed":
# The board already holds researched content — never leave it 'reporting'.
board_mod.set_board_status(str(board_id), "ready")
raise HTTPException(status_code=500, detail="Kunde inte starta rapportjobbet")
return {"job_id": spawned["job_id"]}
@router.post("/research/{board_id}/cancel")
def cancel_research(
board_id: UUID,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> dict:
_require_owner(str(board_id), x_session_id, user)
job = jobs.running_job_for_board(str(board_id))
if job is None:
return {"cancelled": False}
jobs.request_cancel(job["job_id"])
return {"cancelled": True, "job_id": job["job_id"]}
@router.delete("/research/{board_id}", status_code=204)
def delete_research(
board_id: UUID,
x_session_id: Optional[str] = Header(default=None),
user: Optional[dict] = Depends(get_optional_user),
) -> None:
_require_owner(str(board_id), x_session_id, user)
job = jobs.running_job_for_board(str(board_id))
if job is not None:
jobs.request_cancel(job["job_id"])
if not board_mod.delete_board(str(board_id)):
raise HTTPException(status_code=404, detail="Research not found")