Refuse writes from model-authored SQL; finish the tool rename

database_query passed model-written SQL straight to a connection that commits. The
corpus reaches the model's context, and in a parliament anyone able to speak can get
text into the corpus, so that SQL has to be treated as untrusted input.

Two layers now apply. Statements must begin with SELECT or WITH, and multi-statement
payloads are rejected — that is how a write gets smuggled in behind a leading SELECT.
Then the query runs inside SET TRANSACTION READ ONLY, so PostgreSQL rejects writes
itself even if the first check is wrong. Verified both independently: five attack
shapes refused by the guard, and INSERT/DROP still refused when the guard is bypassed
entirely and the database layer is called directly.

share_insight gets the same treatment. It re-executes SQL stored in saved
conversations, which is no more trustworthy than freshly generated SQL.

Neither layer replaces running against a SELECT-only role, and SECURITY.md still says
so — it now describes what the application does rather than what it fails to do.

Tool rename completed as a clean break, no aliases, since there are three users and
six sessions to preserve: arango_search -> search_speeches (it has queried Postgres
since the Arango migration and the name was pure debt), search_motions ->
search_documents, fetch_motion -> fetch_document.

Also renamed fetch_documents -> fetch_speeches. It fetches speeches despite the name,
and sitting next to the new fetch_document it was a trap the model would fall into.

Note for anyone with an MCP client configured against the old names: riksdagen_mcp
exposes these, so the names it advertises have changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Lasse Edfast 1 week ago
parent 794dfc3d31
commit bad2ad89e3
  1. 34
      SECURITY.md
  2. 27
      _postgres/_postgres.py
  3. 46
      backend/services/chat.py
  4. 132
      backend/services/llm_tools.py
  5. 32
      backend/services/mp_chat.py
  6. 6
      backend/services/research/board.py
  7. 4
      backend/services/research/trip.py
  8. 6
      backend/services/text.md
  9. 2
      content/sv/user-guide.md
  10. 4
      frontend/src/components/ChatPanel.tsx
  11. 26
      prompts/sv/chat/orchestrator.md
  12. 4
      prompts/sv/chat/researcher.md
  13. 8
      riksdagen_mcp/server.py
  14. 2
      tests/test_provenance.py

