Parliamentary Policy Vibe Check
parliament_vibe_checkSearch Hansard for debate contributions related to a policy proposal, then classify sentiment and extract supporters, opponents, and key concerns to assess parliamentary reception.
Instructions
Assess the likely parliamentary reception of a policy proposal.
Searches Hansard for relevant debate contributions, then uses LLM sampling to classify sentiment and extract supporters, opponents, and key concerns.
Degrades gracefully if sampling is unavailable — returns contributions only.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| params | Yes | PolicyVibeInput with policy_text (full description) and topic (search keyword). |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| query | Yes | The policy query that was analysed | |
| contributions | Yes | Raw contributions retrieved from Hansard | |
| sentiment_summary | No | LLM-generated sentiment summary (None if sampling unavailable) | |
| key_supporters | No | Members identified as supportive | |
| key_opponents | No | Members identified as opposed or critical | |
| key_concerns | No | Main concerns raised in debate |
Implementation Reference
- src/modules/parliament/tools.py:254-316 (handler)The main handler function for the parliament_vibe_check tool. It queries the Hansard API for contributions matching the topic, then uses LLM sampling (ctx.sample) to classify sentiment and extract supporters, opponents, and key concerns. Degrades gracefully if sampling is unavailable.
@mcp.tool( name="vibe_check", annotations={"title": "Parliamentary Policy Vibe Check", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True}, ) async def parliament_vibe_check(params: PolicyVibeInput, ctx: Context) -> PolicyVibeResult: """Assess the likely parliamentary reception of a policy proposal. Searches Hansard for relevant debate contributions, then uses LLM sampling to classify sentiment and extract supporters, opponents, and key concerns. Degrades gracefully if sampling is unavailable — returns contributions only. Args: params: PolicyVibeInput with policy_text (full description) and topic (search keyword). """ client: httpx.AsyncClient = ctx.lifespan_context["http"] resp = await client.get( f"{HANSARD_API}/search.json", params={"searchTerm": params.topic, "take": 15}, ) resp.raise_for_status() contributions = _parse_hansard_contributions(resp.json()) if not contributions: return PolicyVibeResult( query=params.topic, contributions=[], sentiment_summary="No Hansard contributions found for this topic.", key_supporters=[], key_opponents=[], key_concerns=[], ) contributions_text = "\n\n".join( f"{c.member_name} ({c.party or 'Unknown'}, {c.date}):\n{c.text[:500]}" for c in contributions[:10] ) sample_prompt = ( f"Policy proposal: {params.policy_text}\n\n" f"Relevant Hansard contributions:\n{contributions_text}\n\n" f"Respond ONLY with a JSON object (no markdown fences):\n" '{"sentiment_summary": "...", "key_supporters": [...], ' '"key_opponents": [...], "key_concerns": [...]}' ) sentiment_summary: str | None = None key_supporters: list[str] = [] key_opponents: list[str] = [] key_concerns: list[str] = [] try: result = await ctx.sample(sample_prompt, result_type=str) raw = (result.text or "").strip().lstrip("```json").lstrip("```").rstrip("```") parsed = json.loads(raw) sentiment_summary = parsed.get("sentiment_summary") key_supporters = parsed.get("key_supporters", []) key_opponents = parsed.get("key_opponents", []) key_concerns = parsed.get("key_concerns", []) except Exception: sentiment_summary = "Sentiment analysis unavailable (sampling not supported by this client)." return PolicyVibeResult( query=params.topic, contributions=contributions, sentiment_summary=sentiment_summary, key_supporters=key_supporters, key_opponents=key_opponents, key_concerns=key_concerns, ) - PolicyVibeInput schema: validates policy_text (10-2000 chars) and topic (2-200 chars).
class PolicyVibeInput(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") policy_text: str = Field(..., description="Description of the policy proposal to assess", min_length=10, max_length=2000) topic: str = Field(..., description=( "Search terms for Hansard (keyword search, not phrase-matched). " "Use 2-5 key terms, e.g. 'artificial intelligence financial services regulation'. " "Broader terms return more contributions for sentiment analysis." ), min_length=2, max_length=200) - PolicyVibeResult schema: defines the output shape with query, contributions, sentiment_summary, key_supporters, key_opponents, key_concerns.
class PolicyVibeResult(BaseModel): """Result of a parliamentary sentiment analysis on a policy topic.""" query: str = Field(..., description="The policy query that was analysed") contributions: list[HansardContribution] = Field(..., description="Raw contributions retrieved from Hansard") sentiment_summary: str | None = Field(None, description="LLM-generated sentiment summary (None if sampling unavailable)") key_supporters: list[str] = Field(default_factory=list, description="Members identified as supportive") key_opponents: list[str] = Field(default_factory=list, description="Members identified as opposed or critical") key_concerns: list[str] = Field(default_factory=list, description="Main concerns raised in debate") - src/modules/parliament/tools.py:210-257 (registration)The register_tools function that registers the tool via @mcp.tool decorator with the name 'vibe_check'. The tool is registered on the parliament_mcp FastMCP instance.
def register_tools(mcp: FastMCP) -> None: @mcp.tool( name="search_hansard", annotations={"title": "Search Hansard Debates", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True}, ) async def parliament_search_hansard(params: HansardSearchInput, ctx: Context) -> HansardSearchResult: """Search Hansard for parliamentary debates, questions, and speeches. Returns contributions from MPs and Lords including date, party, debate title, and text (capped at 3000 chars per contribution). Useful for understanding legislative intent or political context. Args: params: HansardSearchInput with query, optional date range, optional member filter. """ client: httpx.AsyncClient = ctx.lifespan_context["http"] qp: dict = { "searchTerm": f'"{params.query}"', "take": params.limit, "skip": params.offset, } if params.from_date: qp["startDate"] = params.from_date.isoformat() if params.to_date: qp["endDate"] = params.to_date.isoformat() if params.member: qp["member"] = params.member resp = await client.get(f"{HANSARD_API}/search.json", params=qp) resp.raise_for_status() contributions = _parse_hansard_contributions(resp.json()) return HansardSearchResult( query=params.query, from_date=params.from_date, to_date=params.to_date, member=params.member, offset=params.offset, limit=params.limit, total=len(contributions), has_more=len(contributions) == params.limit, contributions=contributions, ) @mcp.tool( name="vibe_check", annotations={"title": "Parliamentary Policy Vibe Check", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True}, ) - _parse_hansard_contributions helper: parses the Hansard API response into HansardContribution objects, used by the vibe check handler.
def _parse_hansard_contributions(data: dict) -> list[HansardContribution]: """Parse hansard-api.parliament.uk search.json response.""" contributions = [] for item in data.get("Contributions", []): try: # Extract attribution like "Lord Carlile of Berriew (CB)" attr = item.get("AttributedTo", "") name = item.get("MemberName", "Unknown") party = None if "(" in attr and ")" in attr: party = attr[attr.rfind("(") + 1:attr.rfind(")")] text = _strip_html(item.get("ContributionText", "")) contributions.append(HansardContribution( member_name=name, party=party, constituency=None, date=date.fromisoformat(item.get("SittingDate", "1970-01-01")[:10]), debate_title=item.get("DebateSectionName", item.get("Section", "Unknown")), section=item.get("HansardSection", item.get("House", "Unknown")), text=text[:3000], url=item.get("Url", ""), )) except Exception: continue return contributions