Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

2022 lines
84 KiB

"""
LLM tool implementations for the Riksdagen chat service.
Surface exposed to the orchestrator LLM:
- 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_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_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
import os
import re
import time # Add this import for timing
from contextvars import ContextVar
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
import psycopg2.extras
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
# ─────────────────────────────────────────────────────────────────────────────
# Data models
# ─────────────────────────────────────────────────────────────────────────────
class HitDocument(BaseModel):
"""Normalized representation of a search hit across tools."""
id: Optional[str] = Field(default=None, description="Document id (e.g. 'speeches/H40911')")
key: Optional[str] = Field(default=None, description="Document key without collection prefix.")
speaker: Optional[str] = Field(default=None)
party: Optional[str] = Field(default=None)
date: Optional[str] = Field(default=None)
snippet: Optional[str] = Field(default=None)
text: Optional[str] = Field(default=None)
score: Optional[float] = Field(default=None)
metadata: Dict[str, Any] = Field(default_factory=dict)
def to_string(self, include_metadata: bool = True) -> str:
data: Dict[str, Any] = self.model_dump(exclude_none=True)
metadata: Dict[str, Any] = data.pop("metadata", {})
segments: List[str] = []
# Prepend source tag for citation tracking
bare_id = self.key or (self.id.split("/", 1)[1] if self.id and "/" in self.id else self.id)
if bare_id:
segments.append(f"[src:{bare_id}]")
for field_name, field_value in data.items():
segments.append(f"{field_name.upper()}\n{field_value}")
for meta_key, meta_value in metadata.items():
segments.append(f"{meta_key.upper()}\n{meta_value}")
return "\n\n".join(segments)
def _format_src_tag(
bare_id: str,
speaker: Optional[str] = None,
party: Optional[str] = None,
date: Optional[str] = None,
) -> str:
"""Return an enriched [src:...] tag that carries speaker/party/date inline.
The orchestrator uses bare [src:ID] tags for citation tracking; this richer
variant is injected after summarisation so the orchestrator still sees the
attribution metadata even if the fast model stripped the original tags.
"""
parts = [bare_id]
if speaker:
parts.append(speaker)
if party:
parts.append(party)
if date:
parts.append(str(date))
return f"[src:{' '.join(parts)}]"
class HitsResponse(BaseModel):
hits: List[HitDocument] = Field(default_factory=list)
def to_string(self, include_metadata: bool = True) -> str:
if not self.hits:
return ""
return "\n\n---\n\n".join(
hit.to_string(include_metadata=include_metadata) for hit in self.hits
)
class SearchHitsResult(BaseModel):
"""Returned by search_speeches. Wraps HitsResponse with search metadata."""
type: str = "hits"
response: HitsResponse
stats: Dict[str, Any] = Field(default_factory=dict)
focus_ids: List[str] = Field(default_factory=list)
limit_reached: bool = False
# Side-channel for passing structured hit results out of tool functions.
# The @register_tool() wrapper JSON-serialises the return value, so tools that
# want to hand structured data to ChatService store it here and return a plain
# string to the framework. ChatService reads this var immediately after the call.
_tool_structured_result: ContextVar[Optional[Any]] = ContextVar(
"_tool_structured_result", default=None
)
# Callback for share_insight to publish SSE events directly without returning
# a value. Set by the shadow communicator thread before calling share_insight.
# Using a ContextVar means each thread has its own copy, so threads don't
# interfere with each other.
_insight_callback: ContextVar[Optional[Any]] = ContextVar(
"_insight_callback", default=None
)
# Active provenance registry for the current chat turn. Set by ChatService
# before tool execution so tools (and `lookup_source` in particular) can read
# back grounding text by source ID without going through the message history.
_provenance_registry: ContextVar[Optional[Any]] = ContextVar(
"_provenance_registry", default=None
)
# Fast LLM for the current request. Set by ChatService next to the provenance
# registry so the reader sub-agent (`read_documents_for`) honours per-request
# provider overrides without the user's key ever being stored module-side.
_fast_llm_var: ContextVar[Optional[Any]] = ContextVar(
"_fast_llm_var", default=None
)
# ─────────────────────────────────────────────────────────────────────────────
# database_query
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
def database_query(sql: str) -> str:
"""Execute a SQL query against the Riksdag speeches database (PostgreSQL).
Args:
sql: A PostgreSQL SELECT query string.
Returns:
Query result formatted as a string (raw result or error message).
Use this tool for structured queries on metadata: party breakdowns, aggregations,
speaker statistics, and comparisons. Not for fuzzy/semantic search (use vector_search
or search_speeches instead).
✅ WHEN TO USE:
- Counting or ranking: "how many speeches per party?", "top 10 speakers by year?"
- Aggregations: votes per party, speeches over time periods, joins with demographics
- Full-text aggregations: "how many speeches per party mentioned AI?" (using FTS)
❌ WHEN NOT TO USE:
- Semantic/conceptual search → use vector_search
- Exact phrase or keyword search → use search_speeches
- Fetching full documents → use fetch_speeches
DATABASE SCHEMA:
speeches table:
id (TEXT) - Speech ID (e.g. 'H40911-1')
speaker_name (TEXT) - Speaker name
party (TEXT) - Party code (S, M, V, KD, C, MP, SD, L, FP)
year (INT) - Year of speech
date (DATE) - Date of speech (cast to text: date::text)
person_id (TEXT) - Speaker ID (join key to people table)
activity_type (TEXT) - Debate type/chamber activity
text (TEXT) - Full speech text (use search_vector for searching)
summary (TEXT) - Speech summary
sequence (INT) - Speech number within debate
debate (TEXT) - Debate name
is_reply (TEXT) - Reply indicator
tags (TEXT[]) - Tagged topics
related_doc_id (TEXT) - Related document ID
title (TEXT) - Speech title
people table:
person_id (TEXT) - Speaker ID (join key to speeches)
name (TEXT) - Canonical speaker name
party (TEXT) - Party affiliation
birth_year (INT) - Birth year
gender (TEXT) - Gender
active (BOOL) - Active status
constituency (TEXT) - Electoral district
debates table:
debate (TEXT, PK) - Debate id of form "{YYYY-MM-DD}:{n}", matches speeches.debate
date (DATE) - Debate date (cast to text: date::text)
summary (TEXT) - LLM-generated debate summary
num_talks (INT) - Number of speeches in the debate
talk_ids (TEXT[]) - Array of talk ids in the debate
Note: some rows have NULL summary/summary_embedding (still backfilling).
documents table (motioner — written proposals from MPs):
doc_id (TEXT, PK) - Motion id (e.g. 'HD02846')
session_label (TEXT) - Riksmöte (e.g. '2022/23')
year (INT) - Riksmöte start year
date (DATE) - Submission date (cast to text: date::text)
title (TEXT) - Motion title
subtype (TEXT) - e.g. 'Enskild motion', 'Kommittémotion', 'Partimotion'
committee (TEXT) - Committee it was referred to (e.g. 'AU', 'UU')
status (TEXT) - e.g. 'Klar', 'Inkommen'
parties (TEXT[]) - Party codes of all authors (use && for overlap: parties && ARRAY['S'])
author_names (TEXT[]) - Author names in signing order
num_proposals (INT) - Number of proposals in the motion
text (TEXT) - Full motion text (use search_vector for searching)
search_vector - FTS index over title + yrkanden + text: search_vector @@ websearch_to_tsquery('swedish', ...)
document_authors table (one row per signatory):
doc_id (TEXT) - Join key to documents
person_id (TEXT) - Join key to people (may be NULL for pre-2000 documents)
name (TEXT) - Author name
party (TEXT) - Party code
ordinal (INT) - Signing order (0 = first author)
document_proposals table (one row per formal proposal/yrkande — condensed & precise):
id (TEXT, PK) - "{doc_id}:{ordinal}"
doc_id (TEXT) - Join key to documents
number (TEXT) - Proposal number as stated in the motion
text (TEXT) - The proposal text itself (short, to the point)
committee_recommendation (TEXT) - Committee proposal (e.g. 'Avslag')
chamber_decision (TEXT) - Chamber decision (e.g. 'Avslag'/'Bifall')
handled_in (TEXT) - Committee report where handled
CRITICAL NOTES:
- Use ONLY the column names listed above (do NOT invent columns)
- For full-text search, use: search_vector @@ websearch_to_tsquery('swedish', 'query')
This uses the GIN index (fast); do NOT use LIKE/ILIKE on text (slow + wrong results)
- websearch_to_tsquery supports: plain words, "quoted phrases", OR, - (exclude), Swedish stemming
- NEVER put search_vector @@ tsquery in a SELECT or SUM/CASE — it runs per-row without
the index and causes 30-60 s queries. If you need two FTS counts, use two CTEs with WHERE:
WITH a AS (SELECT id, party FROM speeches WHERE search_vector @@ tsquery('q1')),
b AS (SELECT id FROM speeches WHERE search_vector @@ tsquery('q2'))
SELECT a.party, COUNT(*) total, COUNT(b.id) matches FROM a LEFT JOIN b USING(id) GROUP BY 1
- Join speeches and people: speeches.person_id = people.person_id
- Include `person_id` in SELECT when querying speeches to link back to speakers
EXAMPLES:
# Count speeches per party
SELECT party, COUNT(*) AS cnt FROM speeches GROUP BY party ORDER BY cnt DESC
# Top 10 speakers in a party
SELECT speaker_name, COUNT(*) AS cnt FROM speeches WHERE party = 'M'
GROUP BY speaker_name ORDER BY cnt DESC LIMIT 10
# Speeches per year for a party, with speaker birth year
SELECT t.year, p.birth_year, COUNT(*) AS cnt
FROM speeches t JOIN people p ON t.person_id = p.person_id
WHERE t.party = 'S' AND t.year >= 2015
GROUP BY t.year, p.birth_year ORDER BY t.year
# Count speeches mentioning a topic per party (using FTS with GIN index)
SELECT party, COUNT(*) AS cnt FROM speeches
WHERE search_vector @@ websearch_to_tsquery('swedish', 'artificiell intelligens OR AI')
GROUP BY party ORDER BY cnt DESC
# Count speeches about climate per year (FTS)
SELECT year, COUNT(*) AS cnt FROM speeches
WHERE search_vector @@ websearch_to_tsquery('swedish', 'klimat')
GROUP BY year ORDER BY year
# Count documents about nuclear power per party (any co-author's party counts)
SELECT unnest(parties) AS party, COUNT(*) AS cnt FROM documents
WHERE search_vector @@ websearch_to_tsquery('swedish', 'kärnkraft')
GROUP BY party ORDER BY cnt DESC
# Most active motion authors in a year
SELECT a.name, a.party, COUNT(*) AS cnt
FROM document_authors a JOIN documents m ON a.doc_id = m.doc_id
WHERE m.year = 2023 AND a.ordinal = 0
GROUP BY a.name, a.party ORDER BY cnt DESC LIMIT 10
To surface results to the user as a stats card, call share_insight(sql="...", message="...")
and pass the same SQL query; the backend re-executes it automatically.
"""
print_blue(f"[database_query] SQL:\n{sql}")
# Start timing the query execution
start_time = time.time()
import re as _re
# Guard: rewrite `text @@` → `search_vector @@`.
# The GIN index is on the stored tsvector column `search_vector`, not on `text`.
# Using `text @@ tsquery` triggers an implicit on-the-fly to_tsvector conversion
# with the default (not Swedish) text-search config → full table scan, 78+ seconds, empty results.
_rewritten = _re.sub(
r'\banforandetext\s*@@', 'search_vector @@', sql, flags=_re.IGNORECASE
)
if _rewritten != sql:
print_yellow(f"[database_query] Rewrote text @@ → search_vector @@ (uses GIN index)")
sql = _rewritten
# Guard: reject LIKE/ILIKE on full-text columns — these bypass the FTS index,
# cause slow sequential scans, and produce wrong results (e.g. 'ai' matches
# 'Thai', 'kai', 'Ukraine'). The correct operator is @@ with websearch_to_tsquery.
_text_cols = r"(text|summary)"
if _re.search(rf"\b{_text_cols}\b.*?\bI?LIKE\b", sql, _re.IGNORECASE | _re.DOTALL) or \
_re.search(rf"\bI?LIKE\b.*?\b{_text_cols}\b", sql, _re.IGNORECASE | _re.DOTALL):
msg = (
"TOOL USAGE ERROR: Do not use LIKE or ILIKE on 'text' or 'summary'"
"it is slow and produces wrong results. "
"To search speech content, use the FTS operator instead:\n"
" WHERE search_vector @@ websearch_to_tsquery('swedish', 'your query here')\n"
"This uses the GIN index and supports AND, OR, phrase search, and Swedish stemming. "
"Example for counting speeches about AI per party:\n"
" SELECT party, COUNT(*) AS cnt FROM speeches\n"
" WHERE search_vector @@ websearch_to_tsquery('swedish', 'artificiell intelligens OR AI')\n"
" GROUP BY party ORDER BY cnt DESC"
)
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_readonly(sql)
except Exception as e:
print_red(f"[database_query] Error: {e}")
return f"ERROR executing SQL: {e}"
# Calculate elapsed time and print it
elapsed_time = time.time() - start_time
print_yellow(f"[database_query] Query executed in {elapsed_time:.2f} seconds")
ROW_CAP = 50
truncated_note = ""
if isinstance(rows, list) and len(rows) > ROW_CAP:
truncated_note = f"\n[Result truncated: showing {ROW_CAP} of {len(rows)} rows. Refine your query if you need a different subset.]"
rows = rows[:ROW_CAP]
elif isinstance(rows, list) and len(rows) == 1:
rows = rows[0]
# 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 search_speeches(person_ids=...) calls.
rows_list = rows if isinstance(rows, list) else ([rows] if isinstance(rows, dict) else [])
if (
rows_list
and isinstance(rows_list[0], dict)
and "speaker_name" in rows_list[0]
and "person_id" not in rows_list[0]
):
names = list({r["speaker_name"] for r in rows_list if isinstance(r, dict) and r.get("speaker_name")})
try:
person_rows_extra = pg.execute(
"SELECT person_id, name FROM people WHERE name = ANY(%s)",
(names,),
)
name_to_iid = {r["name"]: r["person_id"] for r in person_rows_extra}
if name_to_iid:
if isinstance(rows, list):
rows = [
{**r, "person_id": name_to_iid[r["speaker_name"]]}
if isinstance(r, dict) and r.get("speaker_name") in name_to_iid
else r
for r in rows
]
elif isinstance(rows, dict) and rows.get("speaker_name") in name_to_iid:
rows = {**rows, "person_id": name_to_iid[rows["speaker_name"]]}
rows_list = rows if isinstance(rows, list) else [rows]
print_yellow(f"[database_query] Enriched {len(name_to_iid)} rows with person_id")
except Exception as e:
print_yellow(f"[database_query] person_id enrichment failed: {e}")
# Store rows for ChatService to populate collected_persons before shadow fires.
_tool_structured_result.set({"type": "db_rows", "rows": rows_list})
print_blue(f"[database_query] ---\n{sql}\n---")
result_str = f"SQL result: {rows}{truncated_note}"
print_blue(f"[database_query] Returning:\n{result_str[:200]}")
return result_str
# ─────────────────────────────────────────────────────────────────────────────
# vector_search (unified: speech_chunks + summaries, merged by speech_id)
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
def vector_search(query: str, limit: int = 10) -> HitsResponse:
"""
Semantic/conceptual search over Riksdag speeches. Blends two sources of signal
under the hood so the caller does not have to choose:
- chunk embeddings → granular, quote-ready passages
- summary embeddings → thematic, whole-speech gist
Results are merged by speech_id; when a talk is strong in both indexes it is
returned once with the chunk passage as the snippet and the summary attached
in metadata. Each hit is tagged with metadata["source_type"] ∈ {"chunk",
"summary", "both"} so you can tell which signal fired.
Use this tool when:
- The user asks a thematic or conceptual question and exact keywords may not appear.
- You want speeches similar in meaning to a phrase or idea.
- You want a blended view of both whole-talk overview and specific passages.
When NOT to use:
- Exact word/phrase matching → use search_speeches
- Counts, aggregations, statistics → use database_query
- You already know the speaker/party/year filter → use search_speeches with filters
Args:
query: Natural-language description of the topic.
limit: Number of merged hits to return (default 10).
Returns:
HitsResponse with the top-limit speeches scored by max(chunk, summary).
"""
print_yellow(f"[Tools] vector_search → query='{query}' (top_k={limit})")
query_vec = pg.make_embeddings([query])[0]
fetch_each = limit * 2 # oversample each index so the merge has room to dedupe
chunk_rows = pg.execute(
"""
SELECT id, speech_id, chunk_index, text,
1 - (embedding <=> %s::vector) AS score
FROM speech_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(query_vec, query_vec, fetch_each),
)
summary_rows = pg.execute(
"""
SELECT id, summary,
1 - (summary_embedding <=> %s::vector) AS score
FROM speeches
WHERE summary_embedding IS NOT NULL
ORDER BY summary_embedding <=> %s::vector
LIMIT %s
""",
(query_vec, query_vec, fetch_each),
)
if not chunk_rows and not summary_rows:
return ""
# Merge by speech_id. Per-talk keep the best chunk hit and the summary hit.
merged: Dict[str, Dict[str, Any]] = {}
for row in chunk_rows:
speech_id = row["speech_id"]
slot = merged.setdefault(speech_id, {})
if row["score"] > slot.get("chunk_score", -1):
slot["chunk_score"] = row["score"]
slot["chunk_index"] = row["chunk_index"]
slot["chunk_text"] = row["text"]
for row in summary_rows:
speech_id = row["id"]
slot = merged.setdefault(speech_id, {})
slot["summary_score"] = row["score"]
slot["summary_text"] = row["summary"]
# Score per talk = max of the two signals (treat missing as 0).
for data in merged.values():
data["score"] = max(data.get("chunk_score") or 0, data.get("summary_score") or 0)
top_ids = sorted(merged.keys(), key=lambda tid: merged[tid]["score"], reverse=True)[:limit]
if not top_ids:
return ""
talk_rows = pg.execute(
"""
SELECT id, speaker_name, party, date::text AS date, person_id, title, url_video
FROM speeches
WHERE id = ANY(%s::text[])
""",
(top_ids,),
)
talk_map = {row["id"]: row for row in talk_rows}
hits: List[HitDocument] = []
for speech_id in top_ids:
data = merged[speech_id]
parent = talk_map.get(speech_id, {})
has_chunk = "chunk_text" in data
has_summary = "summary_text" in data
source_type = (
"both" if has_chunk and has_summary else ("chunk" if has_chunk else "summary")
)
if has_chunk:
# Neighbor speech_chunks give the LLM a bit of context around the hit.
neighbor_rows = pg.execute(
"""
SELECT text, chunk_index
FROM speech_chunks
WHERE speech_id = %s AND chunk_index IN (%s, %s, %s)
ORDER BY chunk_index
""",
(speech_id, data["chunk_index"] - 1, data["chunk_index"], data["chunk_index"] + 1),
)
snippet = " ".join(r["text"] for r in neighbor_rows)
else:
snippet = (data.get("summary_text") or "")[:800]
metadata: Dict[str, Any] = {
"speech_id": speech_id,
"source_type": source_type,
"person_id": parent.get("person_id"),
"title": parent.get("title"),
"url_video": parent.get("url_video"),
}
if has_chunk:
metadata["chunk_index"] = data["chunk_index"]
if has_summary and has_chunk:
# Only attach summary separately when the snippet is the chunk text.
metadata["summary"] = (data["summary_text"] or "")[:500]
hits.append(
HitDocument(
id=f"speeches/{speech_id}",
key=speech_id,
speaker=parent.get("speaker_name"),
party=parent.get("party"),
date=str(parent.get("date") or ""),
snippet=snippet,
score=data["score"],
metadata=metadata,
)
)
result = HitsResponse(hits=hits)
_tool_structured_result.set(result)
return result.to_string() or "(no results)"
# ─────────────────────────────────────────────────────────────────────────────
# vector_search_debates (discovery — not citable)
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
def vector_search_debates(query: str, limit: int = 5) -> HitsResponse:
"""
Semantic discovery tool that finds relevant parliamentary debates (whole
sessions, not individual speeches) by their LLM-written summaries.
This is a NAVIGATION tool. Use it to locate interesting debates, then call
`fetch_debate(debate_id)` to see the speeches inside. Do NOT cite a debate
directly — cite the individual speeches you read via `fetch_debate`.
Workflow:
vector_search_debates("klimatmål 2045")
→ returns ~5 debates with ids like "2021-06-17:42"
→ pick the most relevant → fetch_debate("2021-06-17:42")
→ read talk summaries → cite with [src:TALK_ID] in your answer
Use this tool when:
- The user asks a broad thematic question that likely spans a whole session.
- 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 search_speeches
- You already have a debate_id → call fetch_debate directly
- Counts/aggregations → use database_query
Args:
query: Natural-language description of the topic.
limit: Number of debates to return (default 5).
Returns:
HitsResponse. Each hit uses the bare debate id (e.g. "2021-06-17:42");
`snippet` is the debate summary, metadata includes `num_talks` and `date`.
"""
print_yellow(f"[Tools] vector_search_debates → query='{query}' (top_k={limit})")
query_vec = pg.make_embeddings([query])[0]
rows = pg.execute(
"""
SELECT d.debate, d.date::text AS date, d.summary, d.num_talks,
1 - (d.summary_embedding <=> %s::vector) AS score
FROM debates d
WHERE d.summary_embedding IS NOT NULL
ORDER BY d.summary_embedding <=> %s::vector
LIMIT %s
""",
(query_vec, query_vec, limit),
)
if not rows:
return ""
# Bare debate id (no "debates/" prefix) so the chat service's provenance
# guard can identify and skip these — debates are not citable on their own.
hits: List[HitDocument] = [
HitDocument(
id=row["debate"],
key=row["debate"],
speaker=None,
party=None,
date=str(row.get("date") or ""),
snippet=(row.get("summary") or "")[:400],
score=row.get("score"),
metadata={
"kind": "debate",
"num_talks": row.get("num_talks"),
"date": str(row.get("date") or ""),
},
)
for row in rows
]
result = HitsResponse(hits=hits)
_tool_structured_result.set(result)
return result.to_string() or "(no results)"
# ─────────────────────────────────────────────────────────────────────────────
# fetch_debate (drill down from a debate id to the speeches inside)
# ─────────────────────────────────────────────────────────────────────────────
# Combined character budget for talk summaries in the response. If the full set
# of summaries exceeds this, we either rank by relevance to `query` (if given)
# or fall back to the oldest-first subset whose summaries fit.
FETCH_DEBATE_SUMMARY_BUDGET_CHARS = 7000
@register_tool()
def fetch_debate(debate_id: str, query: Optional[str] = None) -> dict:
"""
Look up a single debate by its id and return a list of its speeches with
per-talk summaries. Registers each returned talk as a citable source.
Typical flow: call `vector_search_debates(query)` to discover relevant
debate ids, then call `fetch_debate(debate_id, query=query)` on the best
match. Passing the same `query` lets the tool rank speeches by semantic
relevance when the debate is too long to return in full.
Args:
debate_id: Debate id of the form "{YYYY-MM-DD}:{n}" (e.g. "2021-06-17:42").
query: Optional search query. When the debate's combined talk summaries
exceed the response budget, speeches are ranked by embedding distance
to this query and only the most relevant ones are returned
(presented in chronological order). Strongly recommended for long
debates — without it, a truncated chronological slice is returned.
Returns:
dict with keys:
debate_id, date, summary, num_talks,
speeches: [{id, speaker_name, party, person_id, summary}, ...],
note (optional): present when not all speeches are returned; explains
how many were omitted and on what basis.
"""
print_yellow(
f"[Tools] fetch_debate → debate_id='{debate_id}'"
+ (f" query='{query}'" if query else "")
)
debate_rows = pg.execute(
"""
SELECT debate, date::text AS date, summary, num_talks, talk_ids
FROM debates
WHERE debate = %s
""",
(debate_id,),
)
if not debate_rows:
return {"error": f"No debate found with id '{debate_id}'."}
debate = debate_rows[0]
talk_ids: List[str] = list(debate.get("talk_ids") or [])
talk_rows = pg.execute(
"""
SELECT id, sequence, speaker_name, party, person_id, summary,
date::text AS date, title, url_video
FROM speeches
WHERE id = ANY(%s::text[])
ORDER BY sequence ASC
""",
(talk_ids,),
)
total_summary_chars = sum(len(r.get("summary") or "") for r in talk_rows)
trimmed_rows = talk_rows
note: Optional[str] = None
if total_summary_chars > FETCH_DEBATE_SUMMARY_BUDGET_CHARS:
ranking_method = "chronological"
chosen_ids: set = set()
running = 0
if query:
# Rank speeches in this debate by embedding distance to the query,
# then keep the top-K whose summaries fit the budget.
embedding = pg.make_embeddings([query])[0]
ranked = pg.execute(
"""
SELECT id, (summary_embedding <=> %s::vector) AS distance
FROM speeches
WHERE id = ANY(%s::text[])
AND summary_embedding IS NOT NULL
ORDER BY distance ASC
""",
(embedding, talk_ids),
)
row_by_id = {r["id"]: r for r in talk_rows}
for r in ranked:
tid = r["id"]
src = row_by_id.get(tid)
if not src:
continue
summary_len = len(src.get("summary") or "")
if running + summary_len > FETCH_DEBATE_SUMMARY_BUDGET_CHARS and chosen_ids:
break
chosen_ids.add(tid)
running += summary_len
if chosen_ids:
ranking_method = "relevance"
if not chosen_ids:
# Either no query was given, or no speeches in this debate have
# summary_embedding populated yet. Fall back to chronological.
running = 0
for r in talk_rows:
summary_len = len(r.get("summary") or "")
if not summary_len:
continue
if running + summary_len > FETCH_DEBATE_SUMMARY_BUDGET_CHARS and chosen_ids:
break
chosen_ids.add(r["id"])
running += summary_len
trimmed_rows = [r for r in talk_rows if r["id"] in chosen_ids]
omitted = len(talk_rows) - len(trimmed_rows)
if ranking_method == "relevance":
note = (
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_speeches with specific ids for full texts."
)
else:
reason = (
"no summary embeddings available for this debate yet; ranked chronologically"
if query
else "ranked chronologically — pass `query` for relevance ranking"
)
note = (
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_speeches for full texts."
)
# Build a compact dict for the LLM; register the speeches as provenance sources.
talks_out: List[Dict[str, Any]] = []
hits: List[HitDocument] = []
for row in trimmed_rows:
speech_id = row["id"]
summary_text = row.get("summary") or ""
talks_out.append(
{
"id": speech_id,
"speaker_name": row.get("speaker_name"),
"party": row.get("party"),
"person_id": row.get("person_id"),
"summary": summary_text,
}
)
hits.append(
HitDocument(
id=f"speeches/{speech_id}",
key=speech_id,
speaker=row.get("speaker_name"),
party=row.get("party"),
date=str(row.get("date") or ""),
snippet=summary_text[:500],
metadata={
"person_id": row.get("person_id"),
"title": row.get("title"),
"url_video": row.get("url_video"),
"debate": debate_id,
},
)
)
if hits:
_tool_structured_result.set(HitsResponse(hits=hits))
result: Dict[str, Any] = {
"debate_id": debate.get("debate"),
"date": debate.get("date"),
"summary": debate.get("summary"),
"num_talks": debate.get("num_talks") or len(talk_ids),
"speeches": talks_out,
}
if note:
result["note"] = note
return result
# ─────────────────────────────────────────────────────────────────────────────
# fetch_speeches
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
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:
- 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 search_speeches or vector_search
- To count/aggregate → use database_query
Args:
_ids: List of document IDs (e.g. ["speeches/H40911", "speeches/H40912"] or bare keys)
collection: Optional prefix to add to bare IDs (e.g. "speeches")
fields: Optional list of field names to return (empty = common fields)
Returns:
List of document dicts, or error message string.
"""
# Normalize IDs: strip "speeches/" prefix
talk_ids = []
for i in _ids:
if "/" in i:
talk_ids.append(i.split("/", 1)[1])
elif collection:
talk_ids.append(i)
else:
talk_ids.append(i)
if not talk_ids:
return []
# Default fields if none specified
if fields:
allowed = {
"id", "text", "sequence", "activity_type",
"speaker_name", "date", "year", "party", "person_id", "title",
"related_doc_id", "debate", "is_reply", "summary", "tags",
}
# Cast date to text so Python receives a string, not a date object
def _col(f: str) -> str:
return "date::text AS date" if f == "date" else f
select = ", ".join(_col(f) for f in fields if f in allowed or f.startswith("_"))
if not select:
select = "id, text, speaker_name, party, date::text AS date, year, activity_type"
else:
select = (
"id, text, sequence, activity_type, "
"speaker_name, date::text AS date, year, party, person_id, title, "
"related_doc_id, debate, is_reply, summary, tags"
)
rows = pg.execute(
f"SELECT {select} FROM speeches WHERE id = ANY(%s::text[])",
(talk_ids,),
)
# Re-add _id and _key virtual fields for downstream compatibility
result = []
for row in rows:
doc = dict(row)
speech_id = doc.get("id", "")
doc["_id"] = f"speeches/{speech_id}"
doc["_key"] = speech_id
result.append(doc)
# Publish structured provenance so ChatService can track these as sources
hits = []
for doc in result:
hits.append(
HitDocument(
id=doc.get("_id"),
key=doc.get("_key"),
speaker=doc.get("speaker_name"),
party=doc.get("party"),
date=doc.get("date"),
text=doc.get("text", ""),
snippet=doc.get("summary") or (doc.get("text") or "")[:300],
metadata={
"person_id": doc.get("person_id"),
"title": doc.get("title"),
"activity_type": doc.get("activity_type"),
},
)
)
if hits:
_tool_structured_result.set(HitsResponse(hits=hits))
return result
# ─────────────────────────────────────────────────────────────────────────────
# read_documents_for (focused sub-agent read — full text never enters the
# orchestrator's context, only the answer to one specific question does)
# ─────────────────────────────────────────────────────────────────────────────
_READER_SYSTEM = load_prompt("tools/reader")
_READER_MAX_DOCS = 6
_READER_SINGLE_BUDGET = 30000 # chars of full text when reading one document
_READER_MULTI_BUDGET = 12000 # chars per document when reading several
# Lazy fallback reader for callers that haven't set _fast_llm_var (e.g. the
# research job runner or ad-hoc scripts). Built once from server-side env.
_default_reader_llm: Optional[LLM] = None
def _get_reader_llm():
llm = _fast_llm_var.get()
if llm is not None:
return llm
global _default_reader_llm
if _default_reader_llm is None:
_default_reader_llm = LLM(
model=os.getenv("LLM_MODEL_FAST", "smart"),
base_url=os.getenv("LLM_DIRECT_URL"),
temperature=0.1,
)
return _default_reader_llm
@register_tool()
def read_documents_for(question: str, _ids: list[str]) -> str:
"""
Read the FULL text of up to 6 speeches/documents and get a focused answer
to ONE specific question about them.
Use this tool when:
- You need to know what specific documents actually SAY about something
(positions, arguments, exact statements) — not just their metadata.
- A snippet or summary is too sparse and you would otherwise fetch full text.
When NOT to use:
- 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_documents
(e.g. "documents/HD02846") work too — the full motion text is read.
Returns:
A short Swedish answer grounded in the documents, with [src:ID] tags,
or a message saying the documents contain nothing relevant.
"""
if not (question or "").strip():
return "Tom fråga — inget att besvara."
talk_ids: list[str] = []
for i in _ids or []:
bare = i.split("/", 1)[1] if "/" in i else i
bare = (bare or "").strip()
if bare and bare not in talk_ids:
talk_ids.append(bare)
if not talk_ids:
return "Inga dokument-id angivna."
talk_ids = talk_ids[:_READER_MAX_DOCS]
rows = pg.execute(
"SELECT id, text, speaker_name, party, date::text AS date, title, "
"person_id, summary FROM speeches WHERE id = ANY(%s::text[])",
(talk_ids,),
)
by_id = {r["id"]: dict(r) for r in rows}
# Ids not found among speeches may be documents — read those too.
missing_ids = [tid for tid in talk_ids if tid not in by_id]
if missing_ids:
motion_rows = pg.execute(
"SELECT doc_id, text, title, parties, author_names, date::text AS date "
"FROM documents WHERE doc_id = ANY(%s::text[])",
(missing_ids,),
)
for r in motion_rows:
names = r.get("author_names") or []
speaker = ", ".join(names[:3]) + (" m.fl." if len(names) > 3 else "")
by_id[r["doc_id"]] = {
"id": r["doc_id"],
"text": r.get("text"),
"speaker_name": speaker,
"party": "/".join(r.get("parties") or []),
"date": r.get("date"),
"title": r.get("title"),
"person_id": None,
"summary": None,
"_kind": "motion",
}
budget = _READER_SINGLE_BUDGET if len(talk_ids) == 1 else _READER_MULTI_BUDGET
blocks: list[str] = []
hits: list[HitDocument] = []
for tid in talk_ids:
doc = by_id.get(tid)
if doc is None:
blocks.append(f"== [src:{tid}] ==\n(dokumentet kunde inte laddas)")
continue
text = (doc.get("text") or "").strip()
if not text:
blocks.append(f"== [src:{tid}] ==\n(dokumentet saknar text)")
continue
if len(text) > budget:
text = text[:budget] + "\n\n[...trunkerat...]"
header_parts = [f"[src:{tid}]"]
if doc.get("speaker_name"):
speaker = doc["speaker_name"]
if doc.get("party"):
speaker += f" ({doc['party']})"
header_parts.append(speaker)
if doc.get("date"):
header_parts.append(doc["date"])
header = "== " + " | ".join(header_parts)
if doc.get("title"):
header += f" == {doc['title']}"
else:
header += " =="
blocks.append(f"{header}\n{text}")
collection = "documents" if doc.get("_kind") == "motion" else "speeches"
hits.append(
HitDocument(
id=f"{collection}/{tid}",
key=tid,
speaker=doc.get("speaker_name"),
party=doc.get("party"),
date=doc.get("date"),
text=doc.get("text", "")[:3000],
snippet=doc.get("summary") or (doc.get("text") or "")[:300],
metadata={
"person_id": doc.get("person_id"),
"title": doc.get("title"),
},
)
)
if not hits:
return "Inga av de angivna dokumenten kunde laddas."
user_prompt = (
f"Fråga: {question}\n\nDokument att läsa:\n\n" + "\n\n".join(blocks)
)
try:
answer = _get_reader_llm().generate(
messages=[
{"role": "system", "content": _READER_SYSTEM},
{"role": "user", "content": user_prompt},
],
think=False,
tools=[],
max_tokens=1200,
)
except Exception as e:
print_red(f"[read_documents_for] reader LLM failed: {e}")
return f"ERROR: läsningen misslyckades ({e})."
# _llm returns a plain string on API errors, a ChatCompletionMessage on success.
answer_text = answer if isinstance(answer, str) else (answer.content or "")
answer_text = answer_text.strip()
if not answer_text or (isinstance(answer, str) and "error" in answer_text.lower()):
return "ERROR: läsningen gav inget svar."
# Publish provenance so the read documents stay citable in the registry.
_tool_structured_result.set(HitsResponse(hits=hits))
return answer_text
# ─────────────────────────────────────────────────────────────────────────────
# _normalize_search_args (unchanged helper)
# ─────────────────────────────────────────────────────────────────────────────
def _normalize_search_args(
query: str,
parties: Optional[Union[str, List[str]]] = None,
people: Optional[Union[str, List[str]]] = None,
debates: Optional[Union[str, List[str]]] = None,
from_year: Optional[Union[str, int]] = None,
to_year: Optional[Union[str, int]] = None,
limit: Optional[Union[str, int]] = 10,
speaker_ids: Optional[Union[str, List[str], bool]] = None,
) -> Dict[str, Any]:
def to_list(val):
if val is None:
return []
if isinstance(val, list):
return val
if isinstance(val, str):
val = val.strip()
if val.startswith("["):
try:
parsed = json.loads(val)
if isinstance(parsed, list):
return [str(v).strip() for v in parsed if v]
except (json.JSONDecodeError, ValueError):
pass
if "," in val:
return [v.strip() for v in val.split(",") if v.strip()]
return [val.strip()]
return [val]
def to_int(val):
if val is None:
return None
if isinstance(val, int):
return val
if isinstance(val, str):
try:
return int(val)
except ValueError:
return None
return None
return {
"query": str(query) if query is not None else "",
"parties": to_list(parties),
"people": to_list(people),
"debates": to_list(debates),
"from_year": to_int(from_year),
"to_year": to_int(to_year),
"limit": to_int(limit) if limit is not None else 10,
"speaker_ids": to_list(speaker_ids),
}
@register_tool()
def search_speeches(
query: str,
parties: Optional[list[str]] = None,
people: Optional[list[str]] = None,
from_year: Optional[int] = None,
to_year: Optional[int] = None,
limit: int = 20,
return_snippets: bool = False,
focus_ids: Optional[List[str]] = None,
person_ids: Optional[Union[str, List[str], bool]] = None,
) -> "SearchHitsResult":
"""
Perform a full-text and metadata search in the Riksdagen 'speeches' table using PostgreSQL FTS.
Args:
query: The search string (supports AND, OR, NOT, phrases in quotes, år:2018-2022).
parties: List of party codes to filter by (e.g., ["S", "M"]).
people: List of speaker names to filter by.
from_year: Start year for filtering.
to_year: End year for filtering.
limit: Maximum number of results (default 20).
return_snippets: If True, return only snippets with highlights.
focus_ids: Restrict search to these specific document ids.
person_ids: An array of numeric strings (e.g., ['0448485371626', '0448485371627']). NEVER guess or make up an ID. If you do not know the exact numeric ID, you MUST use the people parameter instead."
Returns:
SearchHitsResult with hits and search metadata.
Possible to use `return_snippets=True` to only return snippets with highlights instead of
full documents, which can be useful to get an overview of the results. Use this if you're not sure the results are relevant and want to quickly scan them before deciding to fetch full documents.
If searching for specific words or phrases, consider using quotes (") for phrases,
AND/OR/NOT operators, and year ranges (e.g., år:2018-2022).
Always use a limit to avoid too many results. Hits are ranked by relevance (ts_rank_cd).
This tool uses advanced text search (with stemming, language analysis, and ranking) and
can also filter by party, speaker, debate type, and year range.
When NOT to use:
- Fuzzy/semantic similarity → use vector_search
- Exact aggregations, joins, or structured metadata queries → use database_query
"""
# Validate person_ids: they must be purely numeric strings.
# If the model passes a placeholder like "PERS_ID_FOR_X", reject the whole call
# so it knows to use `people=` instead or wait until it has real IDs from results.
if person_ids:
ids_list = (
json.loads(person_ids) if isinstance(person_ids, str) else person_ids
)
if isinstance(ids_list, list):
bad = [sid for sid in ids_list if not str(sid).strip().isdigit()]
if bad:
return f"ERROR: person_ids must be numeric strings (e.g. '0448485371626'). Invalid values: {bad}. Use the `people` parameter to search by name, or only pass person_ids you have seen in previous results."
args = _normalize_search_args(
query=query,
parties=parties,
people=people,
from_year=from_year,
to_year=to_year,
limit=limit,
speaker_ids=person_ids,
)
class Payload:
def __init__(self, q, parties, people, debates, from_year, to_year, limit,
return_snippets=False, focus_ids=None, speaker_ids=None):
self.q = q
self.parties = parties or []
self.people = people or []
self.debates = debates or []
self.from_year = from_year
self.to_year = to_year
self.limit = limit
self.return_snippets = return_snippets
self.focus_ids = focus_ids or []
self.speaker_ids = speaker_ids
focus_id_list: List[str] = []
if focus_ids:
if isinstance(focus_ids, list):
focus_id_list = [str(item) for item in focus_ids if isinstance(item, (str, int))]
elif isinstance(focus_ids, str):
try:
parsed = json.loads(focus_ids)
if isinstance(parsed, list):
focus_id_list = [str(item) for item in parsed if isinstance(item, (str, int))]
except json.JSONDecodeError:
focus_id_list = [focus_ids]
search_service = SearchService()
# Always fetch full text (return_snippets=False) so we can measure total size
# before deciding whether to include text or fall back to snippets.
results, stats, limit_reached = search_service.search(
payload=Payload(
q=args["query"],
parties=args["parties"],
people=args["people"],
debates=args.get("debates", []),
from_year=args["from_year"],
to_year=args["to_year"],
limit=args["limit"],
return_snippets=False,
focus_ids=focus_id_list,
speaker_ids=person_ids,
),
include_snippets=True,
return_snippets=False,
)
# Decide whether to include full text or fall back to snippets.
# Two triggers: caller explicitly asked for snippets, or total text is too large.
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
snippet_mode = return_snippets or auto_snippet_mode
hits: List[HitDocument] = []
for item in results:
if not isinstance(item, dict):
continue
speech_id = item.get("_id", "")
if snippet_mode:
# Only include snippet fields, not full text
hits.append(HitDocument(
id=speech_id,
key=speech_id.removeprefix("speeches/"),
speaker=item.get("speaker"),
party=item.get("party"),
date=item.get("date"),
snippet=item.get("snippet_long") or item.get("snippet") or "",
text=None,
score=item.get("bm25"),
metadata={
"person_id": item.get("person_id"),
"url_video": item.get("url_session") or item.get("url_video"),
"title": item.get("title"),
"activity_type": item.get("activity_type"),
"chunk_index": item.get("chunk_index", -1),
},
))
else:
hits.append(HitDocument(
id=speech_id,
key=speech_id.removeprefix("speeches/"),
speaker=item.get("speaker"),
party=item.get("party"),
date=item.get("date"),
snippet=item.get("snippet") or item.get("snippet_long") or "",
text=item.get("text") or "",
score=item.get("bm25"),
metadata={
"person_id": item.get("person_id"),
"url_video": item.get("url_session") or item.get("url_video"),
"title": item.get("title"),
"activity_type": item.get("activity_type"),
"chunk_index": item.get("chunk_index", -1),
},
))
structured = SearchHitsResult(
response=HitsResponse(hits=hits),
stats=stats,
focus_ids=[h.id for h in hits if h.id],
limit_reached=limit_reached,
)
_tool_structured_result.set(structured)
output = structured.response.to_string() or "(no results)"
if auto_snippet_mode:
note = (
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 document IDs from the results and use `focus_ids` to fetch only those, or\n"
" 2. Repeat the search with a lower `limit` to reduce the result set."
)
output = output + "\n\n---\n\n" + note
return output
# ─────────────────────────────────────────────────────────────────────────────
# Motioner (written proposals from MPs) — search, semantic search, fetch
# ─────────────────────────────────────────────────────────────────────────────
@register_tool()
def search_documents(
query: str,
parties: Optional[list[str]] = None,
people: Optional[list[str]] = None,
from_year: Optional[int] = None,
to_year: Optional[int] = None,
limit: int = 20,
return_snippets: bool = False,
focus_ids: Optional[List[str]] = None,
person_ids: Optional[Union[str, List[str], bool]] = None,
) -> "SearchHitsResult":
"""
Full-text and metadata search over MOTIONER (written proposals submitted by MPs),
as opposed to search_speeches which searches chamber SPEECHES (anföranden).
Speeches (anföranden) are the PRIMARY source — search them first with
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?").
Args:
query: The search string (supports AND, OR, NOT, phrases in quotes, år:2018-2022).
parties: List of party codes to filter by (e.g., ["S", "M"]). Matches any co-author.
people: List of author names to filter by (matches any signatory).
from_year: Start year (riksmöte start year) for filtering.
to_year: End year for filtering.
limit: Maximum number of results (default 20).
return_snippets: If True, return only snippets with highlights.
focus_ids: Restrict search to these specific motion ids.
person_ids: An array of numeric strings (e.g., ['0448485371626']). NEVER guess
or make up an ID. If you do not know the exact numeric ID, use `people` instead.
Returns:
SearchHitsResult. Hit ids look like "documents/HD02846"; metadata includes title,
committee (committee), session_label (riksmöte) and num_proposals. Cite with [src:DOK_ID].
When NOT to use:
- 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:
ids_list = (
json.loads(person_ids) if isinstance(person_ids, str) else person_ids
)
if isinstance(ids_list, list):
bad = [sid for sid in ids_list if not str(sid).strip().isdigit()]
if bad:
return f"ERROR: person_ids must be numeric strings (e.g. '0448485371626'). Invalid values: {bad}. Use the `people` parameter to search by name, or only pass person_ids you have seen in previous results."
args = _normalize_search_args(
query=query,
parties=parties,
people=people,
from_year=from_year,
to_year=to_year,
limit=limit,
speaker_ids=person_ids,
)
class Payload:
def __init__(self, q, parties, people, from_year, to_year, limit,
focus_ids=None, speaker_ids=None):
self.q = q
self.parties = parties or []
self.people = people or []
self.from_year = from_year
self.to_year = to_year
self.limit = limit
self.focus_ids = focus_ids or []
self.speaker_ids = speaker_ids
focus_id_list: List[str] = []
if focus_ids:
if isinstance(focus_ids, list):
focus_id_list = [str(item) for item in focus_ids if isinstance(item, (str, int))]
elif isinstance(focus_ids, str):
try:
parsed = json.loads(focus_ids)
if isinstance(parsed, list):
focus_id_list = [str(item) for item in parsed if isinstance(item, (str, int))]
except json.JSONDecodeError:
focus_id_list = [focus_ids]
print_yellow(f"[Tools] search_documents → query='{query}' limit={args['limit']}")
search_service = MotionSearchService()
results, stats, limit_reached = search_service.search(
payload=Payload(
q=args["query"],
parties=args["parties"],
people=args["people"],
from_year=args["from_year"],
to_year=args["to_year"],
limit=args["limit"],
focus_ids=focus_id_list,
speaker_ids=person_ids,
),
include_snippets=True,
return_snippets=False,
)
# 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
snippet_mode = return_snippets or auto_snippet_mode
hits: List[HitDocument] = []
for item in results:
if not isinstance(item, dict):
continue
doc_id = item.get("_id", "")
metadata = {
"kind": "motion",
"title": item.get("title"),
"session_label": item.get("session_label"),
"committee": item.get("committee"),
"subtype": item.get("subtype"),
"num_proposals": item.get("num_proposals"),
"url_video": item.get("url_session"),
}
if not item.get("has_text"):
metadata["note"] = "endast inskannad PDF — fulltext saknas"
hits.append(HitDocument(
id=doc_id,
key=doc_id.removeprefix("documents/"),
speaker=item.get("speaker"),
party=item.get("party"),
date=item.get("date"),
snippet=(item.get("snippet_long") if snippet_mode else item.get("snippet"))
or item.get("snippet") or "",
text=None if snippet_mode else (item.get("text") or ""),
score=item.get("bm25"),
metadata=metadata,
))
structured = SearchHitsResult(
response=HitsResponse(hits=hits),
stats=stats,
focus_ids=[h.id for h in hits if h.id],
limit_reached=limit_reached,
)
_tool_structured_result.set(structured)
output = structured.response.to_string() or "(no results)"
if auto_snippet_mode:
note = (
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_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
return output
@register_tool()
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_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:
- You want to deepen speech-based findings with what MPs formally proposed
and exact keywords may not appear in the motion text.
- The user explicitly asks about motioner, or speeches gave no coverage.
- You want documents similar in meaning to a phrase or idea.
When NOT to use:
- Exact word/phrase matching in documents → search_documents
- Chamber speeches → vector_search
- Counts/aggregations → database_query
Args:
query: Natural-language description of the topic.
limit: Number of documents to return (default 10).
Returns:
HitsResponse with one hit per motion. The snippet is the best-matching
yrkande (the motion's condensed formal proposal) when that is the
strongest signal, otherwise the best full-text passage; metadata["matched"]
says which ("yrkande" or "text"). Hit ids look like "documents/HD02846";
cite with [src:DOK_ID].
"""
print_yellow(f"[Tools] vector_search_documents → query='{query}' (top_k={limit})")
query_vec = pg.make_embeddings([query])[0]
# Two semantic signals: full-text speech_chunks (coverage) and yrkanden (condensed,
# to-the-point proposals). Merge per motion, keeping the best of each.
chunk_rows = pg.execute(
"""
SELECT doc_id, chunk_index, 1 - (embedding <=> %s::vector) AS score
FROM document_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(query_vec, query_vec, limit * 2),
)
yrkande_rows = pg.execute(
"""
SELECT doc_id, number, text, committee_recommendation, chamber_decision,
1 - (embedding <=> %s::vector) AS score
FROM document_proposals
WHERE embedding IS NOT NULL
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(query_vec, query_vec, limit * 2),
)
if not chunk_rows and not yrkande_rows:
return "(no results — motion embeddings may not be built yet)"
merged: Dict[str, Dict[str, Any]] = {}
for row in chunk_rows:
slot = merged.setdefault(row["doc_id"], {"score": -1})
if row["score"] > slot.get("chunk_score", -1):
slot["chunk_score"] = row["score"]
slot["chunk_index"] = row["chunk_index"]
slot["score"] = max(slot["score"], row["score"])
for row in yrkande_rows:
slot = merged.setdefault(row["doc_id"], {"score": -1})
if row["score"] > slot.get("yrkande_score", -1):
slot["yrkande_score"] = row["score"]
slot["yrkande"] = row
slot["score"] = max(slot["score"], row["score"])
top_ids = sorted(merged.keys(), key=lambda mid: merged[mid]["score"], reverse=True)[:limit]
motion_rows = pg.execute(
"""
SELECT doc_id, title, session_label, committee, date::text AS date, year,
parties, author_names, num_proposals, url_html
FROM documents
WHERE doc_id = ANY(%s::text[])
""",
(top_ids,),
)
motion_map = {row["doc_id"]: row for row in motion_rows}
hits: List[HitDocument] = []
for doc_id in top_ids:
data = merged[doc_id]
parent = motion_map.get(doc_id, {})
# Prefer the matching yrkande as the snippet — it is condensed and to the
# point — falling back to the full-text chunk (with neighbours) otherwise.
yrkande = data.get("yrkande")
prefer_yrkande = yrkande is not None and (
"chunk_index" not in data or data.get("yrkande_score", 0) >= data.get("chunk_score", 0)
)
metadata: Dict[str, Any] = {
"kind": "motion",
"title": parent.get("title"),
"session_label": parent.get("session_label"),
"committee": parent.get("committee"),
"num_proposals": parent.get("num_proposals"),
"url_video": parent.get("url_html"),
}
if prefer_yrkande:
snippet = yrkande["text"]
outcome = yrkande.get("chamber_decision") or yrkande.get("committee_recommendation")
if outcome:
snippet += f" [beslut: {outcome}]"
metadata["matched"] = "yrkande"
metadata["yrkande_nummer"] = yrkande.get("number")
elif "chunk_index" in data:
neighbor_rows = pg.execute(
"""
SELECT text, chunk_index
FROM document_chunks
WHERE doc_id = %s AND chunk_index IN (%s, %s, %s)
ORDER BY chunk_index
""",
(doc_id, data["chunk_index"] - 1, data["chunk_index"], data["chunk_index"] + 1),
)
snippet = " ".join(r["text"] for r in neighbor_rows)
metadata["matched"] = "text"
metadata["chunk_index"] = data["chunk_index"]
else:
snippet = yrkande["text"] if yrkande else ""
metadata["matched"] = "yrkande" if yrkande else "text"
author_names = parent.get("author_names") or []
speaker = ", ".join(author_names[:3])
if len(author_names) > 3:
speaker += " m.fl."
hits.append(
HitDocument(
id=f"documents/{doc_id}",
key=doc_id,
speaker=speaker,
party="/".join(parent.get("parties") or []),
date=str(parent.get("date") or ""),
snippet=snippet,
score=data["score"],
metadata=metadata,
)
)
result = HitsResponse(hits=hits)
_tool_structured_result.set(result)
return result.to_string() or "(no results)"
# Keys worth surfacing from the raw dokforslag JSON per yrkande.
_YRKANDE_KEYS = (
"number", "text", "lydelse2", "committee_recommendation", "chamber_decision",
"kammarbeslutstyp", "handled_in",
)
@register_tool()
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_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".
Returns:
dict with keys:
doc_id, title, subtitle, session_label, designation, subtype, committee, status, date,
authors: [{name, party, person_id, role}, ...],
yrkanden: [{number, text, committee_recommendation, chamber_decision, handled_in}, ...]
(committee_recommendation = committee proposal, chamber_decision = chamber decision,
handled_in = committee report where it was handled),
text (truncated to ~30 000 chars),
url_pdf, url,
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_document → doc_id='{bare_id}'")
rows = pg.execute(
"""
SELECT doc_id, title, subtitle, session_label, designation, subtype, committee, status,
date::text AS date, year, text, has_text, proposals_raw,
parties, author_names, url_pdf, url_html
FROM documents
WHERE doc_id = %s
""",
(bare_id,),
)
if not rows:
return {"error": f"No motion found with doc_id '{bare_id}'."}
motion = rows[0]
author_rows = pg.execute(
"""
SELECT name, party, person_id, role
FROM document_authors
WHERE doc_id = %s
ORDER BY ordinal
""",
(bare_id,),
)
proposals_raw = motion.get("proposals_raw") or []
if isinstance(proposals_raw, str):
proposals_raw = json.loads(proposals_raw)
yrkanden = [
{k: f.get(k) for k in _YRKANDE_KEYS if f.get(k) is not None}
for f in proposals_raw
if isinstance(f, dict)
]
text = (motion.get("text") or "").strip()
truncated = len(text) > _READER_SINGLE_BUDGET
if truncated:
text = text[:_READER_SINGLE_BUDGET] + "\n\n[...trunkerat...]"
author_names = motion.get("author_names") or []
speaker = ", ".join(author_names[:3])
if len(author_names) > 3:
speaker += " m.fl."
# Register as citable source.
_tool_structured_result.set(HitsResponse(hits=[
HitDocument(
id=f"documents/{bare_id}",
key=bare_id,
speaker=speaker,
party="/".join(motion.get("parties") or []),
date=str(motion.get("date") or ""),
text=text[:3000],
snippet=(motion.get("title") or "") + "" + text[:300],
metadata={
"kind": "motion",
"title": motion.get("title"),
"session_label": motion.get("session_label"),
"committee": motion.get("committee"),
"url_video": motion.get("url_html"),
},
)
]))
result: Dict[str, Any] = {
"doc_id": motion.get("doc_id"),
"title": motion.get("title"),
"subtitle": motion.get("subtitle"),
"session_label": motion.get("session_label"),
"designation": motion.get("designation"),
"subtype": motion.get("subtype"),
"committee": motion.get("committee"),
"status": motion.get("status"),
"date": motion.get("date"),
"authors": [dict(a) for a in author_rows],
"yrkanden": yrkanden,
"text": text,
"url_pdf": motion.get("url_pdf"),
"url": motion.get("url_html"),
}
if not motion.get("has_text"):
result["note"] = (
"Motionen finns endast som inskannad PDF — fulltext saknas i databasen. "
"Metadata och eventuella yrkanden ovan är kompletta."
)
if truncated:
result["text_note"] = "Texten är trunkerad."
return result
@register_tool()
def share_insight(
message: str,
speaker_ids: list = None,
speaker_ids_context: str = None,
hit_ids: list = None,
sql: str = None,
hits: list = None,
rows: list = None,
) -> dict:
"""Surface a concrete finding to the user while you continue researching.
Args:
message (str): Brief observation in Swedish (1–3 sentences). Be specific and concrete.
Use this to put other insights in context. It appears as the header of the card
and should explain what the attached data shows. If you refer to specific persons
in this message, make sure to include their person_id in speaker_ids so their
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
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
variant). The backend re-executes it and builds the rows — you do NOT write rows=[]
by hand.
speaker_ids (list[str]): Optional. person_id values for portrait highlights (to show
speaker portrait photos). List of person_id values for speakers you want to
highlight visually. Always pair with speaker_ids_context to explain why they matter.
speaker_ids_context (str): Optional. Caption for the speaker highlights (1–2 sentences
explaining why these specific speakers are notable in this context).
Returns:
dict: Consumed by ChatService to emit a search_card, stats_card, or insight SSE event.
Examples:
>>> share_insight(
... message="60 % av SD:s AI-debatter 2019–2022 hölls av tre speaker_name.",
... speaker_ids=["0448485371626", "0448485371627", "0448485371628"],
... speaker_ids_context="Dessa tre speaker_name stod för 60 % av SD:s AI-debatter 2019–2022.",
... )
>>> share_insight(
... message="Lista över politiker som nämnt 'artificiell intelligens' i sina tal, och hur många gånger var.",
... sql="SELECT speaker_name, COUNT(*) AS cnt FROM speeches WHERE text @@ websearch_to_tsquery('swedish', 'artificiell intelligens') GROUP BY speaker_name ORDER BY cnt DESC",
... )
>>> share_insight(
... message="Debatten om Luftvärn präglas av två huvudstrider: valet av vapensystem och kopplingen mellan antal stridsflygplan och luftvärsbehov.",
... hit_ids=["H40911", "H40912", "H40913", "H40914", "H40915"],
... )
"""
# Resolve hit_ids → hits by querying the speeches table (documents as fallback)
if hit_ids and not hits:
# Normalize: strip "speeches/"/"documents/" prefix if present
bare_ids = [i.split("/", 1)[1] if "/" in i else i for i in hit_ids]
try:
talk_rows = pg.execute(
"""
SELECT id, speaker_name, party, date::text AS date, person_id, summary
FROM speeches
WHERE id = ANY(%s::text[])
""",
(bare_ids,),
)
hits = [
{
"_id": f"speeches/{r['id']}",
"speaker": r.get("speaker_name"),
"party": r.get("party"),
"date": r.get("date"),
"snippet": (r.get("summary") or "")[:300],
"person_id": r.get("person_id"),
}
for r in talk_rows
]
found = {r["id"] for r in talk_rows}
missing = [i for i in bare_ids if i not in found]
if missing:
motion_rows = pg.execute(
"""
SELECT doc_id, title, parties, author_names, date::text AS date, text
FROM documents
WHERE doc_id = ANY(%s::text[])
""",
(missing,),
)
for r in motion_rows:
names = r.get("author_names") or []
speaker = ", ".join(names[:3]) + (" m.fl." if len(names) > 3 else "")
hits.append(
{
"_id": f"documents/{r['doc_id']}",
"speaker": speaker,
"party": "/".join(r.get("parties") or []),
"date": r.get("date"),
"snippet": (r.get("title") or "") or (r.get("text") or "")[:300],
"person_id": None,
}
)
except Exception as e:
print_red(f"[share_insight] Failed to fetch hit_ids: {e}")
# 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_readonly(sql)
except Exception as e:
print_red(f"[share_insight] Failed to execute sql: {e}")
rows = [{"error": str(e)}]
# Resolve [src:ID] tags in plain insight messages to debateurls for footnote links.
src_sources: dict = {}
src_ids = re.findall(r"\[src:([A-Za-z0-9_-]+)\]", message)
if src_ids and not hits and not rows:
try:
src_rows = pg.execute(
"SELECT id, url_video FROM speeches WHERE id = ANY(%s::text[])",
(src_ids,),
)
src_sources = {r["id"]: r["url_video"] for r in src_rows if r.get("url_video")}
except Exception as e:
print_red(f"[share_insight] Failed to fetch src debateurls: {e}")
cb = _insight_callback.get()
if cb:
# Dispatch to the appropriate SSE event type based on what data is attached.
if hits:
cb({
"type": "search_card",
# message = insight text shown as card header (✓ …)
# query is intentionally empty — the insight text is not a search query
"message": message,
"query": "",
"results": hits[:8],
"total": len(hits),
"limit_reached": False,
"stats": {},
"speaker_ids": speaker_ids or [],
"speaker_ids_context": speaker_ids_context or "",
})
elif rows:
cb({
"type": "stats_card",
# message = insight text shown as card header (✓ …)
"message": message,
"rows": rows[:20],
"speaker_ids": speaker_ids or [],
"speaker_ids_context": speaker_ids_context or "",
})
else:
cb({
"type": "insight",
"message": message,
"sources": src_sources,
"speaker_ids": speaker_ids or [],
"speaker_ids_context": speaker_ids_context or "",
})
@register_tool()
def lookup_source(source_ids: list[str]) -> str:
"""Återhämta lagrad grundtext för en eller flera tidigare registrerade källor.
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
med en lista av tal-id:n du redan sett.
Args:
source_ids: Lista av bara tal-id:n (t.ex. ["H40911", "GH09100"]) eller
"speeches/H40911"-format. ID:n som inte finns i registret utelämnas.
Returns:
Sträng med speaker_name, party, date, rubrik och lagrad text per id.
Returnerar en kort meddelandetext om inga av id:na hittades.
"""
registry = _provenance_registry.get()
if registry is None:
return "ERROR: ingen provenance-registry är active för denna session."
if not source_ids:
return "ERROR: source_ids är tom."
# Cap output: at most 5 sources per call, 1500 chars per body. Keeps the
# orchestrator's history bounded even if the model asks for many at once.
MAX_IDS = 5
PER_BODY_CAP = 1500
requested = list(dict.fromkeys(source_ids))
dropped_for_cap = requested[MAX_IDS:]
requested = requested[:MAX_IDS]
parts: list[str] = []
missing: list[str] = []
for raw_id in requested:
sid = raw_id.split("/", 1)[1] if "/" in raw_id else raw_id
rec = registry.get(sid)
if rec is None:
missing.append(raw_id)
continue
header = f"[src:{sid}]"
meta = " | ".join(
v for v in [rec.speaker, f"({rec.party})" if rec.party else None, rec.date, rec.heading]
if v
)
body = rec.body or rec.snippet or "(ingen lagrad text)"
if len(body) > PER_BODY_CAP:
body = body[:PER_BODY_CAP].rstrip() + ""
parts.append(f"{header} {meta}\n{body}")
if not parts:
return f"Inga källor hittades. Kontrollera id:n: {missing}"
notes: list[str] = []
if missing:
notes.append(f"{len(missing)} id hittades inte: {missing}")
if dropped_for_cap:
notes.append(
f"max {MAX_IDS} källor per anrop — {len(dropped_for_cap)} utelämnades: {dropped_for_cap}. "
"Anropa lookup_source igen om du behöver dessa."
)
suffix = "\n\n[Notera: " + " | ".join(notes) + "]" if notes else ""
return "\n\n---\n\n".join(parts) + suffix
if __name__ == "__main__":
print(vector_search("klimatförändringar", limit=3))