@ -5,20 +5,29 @@
Open a private issue on the repository, or contact the maintainer directly rather
than filing a public issue. Please allow reasonable time for a fix before disclosing.
## Known issue: `database_query` executes model-authored SQL
## Model-authored SQL
The `database_query` tool lets the model write and run SQL so it can answer
aggregate questions ("how many speeches per party mentioned X?"). The SQL is passed
to the database as written. There is currently **no `SELECT`-only enforcement in the
application**.
The `database_query` tool lets the model write and run SQL so it can answer aggregate
questions ("how many speeches per party mentioned X?"). This matters more than it
might appear, because the corpus reaches the model's context: speech and document
text is fetched and shown to it, so anyone who can get text into the parliament's
record — which, in a parliament, is the point — can attempt a prompt injection.
This matters more than it might appear, because the corpus itself reaches the model's
context. Speech and document text is fetched and shown to the model, so anyone who
can get text into the parliament's record — which, in a parliament, is the point —
can attempt a prompt injection.
Two layers apply, and the second is the one that counts:
**Run plenum against a database role that only has `SELECT`.** The application does
not do this for you:
1. Statements are refused unless they begin with `SELECT` or `WITH`, and multiple
statements are rejected outright — that is how a write gets smuggled in behind a
leading `SELECT`.
2. The query runs inside a `SET TRANSACTION READ ONLY` transaction, so PostgreSQL
itself rejects `INSERT`, `UPDATE`, `DELETE` and DDL regardless of what the first
check concluded.
The same applies to `share_insight`, which re-executes SQL stored in saved
conversations — replayed SQL is no more trustworthy than freshly generated SQL.
**Still run plenum against a database role that only has `SELECT`.** Defence in depth
means not relying on any single layer, and the ingest pipeline needs write access
that the web application never should have:
```sql
CREATE ROLE plenum_ro LOGIN PASSWORD '...';
@ -30,9 +39,6 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO plenum_ro;
The ingest pipeline needs write access and should use a separate role.
Enforcing this in the application — a read-only transaction plus a statement-type
check on the tool path — is tracked and intended.
## API keys
Users may supply their own model provider key in the browser. That key is held only

@ -144,6 +144,33 @@ class Postgres:
finally:
self._put_conn(conn)
def execute_readonly(self, query: str, params: Optional[tuple] = None) -> List[dict]:
"""Run a query inside a read-only transaction.
For SQL the application did not write currently the `database_query` tool,
where the statement is composed by a language model whose context includes
corpus text that anyone able to speak in parliament can influence.
`SET TRANSACTION READ ONLY` is enforced by PostgreSQL itself, so it holds
even if a statement slips past the caller's own checks: INSERT, UPDATE,
DELETE, DROP and friends all raise instead of executing. It is not a
substitute for connecting as a role that only has SELECT see SECURITY.md
but it is the part the application can guarantee on its own.
"""
conn = self._get_conn()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SET TRANSACTION READ ONLY")
cur.execute(query, params)
rows = [dict(row) for row in cur.fetchall()] if cur.description else []
conn.rollback() # nothing to commit; ends the transaction cleanly
return rows
except Exception:
conn.rollback()
raise
finally:
self._put_conn(conn)
def execute_void(self, query: str, params: Optional[tuple] = None) -> None:
"""
Execute a query that returns no rows (INSERT/UPDATE/DELETE).

@ -70,17 +70,17 @@ def _is_duplicate_insight(candidate: str, sent: List[str], threshold: float = 0.
# Per-tool nudges appended to the cached-result message when the model repeats
# an identical call, steering it toward a genuinely different next step.
_DEDUP_HINTS = {
"arango_search": "Vary the keywords/filters, or try vector_search for semantic matching, or database_query for counts.",
"vector_search": "Rephrase the query as a content statement, or use arango_search with metadata filters.",
"search_speeches": "Vary the keywords/filters, or try vector_search for semantic matching, or database_query for counts.",
"vector_search": "Rephrase the query as a content statement, or use search_speeches with metadata filters.",
"vector_search_debates": "Pick a debate from the previous result and call fetch_debate, or vary the query.",
"fetch_debate": "You already have this debate's speeches — read specific speeches with read_documents_for instead.",
"fetch_documents": "You already have these documents. Use read_documents_for with a focused question if you need their substance.",
"fetch_speeches": "You already have these documents. Use read_documents_for with a focused question if you need their substance.",
"read_documents_for": "Ask a DIFFERENT question or read different documents.",
"database_query": "The result will not change — use the rows you already received.",
"lookup_source": "You already recalled these sources; the text is in your history.",
"search_motions": "Vary the keywords/filters, or try vector_search_motions for semantic matching, or database_query for counts.",
"vector_search_motions": "Rephrase the query as a content statement, or use search_motions with metadata filters.",
"fetch_motion": "You already have this motion — use read_documents_for with a focused question if you need its substance.",
"search_documents": "Vary the keywords/filters, or try vector_search_documents for semantic matching, or database_query for counts.",
"vector_search_documents": "Rephrase the query as a content statement, or use search_documents with metadata filters.",
"fetch_document": "You already have this motion — use read_documents_for with a focused question if you need its substance.",
}
@ -855,16 +855,16 @@ class ChatService:
# research sessions. Fires only for data tools that returned
# something useful, mirroring the orchestrator-loop behaviour.
_DATA_TOOLS = {
"arango_search",
"search_speeches",
"vector_search",
"vector_search_debates",
"fetch_debate",
"database_query",
"fetch_documents",
"fetch_speeches",
"read_documents_for",
"search_motions",
"vector_search_motions",
"fetch_motion",
"search_documents",
"vector_search_documents",
"fetch_document",
}
result_is_useful = (
tool_name in _DATA_TOOLS
@ -1229,7 +1229,7 @@ class ChatService:
if not speech_id:
continue
# Body = grounding text the LLM should be able to recall via
# lookup_source. Prefer full text (fetch_documents), fall back to
# lookup_source. Prefer full text (fetch_speeches), fall back to
# snippet (vector_search neighbours, summary, etc.). The registry
# caps it at BODY_CAP_CHARS.
body_text = hit.text or hit.snippet or ""
@ -1361,7 +1361,7 @@ class ChatService:
"Du har tidigare delat sökresultat med användaren. "
"Listan `focus_ids` innehåller deras dokument-id:n:\n"
f"{active_focus_ids}\n"
"Om du vill begränsa en ny arango_search till samma träffar anger du argumentet "
"Om du vill begränsa en ny search_speeches till samma träffar anger du argumentet "
"`focus_ids=focus_ids`."
),
}
@ -1626,14 +1626,14 @@ class ChatService:
structured, registry, tool_name
)
# Replace tool_result with structured data for search tools.
# fetch_documents returns a plain list and fetch_debate returns
# fetch_speeches returns a plain list and fetch_debate returns
# a dict with debate-level metadata; for both we keep the
# original return value so the eviction step (else branch)
# can read it alongside the structured HitsResponse.
# read_documents_for returns the distilled answer itself —
# replacing it with the structured hits would destroy it.
if structured is not None and tool_name not in (
"fetch_documents", "fetch_debate", "read_documents_for"
"fetch_speeches", "fetch_debate", "read_documents_for"
):
tool_result = structured
@ -1717,7 +1717,7 @@ class ChatService:
)
else:
# fetch_documents and fetch_debate both produce a structured
# fetch_speeches and fetch_debate both produce a structured
# HitsResponse on the side; we evict the bodies and keep a
# one-line stub. fetch_debate also has debate-level metadata
# (summary, note, num_talks) worth preserving.
@ -1751,7 +1751,7 @@ class ChatService:
# Route long results through the fast model for summarization.
# HitsResponse results are already per-document summarised above.
# Plain strings (database_query, fetch_documents) may still be large.
# Plain strings (database_query, fetch_speeches) may still be large.
if len(tool_result_string) > SUMMARIZE_THRESHOLD:
tool_result_string = self._summarize_tool_result(
tool_name, tool_result_string, user_question, fast_llm=_fast
@ -1796,7 +1796,7 @@ class ChatService:
})
# ── LLM misbehaviour detection ────────────────────────────────────
_SEARCH_TOOLS = {"arango_search", "vector_search", "vector_search_debates", "search_motions", "vector_search_motions"}
_SEARCH_TOOLS = {"search_speeches", "vector_search", "vector_search_debates", "search_documents", "vector_search_documents"}
_args_json = json.dumps(tool_args, sort_keys=True, default=str)
_result_empty = not tool_result_string.strip() or tool_result_string.startswith("ERROR")
_model_name = getattr(_smart, "model", None)
@ -1832,16 +1832,16 @@ class ChatService:
)
# After data-returning tools, check if the shadow communicator should share an insight.
data_tools = {
"arango_search",
"search_speeches",
"vector_search",
"vector_search_debates",
"fetch_debate",
"database_query",
"fetch_documents",
"fetch_speeches",
"read_documents_for",
"search_motions",
"vector_search_motions",
"fetch_motion",
"search_documents",
"vector_search_documents",
"fetch_document",
}
result_is_useful = (
tool_name in data_tools

@ -2,17 +2,17 @@
LLM tool implementations for the Riksdagen chat service.
Surface exposed to the orchestrator LLM:
- arango_search PostgreSQL full-text + metadata filters (SearchService)
- search_speeches PostgreSQL full-text + metadata filters (SearchService)
- vector_search unified chunk + summary semantic search, merged by speech_id
- vector_search_debates debate-level discovery (navigation, not citable)
- fetch_debate drill into one debate, return its speeches with summaries
- fetch_documents full-text retrieval by id list
- fetch_speeches full-text retrieval by id list
- read_documents_for focused sub-agent read: full texts in, short answer out
- database_query direct SQL for aggregations
- share_insight side-channel to surface findings to the user mid-loop
- search_motions full-text + metadata search over motioner (MotionSearchService)
- vector_search_motions semantic chunk search over motioner
- fetch_motion one motion: metadata, authors, yrkanden + outcomes, text
- search_documents full-text + metadata search over motioner (MotionSearchService)
- vector_search_documents semantic chunk search over motioner
- fetch_document one motion: metadata, authors, yrkanden + outcomes, text
"""
import json
@ -27,7 +27,41 @@ from packages.colorprinter import *
from pgvector.psycopg2 import register_vector
from pydantic import BaseModel, Field
import re as _re_guard
from packages.llm import LLM, get_tools, register_tool
# Statements the model is allowed to run. Anything else is refused before it
# reaches the database.
#
# This is defence in depth, not the defence: pg.execute_readonly() opens a
# READ ONLY transaction, so PostgreSQL rejects writes even if a statement gets
# past this check. The check exists to give the model a clear, correctable error
# instead of a database exception, and to catch multi-statement payloads.
_ALLOWED_SQL = _re_guard.compile(r"^\s*(?:WITH|SELECT)\b", _re_guard.IGNORECASE)
def _reject_unsafe_sql(sql: str) -> str | None:
"""Return an error message if this SQL must not run, else None.
Corpus text reaches the model's context, and in a parliament anyone able to
speak can get text into the corpus so treat generated SQL as untrusted.
"""
stripped = sql.strip().rstrip(";").strip()
if not _ALLOWED_SQL.match(stripped):
first = (stripped.split() or ["(empty)"])[0]
return (
f"REFUSED: only SELECT and WITH queries may run, got {first!r}. "
f"This tool is read-only."
)
# A second statement is how a write gets smuggled past a leading SELECT.
if ";" in stripped:
return (
"REFUSED: multiple statements are not allowed. "
"Send one SELECT (a WITH clause may precede it)."
)
return None
from backend.services.search import MotionSearchService, SearchService
from postgres_client import pg
from prompts_loader import load_prompt
@ -99,7 +133,7 @@ class HitsResponse(BaseModel):
class SearchHitsResult(BaseModel):
"""Returned by arango_search. Wraps HitsResponse with search metadata."""
"""Returned by search_speeches. Wraps HitsResponse with search metadata."""
type: str = "hits"
response: HitsResponse
stats: Dict[str, Any] = Field(default_factory=dict)
@ -154,7 +188,7 @@ def database_query(sql: str) -> str:
Use this tool for structured queries on metadata: party breakdowns, aggregations,
speaker statistics, and comparisons. Not for fuzzy/semantic search (use vector_search
or arango_search instead).
or search_speeches instead).
WHEN TO USE:
- Counting or ranking: "how many speeches per party?", "top 10 speakers by year?"
@ -163,8 +197,8 @@ def database_query(sql: str) -> str:
WHEN NOT TO USE:
- Semantic/conceptual search use vector_search
- Exact phrase or keyword search use arango_search
- Fetching full documents use fetch_documents
- Exact phrase or keyword search use search_speeches
- Fetching full documents use fetch_speeches
DATABASE SCHEMA:
@ -324,8 +358,13 @@ def database_query(sql: str) -> str:
print_red(f"[database_query] Blocked LIKE on text column: {sql[:120]}")
return msg
refusal = _reject_unsafe_sql(sql)
if refusal:
print_red(f"[database_query] {refusal} | {sql[:120]}")
return refusal
try:
rows = pg.execute(sql)
rows = pg.execute_readonly(sql)
except Exception as e:
print_red(f"[database_query] Error: {e}")
return f"ERROR executing SQL: {e}"
@ -344,7 +383,7 @@ def database_query(sql: str) -> str:
# Enrich with person_id when rows have speaker_name but no person_id.
# This lets the shadow communicator attach speaker portraits to stats insights,
# and gives the main LLM the IDs for future arango_search(person_ids=...) calls.
# and gives the main LLM the IDs for future search_speeches(person_ids=...) calls.
rows_list = rows if isinstance(rows, list) else ([rows] if isinstance(rows, dict) else [])
if (
rows_list
@ -407,9 +446,9 @@ def vector_search(query: str, limit: int = 10) -> HitsResponse:
- You want a blended view of both whole-talk overview and specific passages.
When NOT to use:
- Exact word/phrase matching use arango_search
- Exact word/phrase matching use search_speeches
- Counts, aggregations, statistics use database_query
- You already know the speaker/party/year filter use arango_search with filters
- You already know the speaker/party/year filter use search_speeches with filters
Args:
query: Natural-language description of the topic.
@ -563,7 +602,7 @@ def vector_search_debates(query: str, limit: int = 5) -> HitsResponse:
- You want a quick map of which debates touched a topic before drilling in.
When NOT to use:
- You want individual speech hits use vector_search or arango_search
- You want individual speech hits use vector_search or search_speeches
- You already have a debate_id call fetch_debate directly
- Counts/aggregations use database_query
@ -743,7 +782,7 @@ def fetch_debate(debate_id: str, query: Optional[str] = None) -> dict:
f"Debate has {len(talk_rows)} speeches (combined summaries "
f"{total_summary_chars} chars). Returned the {len(trimmed_rows)} "
f"most relevant to query '{query}'; {omitted} speeches omitted. "
f"Use fetch_documents with specific ids for full texts."
f"Use fetch_speeches with specific ids for full texts."
)
else:
reason = (
@ -755,7 +794,7 @@ def fetch_debate(debate_id: str, query: Optional[str] = None) -> dict:
f"Debate has {len(talk_rows)} speeches (combined summaries "
f"{total_summary_chars} chars). Returned the first "
f"{len(trimmed_rows)} summarised speeches ({reason}); "
f"{omitted} speeches omitted. Use fetch_documents for full texts."
f"{omitted} speeches omitted. Use fetch_speeches for full texts."
)
# Build a compact dict for the LLM; register the speeches as provenance sources.
@ -806,20 +845,20 @@ def fetch_debate(debate_id: str, query: Optional[str] = None) -> dict:
# ─────────────────────────────────────────────────────────────────────────────
# fetch_documents
# fetch_speeches
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
def fetch_documents(_ids: list[str], collection: str = "", fields: list = []) -> list:
def fetch_speeches(_ids: list[str], collection: str = "", fields: list = []) -> list:
"""
Fetch full documents by their id from the speeches table.
Use this tool when:
- arango_search or vector_search returned _ids and you need the full speech text.
- search_speeches or vector_search returned _ids and you need the full speech text.
- You want specific fields for a known set of documents.
When NOT to use:
- To search use arango_search or vector_search
- To search use search_speeches or vector_search
- To count/aggregate use database_query
Args:
@ -944,14 +983,14 @@ def read_documents_for(question: str, _ids: list[str]) -> str:
- A snippet or summary is too sparse and you would otherwise fetch full text.
When NOT to use:
- To search use arango_search or vector_search.
- When the user explicitly asks to see the complete raw text fetch_documents.
- To search use search_speeches or vector_search.
- When the user explicitly asks to see the complete raw text fetch_speeches.
Args:
question: One concrete question in Swedish, e.g.
"Vilka argument anför talarna mot höjd bensinskatt?"
_ids: 1-6 document IDs from earlier search results
(e.g. ["H40911", "speeches/H40912"]). Motion ids from search_motions
(e.g. ["H40911", "speeches/H40912"]). Motion ids from search_documents
(e.g. "documents/HD02846") work too the full motion text is read.
Returns:
@ -1133,7 +1172,7 @@ def _normalize_arango_search_args(
@register_tool()
def arango_search(
def search_speeches(
query: str,
parties: Optional[list[str]] = None,
people: Optional[list[str]] = None,
@ -1316,7 +1355,7 @@ def arango_search(
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
def search_motions(
def search_documents(
query: str,
parties: Optional[list[str]] = None,
people: Optional[list[str]] = None,
@ -1329,10 +1368,10 @@ def search_motions(
) -> "SearchHitsResult":
"""
Full-text and metadata search over MOTIONER (written proposals submitted by MPs),
as opposed to arango_search which searches chamber SPEECHES (anföranden).
as opposed to search_speeches which searches chamber SPEECHES (anföranden).
Speeches (anföranden) are the PRIMARY source search them first with
arango_search/vector_search. Use this tool as a COMPLEMENT: to deepen research
search_speeches/vector_search. Use this tool as a COMPLEMENT: to deepen research
with the concrete proposals (yrkanden) behind positions found in speeches, to
add committee/chamber outcomes, or when the user explicitly asks about motioner
("vad har X föreslagit/motionerat om?", "vilka motioner finns om Y?").
@ -1354,8 +1393,8 @@ def search_motions(
committee (committee), session_label (riksmöte) and num_proposals. Cite with [src:DOK_ID].
When NOT to use:
- Chamber speeches/debates arango_search or vector_search
- Fuzzy/semantic similarity over documents vector_search_motions
- Chamber speeches/debates search_speeches or vector_search
- Fuzzy/semantic similarity over documents vector_search_documents
- Counts/aggregations database_query (documents table)
"""
if person_ids:
@ -1401,7 +1440,7 @@ def search_motions(
except json.JSONDecodeError:
focus_id_list = [focus_ids]
print_yellow(f"[Tools] search_motions → query='{query}' limit={args['limit']}")
print_yellow(f"[Tools] search_documents → query='{query}' limit={args['limit']}")
search_service = MotionSearchService()
results, stats, limit_reached = search_service.search(
@ -1419,7 +1458,7 @@ def search_motions(
return_snippets=False,
)
# Same size guard as arango_search: fall back to snippets when the combined
# Same size guard as search_speeches: fall back to snippets when the combined
# full texts would blow up the orchestrator context.
total_text_chars = sum(len(item.get("text") or "") for item in results if isinstance(item, dict))
auto_snippet_mode = total_text_chars > 20_000
@ -1467,7 +1506,7 @@ def search_motions(
f"NOTE: The full texts of these {len(hits)} results total {total_text_chars:,} characters "
f"(exceeds 20 000), so only snippets are shown above. "
"You can either:\n"
" 1. Pick specific motion IDs and call fetch_motion(doc_id) for the full text, or\n"
" 1. Pick specific motion IDs and call fetch_document(doc_id) for the full text, or\n"
" 2. Repeat the search with a lower `limit` to reduce the result set."
)
output = output + "\n\n---\n\n" + note
@ -1475,11 +1514,11 @@ def search_motions(
@register_tool()
def vector_search_motions(query: str, limit: int = 10) -> HitsResponse:
def vector_search_documents(query: str, limit: int = 10) -> HitsResponse:
"""
Semantic/conceptual search over MOTIONER (written proposals from MPs), using
chunk embeddings of the motion texts. Complements search_motions the same way
vector_search complements arango_search.
chunk embeddings of the motion texts. Complements search_documents the same way
vector_search complements search_speeches.
Speeches (anföranden) are the PRIMARY source search them first. Use this
tool as a complement when:
@ -1489,7 +1528,7 @@ def vector_search_motions(query: str, limit: int = 10) -> HitsResponse:
- You want documents similar in meaning to a phrase or idea.
When NOT to use:
- Exact word/phrase matching in documents search_motions
- Exact word/phrase matching in documents search_documents
- Chamber speeches vector_search
- Counts/aggregations database_query
@ -1504,7 +1543,7 @@ def vector_search_motions(query: str, limit: int = 10) -> HitsResponse:
says which ("yrkande" or "text"). Hit ids look like "documents/HD02846";
cite with [src:DOK_ID].
"""
print_yellow(f"[Tools] vector_search_motions → query='{query}' (top_k={limit})")
print_yellow(f"[Tools] vector_search_documents → query='{query}' (top_k={limit})")
query_vec = pg.make_embeddings([query])[0]
@ -1634,13 +1673,13 @@ _YRKANDE_KEYS = (
@register_tool()
def fetch_motion(doc_id: str) -> dict:
def fetch_document(doc_id: str) -> dict:
"""
Fetch one motion by its doc_id: metadata, all authors, all yrkanden (proposals)
with committee and chamber outcomes, and the full text.
Typical flow: search_motions / vector_search_motions pick a hit
fetch_motion(doc_id) to read the yrkanden and full text.
Typical flow: search_documents / vector_search_documents pick a hit
fetch_document(doc_id) to read the yrkanden and full text.
Args:
doc_id: Motion id, e.g. "HD02846" or "documents/HD02846".
@ -1657,7 +1696,7 @@ def fetch_motion(doc_id: str) -> dict:
note (optional): present when the motion only exists as a scanned PDF.
"""
bare_id = doc_id.split("/", 1)[1] if "/" in doc_id else doc_id
print_yellow(f"[Tools] fetch_motion → doc_id='{bare_id}'")
print_yellow(f"[Tools] fetch_document → doc_id='{bare_id}'")
rows = pg.execute(
"""
@ -1768,7 +1807,7 @@ def share_insight(
portraits can be highlighted visually.
hit_ids (list[str]): Optional. Talk IDs to surface as a search card (backend fetches
metadata). Pass the talk IDs (e.g. ["H40911", "H40912"]) you saw in a previous
arango_search or vector_search result. The backend fetches speaker/party/date/
search_speeches or vector_search result. The backend fetches speaker/party/date/
summary for each ID automatically you do NOT need to copy the data yourself.
sql (str): Optional. SQL query to re-execute for a stats card (preferred for surfacing
stats tables). Re-pass the same SQL query you used in database_query (or a simplified
@ -1853,8 +1892,13 @@ def share_insight(
# Resolve sql → rows by re-executing the query
if sql and not rows:
# Replayed from a saved snapshot, so it is no more trusted than fresh output.
refusal = _reject_unsafe_sql(sql)
if refusal:
print_red(f"[share_insight] {refusal}")
return refusal
try:
rows = pg.execute(sql)
rows = pg.execute_readonly(sql)
except Exception as e:
print_red(f"[share_insight] Failed to execute sql: {e}")
rows = [{"error": str(e)}]
@ -1912,7 +1956,7 @@ def share_insight(
def lookup_source(source_ids: list[str]) -> str:
"""Återhämta lagrad grundtext för en eller flera tidigare registrerade källor.
Sökverktyg (`arango_search`, `vector_search`, `fetch_debate`, `fetch_documents`)
Sökverktyg (`search_speeches`, `vector_search`, `fetch_debate`, `fetch_speeches`)
komprimeras automatiskt i meddelandehistoriken: bara `[src:ID]` plus en kort
rubrikrad sparas. När du behöver det faktiska textinnehållet (t.ex. för att
citera ordagrant eller verifiera ett påstående) anropa det här verktyget

@ -100,20 +100,20 @@ Om du inte har tillräckligt med material: anropa nästa verktyg direkt.
**Steg 1 Sök {first_name}s egna anföranden (ALLTID första steget):**
```
arango_search(query="<ämne>", person_ids=["{person_id}"], return_snippets=True, limit=10)
search_speeches(query="<ämne>", person_ids=["{person_id}"], return_snippets=True, limit=10)
```
`return_snippets=True` ger bara korta utdrag bra för att se om det finns träffar.
**Om du hittar relevanta träffar MÅSTE du sedan hämta full text med fetch_documents.**
**Om du hittar relevanta träffar MÅSTE du sedan hämta full text med fetch_speeches.**
**Steg 2 Hämta full text för de mest relevanta träffarna:**
```
fetch_documents(_ids=["<_id från steg 1>", ...])
fetch_speeches(_ids=["<_id från steg 1>", ...])
```
Utan full text kan du inte citera korrekt. Hoppa inte över detta steg.
**Steg 3 Om < 3 relevanta träffar {first_name}:** sök automatiskt partiets linje (ingen fråga till användaren):
```
arango_search(query="<ämne>", parties=["{party}"], limit=5)
search_speeches(query="<ämne>", parties=["{party}"], limit=5)
```
Notera: utan `return_snippets=True` får du full text direkt du behöver inte hämta separat.
@ -150,7 +150,7 @@ Inkludera inline-källhänvisningar i formatet [src:ID] direkt efter påstående
def _collect_sources_from_payload(payload: Dict[str, Any], collected_sources: List[ChatSource]) -> None:
"""Extract sources from an arango_search payload dict and append to collected_sources."""
"""Extract sources from an search_speeches payload dict and append to collected_sources."""
results = payload.get("results", [])
for item in results:
if not isinstance(item, dict):
@ -298,8 +298,8 @@ class MpChatService:
search_reminder = ""
else:
search_reminder = (
f"\n\n**OBLIGATORISKT:** Anropa arango_search med person_ids=[\"{self.person_id}\"] "
f"INNAN du svarar. Hämta sedan full text med fetch_documents om du bara fick snippets. "
f"\n\n**OBLIGATORISKT:** Anropa search_speeches med person_ids=[\"{self.person_id}\"] "
f"INNAN du svarar. Hämta sedan full text med fetch_speeches om du bara fick snippets. "
f"Alla sakpåståenden MÅSTE grunda sig i faktiska anföranden du hittat via sök."
)
@ -477,8 +477,8 @@ class MpChatService:
tool_name, tool_args, tool_result, collected_sources, collected_persons
)
# ── Guard: arango_search without person/party filter ────────
if tool_name == "arango_search" and isinstance(tool_args, dict):
# ── Guard: search_speeches without person/party filter ────────
if tool_name == "search_speeches" and isinstance(tool_args, dict):
has_person = bool(
tool_args.get("person_ids") or tool_args.get("people")
)
@ -493,7 +493,7 @@ class MpChatService:
)
# ── Guard: snippets only → must fetch full text before citing ──
if tool_name == "arango_search" and isinstance(tool_args, dict) and tool_args.get("return_snippets"):
if tool_name == "search_speeches" and isinstance(tool_args, dict) and tool_args.get("return_snippets"):
result_ids = []
if isinstance(tool_result, dict):
results_list = tool_result.get("results") or tool_result.get("payload", {}).get("results", [])
@ -505,7 +505,7 @@ class MpChatService:
tool_result_string += (
f"\n\n[SYSTEMINFO: Du sökte med return_snippets=True och fick bara utdrag. "
f"Om du vill basera svaret på dessa anföranden MÅSTE du hämta full text först: "
f"fetch_documents(_ids={result_ids[:5]})]"
f"fetch_speeches(_ids={result_ids[:5]})]"
)
if len(tool_result_string) > SUMMARIZE_THRESHOLD:
@ -526,7 +526,7 @@ class MpChatService:
"\n\n**KRITISKT:** Antingen anropar du nästa verktyg NU, eller ger du ditt slutsvar. "
"Du får INTE fråga användaren om du ska söka mer — bara sök. "
"Du får INTE beskriva vad du planerar att göra. "
"Om materialet är otillräckligt: anropa arango_search med parties eller vector_search direkt. "
"Om materialet är otillräckligt: anropa search_speeches med parties eller vector_search direkt. "
"I slutsvaret: svara som {name} i första person och inkludera [src:ID]-citat direkt efter varje påstående. "
"ID:n hittar du i verktygsresultaten ovan (t.ex. [src:H40911]). "
"Avsluta INTE med en separat 'Källor'-sektion."
@ -581,26 +581,26 @@ class MpChatService:
and side-effect: append sources to collected_sources and persons to
collected_persons when applicable.
arango_search has three possible return shapes:
search_speeches has three possible return shapes:
1. Normal call (no flags): returns payload dict directly
{ "results": [...], "stats": {...}, "limit_reached": bool, ... }
2. surface_results=True: {"type": "search_results", "payload": {...}, "surface_only": True}
3. results_to_user=True: {"type": "search_results", "payload": {...}}
"""
if not isinstance(tool_result, dict):
# str from vector_search, list from fetch_documents, etc.
# str from vector_search, list from fetch_speeches, etc.
return str(tool_result)
t = tool_result.get("type")
# ── arango_search: wrapped return (surface_results / results_to_user) ──
# ── search_speeches: wrapped return (surface_results / results_to_user) ──
if t == "search_results":
payload = tool_result.get("payload", {})
_collect_sources_from_payload(payload, collected_sources)
_collect_persons_from_results(payload.get("results", []), collected_persons)
return json.dumps(payload, ensure_ascii=False)
# ── arango_search: direct payload (normal call, no special flags) ──
# ── search_speeches: direct payload (normal call, no special flags) ──
if "results" in tool_result and isinstance(tool_result.get("results"), list):
_collect_sources_from_payload(tool_result, collected_sources)
_collect_persons_from_results(tool_result.get("results", []), collected_persons)

@ -445,13 +445,13 @@ def _search_material(query: str, seen_ids: set, *,
is what steers discovery toward party/issue threads instead of comparing
individual debates by date. ``debates_limit=0`` skips the debate summaries
(they carry no party attribution)."""
from backend.services.llm_tools import arango_search, vector_search_debates
from backend.services.llm_tools import search_speeches, vector_search_debates
parts: List[str] = []
# Speeches (anföranden): named speaker + party — the substance for positions.
try:
_tool_structured_result.set(None)
arango_search(query=query, return_snippets=True, limit=talks_limit)
search_speeches(query=query, return_snippets=True, limit=talks_limit)
structured = _tool_structured_result.get()
if isinstance(structured, SearchHitsResult) and structured.response.hits:
lines = ["ANFÖRANDEN (party — speaker_name: utdrag [id]):"]
@ -467,7 +467,7 @@ def _search_material(query: str, seen_ids: set, *,
if len(lines) > 1:
parts.append("\n".join(lines))
except Exception:
log.exception("discovery: arango_search failed")
log.exception("discovery: search_speeches failed")
# Debate summaries: topical context only (no party attribution).
if debates_limit > 0:
try:

@ -34,9 +34,9 @@ from prompts_loader import load_prompt
log = logging.getLogger("riksdagen.research.trip")
# Tools a trip may use. share_insight/lookup_source are chat-turn plumbing;
# fetch_documents dumps raw text — trips use read_documents_for instead.
# fetch_speeches dumps raw text — trips use read_documents_for instead.
RESEARCH_TOOLS = [
"arango_search",
"search_speeches",
"vector_search",
"vector_search_debates",
"fetch_debate",

@ -10,7 +10,7 @@ Important operational rules:
Decision / tool-selection map:
- Use `vector_search_talks(query, limit)` for semantic / concept matches (conceptual similarity, thematic clustering).
- Use `arango_search(query, parties, people, from_year, to_year, limit)` for ranked full-text searches (language-aware, boolean/phrase search, highlighted snippets).
- Use `search_speeches(query, parties, people, from_year, to_year, limit)` for ranked full-text searches (language-aware, boolean/phrase search, highlighted snippets).
- Use `aql_query(query)` for exact/structured queries, joins, and aggregations (you must write AQL; see the tool's docstring for templates).
- Use `fetch_document(_id)` to retrieve an entire document when you need the full text.
@ -45,7 +45,7 @@ Use this tiny decision map when choosing which tool to call.
- **Semantic / “meaning” search**`vector_search_talks(query, limit)`
Use when the user asks for conceptually similar speeches, thematic matches, or you want few high-relevance snippets to summarize or paraphrase.
- **Full-text + filters (language-aware, ranked)**`arango_search(query, parties, people, from_year, to_year, limit)`
- **Full-text + filters (language-aware, ranked)**`search_speeches(query, parties, people, from_year, to_year, limit)`
Use for Google-like queries with boolean operators, phrase search, party/speaker/year filters and highlighted snippets.
- **Exact/structured queries, aggregates, joins, date ranges**`aql_query(query)`
@ -56,7 +56,7 @@ Use this tiny decision map when choosing which tool to call.
## Handy one-line decision
- Need **meaning**`vector_search_talks`
- Need **ranked full-text+filters**`arango_search`
- Need **ranked full-text+filters**`search_speeches`
- Need **exact/aggregated/make statistics**`aql_query`
- Need **full document**`fetch_document`
```

