diff --git a/SECURITY.md b/SECURITY.md index 3044a62..6fc2c68 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/_postgres/_postgres.py b/_postgres/_postgres.py index d5a3ecd..5672383 100644 --- a/_postgres/_postgres.py +++ b/_postgres/_postgres.py @@ -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). diff --git a/backend/services/chat.py b/backend/services/chat.py index a2b71cf..6b5f556 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -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 diff --git a/backend/services/llm_tools.py b/backend/services/llm_tools.py index 79baf4f..dfab872 100644 --- a/backend/services/llm_tools.py +++ b/backend/services/llm_tools.py @@ -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 diff --git a/backend/services/mp_chat.py b/backend/services/mp_chat.py index e92bb82..3cb1a74 100644 --- a/backend/services/mp_chat.py +++ b/backend/services/mp_chat.py @@ -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 på {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) diff --git a/backend/services/research/board.py b/backend/services/research/board.py index 3a0b3d3..31f3999 100644 --- a/backend/services/research/board.py +++ b/backend/services/research/board.py @@ -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: diff --git a/backend/services/research/trip.py b/backend/services/research/trip.py index 9a9ba04..bbb40cc 100644 --- a/backend/services/research/trip.py +++ b/backend/services/research/trip.py @@ -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", diff --git a/backend/services/text.md b/backend/services/text.md index 8fb361a..0336414 100644 --- a/backend/services/text.md +++ b/backend/services/text.md @@ -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` ``` diff --git a/content/sv/user-guide.md b/content/sv/user-guide.md index 649a3dc..5640d0f 100644 --- a/content/sv/user-guide.md +++ b/content/sv/user-guide.md @@ -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. diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx index 7465371..19b5280 100644 --- a/frontend/src/components/ChatPanel.tsx +++ b/frontend/src/components/ChatPanel.tsx @@ -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 = { - 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 = { diff --git a/prompts/sv/chat/orchestrator.md b/prompts/sv/chat/orchestrator.md index 4df794f..16ad4ca 100644 --- a/prompts/sv/chat/orchestrator.md +++ b/prompts/sv/chat/orchestrator.md @@ -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=)`. 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. diff --git a/prompts/sv/chat/researcher.md b/prompts/sv/chat/researcher.md index e0dfaae..49eeff0 100644 --- a/prompts/sv/chat/researcher.md +++ b/prompts/sv/chat/researcher.md @@ -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. diff --git a/riksdagen_mcp/server.py b/riksdagen_mcp/server.py index a8cd31b..a3b4e65 100644 --- a/riksdagen_mcp/server.py +++ b/riksdagen_mcp/server.py @@ -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) diff --git a/tests/test_provenance.py b/tests/test_provenance.py index 88de198..44322fb 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -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",