@ -303,7 +303,7 @@ Välj **Standardmodell (serverns egen)** längst upp i providerlistan – chatte
- **Tankeläge:** Aktivt på första iterationen, inaktiverat på efterföljande för latensoptimering.
- **Planerare/Forskare:** `_plan_research` returnerar en `ResearchRequest` med 1–3 `SubQuestion`. Vid ≥2 subfrågor kör `_run_researcher` en avgränsad sökrunda per subfråga (max 5 verktygsanrop) och returnerar en `ResearchReport` (`SubFinding[]`) som injiceras i orkestratorns historik.
- **Quick-läge:** `quick=true` i `ChatRequest` skippar både planerare och forskare; orkestratorn svarar direkt.
- **Sökverktyg:** `arango_search` (FTS), `vector_search` (hybrid chunk+summary), `vector_search_debates`, `fetch_debate`, `database_query`, `fetch_documents`, `lookup_source` (registry-uppslag).
- **Sökverktyg:** `search_speeches` (FTS), `vector_search` (hybrid chunk+summary), `vector_search_debates`, `fetch_debate`, `database_query`, `fetch_speeches`, `lookup_source` (registry-uppslag).
- **Källvalidering:** `ProvenanceRegistry` mappar varje `[src:ID]` mot verkliga sökträffar; ogiltiga ID:n filtreras bort. Vid 100 % ogiltiga citat tvingas modellen söka om (max 2 omförsök).
- **Provenance-grounding:** Varje registrerad källa lagras med fullständig grundtext (cap 3 000 tecken). `lookup_source` returnerar max 5 id per anrop, max 1 500 tecken per kropp.
- **Eviction:** Råa verktygssvar för sökverktyg byts ut mot en kompakt `[src:ID]`-stub i orkestratorns historik. `HISTORY_CHAR_BUDGET = 50 000` tecken; över gränsen komprimeras äldsta stubbar till en placeholder.

@ -14,14 +14,14 @@ export const INITIAL_ASSISTANT_MESSAGE = "Hej! Ställ en fråga om protokollen s
const CHAT_REQUEST_TIMEOUT_MS = 360_000; // Allow up to six minutes for long-running tool calls.
const TOOL_HINTS: Record<string, string> = {
arango_search: "Söker i anföranden med fulltextsökning…",
search_speeches: "Söker i anföranden med fulltextsökning…",
vector_search: "Gör semantisk sökning i databasen…",
vector_search_debates: "Söker efter relevanta debatter…",
fetch_debate: "Hämtar debattens tal…",
aql_query: "Kör strukturerad databasfråga…",
database_query: "Kör strukturerad databasfråga…",
search_documents: "Analyserar och söker i dokumenten…",
fetch_documents: "Hämtar fullständiga dokument…",
fetch_speeches: "Hämtar fullständiga dokument…",
};
type Props = {

@ -12,23 +12,23 @@ The data in the database is correct, including party affiliations, dates, and sp
**Decision / tool-selection map (follow this strictly):**
1. `arango_search(query, people, parties, from_year, to_year, limit, return_snippets, intressent_ids)`
1. `search_speeches(query, people, parties, from_year, to_year, limit, return_snippets, person_ids)`
- Use for: finding speeches by keyword, phrase, person, party, or year.
- Supports: `intressent_ids=["012345678"]` and `people=["Helena Gellermann"]` to filter by speaker, `parties=["S","M"]` to filter by party.
- Use intressent_ids if you have them from earlier searches to find speeches by specific individuals, better than filtering by the `people` parameter.
- Supports: `person_ids=["012345678"]` and `people=["Helena Gellermann"]` to filter by speaker, `parties=["S","M"]` to filter by party.
- Use person_ids if you have them from earlier searches to find speeches by specific individuals, better than filtering by the `people` parameter.
- Use `return_snippets=True` for a quick overview.
- If a search returns fewer results than your requested limit, or if `limit reached: False`, it means you have retrieved all available documents. Do not repeat the same search with a higher limit.
2. `vector_search(query, limit)` — semantic/conceptual search.
- Use when keywords alone won't work (vague topics, synonyms, thematic clusters).
- Under the hood this blends chunk-level passages (quote-ready) with summary-level gists (thematic) and merges them by talk, so you get a mix in a single call. Each hit carries `source_type` in metadata: `"chunk"`, `"summary"`, or `"both"`.
- You do NOT need to choose between snippet- and summary-level searching; this tool does both. Use as a complement to `arango_search`, not a replacement.
- You do NOT need to choose between snippet- and summary-level searching; this tool does both. Use as a complement to `search_speeches`, not a replacement.
3. `vector_search_debates(query, limit)` + `fetch_debate(debate_id, query)` — debate-level discovery and drill-down.
- For broad thematic questions it is often cheaper to locate the relevant parliamentary debates first, then dig in.
- `vector_search_debates` returns ~5 debates with their summaries. The ids look like `"2021-06-17:42"` (bare date:index form). **Do not cite debates directly** — they are a navigation aid.
- Pick the best debate and call `fetch_debate(debate_id, query=<same query>)`. You get the debate summary plus a compact list of speeches (id, speaker_name, party, person_id, per-talk summary). **Pass the same query** — long debates are trimmed by semantic relevance to it; without a query, a chronological slice is returned and a `note` field tells you how many speeches were omitted. Cite the individual speeches with `[src:SPEECH_ID]` as usual.
- Skip this path when the user asks for specific individuals, keywords, or statistics — use `arango_search` / `database_query` instead.
- Skip this path when the user asks for specific individuals, keywords, or statistics — use `search_speeches` / `database_query` instead.
4. `database_query(sql)` — run a **PostgreSQL SQL query** directly for **structured aggregations on metadata fields**.
- Use for: count/rank by party, year, speaker, debate type — e.g. "how many speeches per party?" or "top 10 most active speakers in S?"
@ -50,11 +50,11 @@ The data in the database is correct, including party affiliations, dates, and sp
- Keep letters $preserve_characters as they are, if substituting with a a o there will be no hits for those words (this and other tools).
5. `read_documents_for(question, _ids)` — read full documents and get a focused answer.
- Use after `arango_search`, `vector_search`, or `fetch_debate` when you need to know what specific speeches actually SAY (positions, arguments, exact statements) — this is the default way to go deeper than snippets.
- Use after `search_speeches`, `vector_search`, or `fetch_debate` when you need to know what specific speeches actually SAY (positions, arguments, exact statements) — this is the default way to go deeper than snippets.
- A reading assistant reads the full texts (up to 6 ids) and returns a short grounded answer with `[src:ID]` tags and verbatim quotes. Ask ONE concrete question per call.
- Prefer this over `fetch_documents`: you get the substance without flooding your context with raw text.
- Prefer this over `fetch_speeches`: you get the substance without flooding your context with raw text.
6. `fetch_documents(_ids)` — fetch full raw document text by ID.
6. `fetch_speeches(_ids)` — fetch full raw document text by ID.
- Use ONLY when you truly need the complete verbatim text (e.g. the user explicitly asks to see a whole speech). For "what does the speech say about X?" use `read_documents_for` instead.
- Pass `fields=["text", "speaker_name", "person_id", "date"]` to keep the response compact.
@ -63,21 +63,21 @@ The data in the database is correct, including party affiliations, dates, and sp
- Call `lookup_source(["H40911", "GH09100"])` ONLY when you actually need the underlying text to quote verbatim or verify a specific claim. For most claims the eviction stub + your own notes are enough.
- **Maximum 5 source IDs per call.** Pick the few you really need; bodies are truncated to keep your context lean.
8. `search_motions(query, people, parties, from_year, to_year, limit, return_snippets, intressent_ids)` + `vector_search_motions(query, limit)` + `fetch_motion(doc_id)` — MOTIONER (written proposals from MPs).
8. `search_documents(query, people, parties, from_year, to_year, limit, return_snippets, person_ids)` + `vector_search_documents(query, limit)` + `fetch_document(doc_id)` — MOTIONER (written proposals from MPs).
- **Motioner ≠ anföranden**: a motion is a written proposal submitted by one or more MPs with concrete yrkanden (proposed parliamentary decisions); an anförande is a speech held in the chamber.
- **Anföranden are your PRIMARY source — search speeches first.** Motion tools are a SECONDARY, complementary source. Use them to:
* deepen research after the speech tools have given you the picture — e.g. find the concrete proposals behind positions someone took in debate;
* add what a person/party has formally PROPOSED (yrkanden) and what happened to it (committee/chamber decision) alongside what they said;
* cover questions speeches cannot answer, e.g. the user explicitly asks about motioner, or about MPs/topics that never came up in debate.
- Do NOT lead with motion tools for general questions ("vad tycker X om Y?") — start with `arango_search`/`vector_search`, then complement with documents when proposals matter for the answer.
- `search_motions` = keyword/FTS search (like `arango_search` but over documents; `parties`/`people` match any co-author). `vector_search_motions` = semantic search (like `vector_search`). Same query syntax and filters.
- `fetch_motion(doc_id)` returns the motion's metadata, all authors, all yrkanden with committee proposal (`committee_recommendation`) and chamber decision (`chamber_decision` — e.g. "Avslag"/"Bifall"), and the full text. Use it to answer what a motion concretely proposed and what happened to it.
- Do NOT lead with motion tools for general questions ("vad tycker X om Y?") — start with `search_speeches`/`vector_search`, then complement with documents when proposals matter for the answer.
- `search_documents` = keyword/FTS search (like `search_speeches` but over documents; `parties`/`people` match any co-author). `vector_search_documents` = semantic search (like `vector_search`). Same query syntax and filters.
- `fetch_document(doc_id)` returns the motion's metadata, all authors, all yrkanden with committee proposal (`committee_recommendation`) and chamber decision (`chamber_decision` — e.g. "Avslag"/"Bifall"), and the full text. Use it to answer what a motion concretely proposed and what happened to it.
- Motion hits are cited like speeches: `[src:HD02846]`. `read_documents_for` accepts motion ids too. In your answer, make clear which claims come from speeches and which from documents.
- Note: documents from before ~1995 may only exist as scanned PDFs (metadata present, `note` says fulltext saknas).
**Notes:**
- You may call **multiple tools in a single turn** — this is encouraged.
- `arango_search` with `return_snippets=True`: gives highlighted excerpts — use to quickly scan what topics appear before fetching full texts.
- `search_speeches` with `return_snippets=True`: gives highlighted excerpts — use to quickly scan what topics appear before fetching full texts.
- `focus_ids`: pass `focus_ids=focus_ids` to narrow the next search to previously found documents.
Once you have gathered enough information to fully answer the user's prompt, DO NOT call any more tools. Immediately output your final answer to the user.

@ -1,7 +1,7 @@
Du är en research-assistent som undersöker EN specifik delfråga i tal från svenska riksdagen.
Du har samma data-verktyg som huvudassistenten: arango_search, vector_search, vector_search_debates, fetch_debate, database_query, read_documents_for, fetch_documents, lookup_source, search_motions, vector_search_motions, fetch_motion.
Behöver du veta vad specifika tal faktiskt SÄGER — använd `read_documents_for(question, _ids)` (en läsassistent läser fulltexterna och svarar fokuserat) i stället för att hämta rå fulltext med fetch_documents.
Du har samma data-verktyg som huvudassistenten: search_speeches, vector_search, vector_search_debates, fetch_debate, database_query, read_documents_for, fetch_speeches, lookup_source, search_documents, vector_search_documents, fetch_document.
Behöver du veta vad specifika tal faktiskt SÄGER — använd `read_documents_for(question, _ids)` (en läsassistent läser fulltexterna och svarar fokuserat) i stället för att hämta rå fulltext med fetch_speeches.
Arbetssätt:
1. Läs delfrågan noga, planera sökningar.

@ -1,19 +1,19 @@
from fastmcp import FastMCP
from backend.services.llm_tools import (
arango_search,
search_speeches,
database_query,
fetch_debate,
fetch_documents,
fetch_speeches,
vector_search,
vector_search_debates,
)
mcp = FastMCP("riksdagen-tools")
mcp.tool()(arango_search)
mcp.tool()(search_speeches)
mcp.tool()(database_query)
mcp.tool()(fetch_debate)
mcp.tool()(fetch_documents)
mcp.tool()(fetch_speeches)
mcp.tool()(vector_search)
mcp.tool()(vector_search_debates)

@ -39,7 +39,7 @@ class TestNormalizeTalkId:
def _make_record(source_id="H40911", **kwargs):
defaults = dict(
tool="arango_search",
tool="search_speeches",
speaker="Test Speaker",
party="S",
date="2024-01-15",

Loading…
Cancel
Save