/**
* GraphQL Schema using @neo4j/graphql
*
* This schema is automatically mapped to Neo4j nodes and relationships.
* The @neo4j/graphql library generates resolvers, filters, and pagination.
*/
export const typeDefs = `#graphql
# ============================================
# People & Organizations
# ============================================
type MP @node {
id: ID! @unique
name: String!
given_name: String
family_name: String
gender: String
party: String
riding: String
current: Boolean!
elected_date: Date
email: String
phone: String
twitter: String
wikipedia_id: String
constituency_office: String
ourcommons_url: String
cabinet_position: String
# Photo URLs - Two-tier approach for graceful fallback
photo_url_source: String # OpenParliament URL (auto-updated by ingestion)
photo_url: String # Custom high-res GCS URL (manually upgraded, takes precedence)
# House of Commons seating information
parl_mp_id: Int # House of Commons PersonId (MP XML, Votes, Committee Evidence)
hansard_db_id: Int # Hansard Affiliation DbId (for Hansard statement linking)
seat_row: Int # Row number in chamber (1-12 across both sides)
seat_column: Int # Column position within row
bench_section: String # "government" | "opposition" | "speaker"
seat_visual_x: Float # SVG X coordinate for rendering
seat_visual_y: Float # SVG Y coordinate for rendering
# Enhanced MP metadata from OurCommons XML
honorific: String # "Hon.", "Right Hon." (for ministers and former PMs)
term_start_date: DateTime # Precise swearing-in date from OurCommons XML
term_end_date: DateTime # Term end date (null if currently serving)
province: String # Province/territory directly from OurCommons XML
updated_at: DateTime!
# Relationships
memberOf: Party @relationship(type: "MEMBER_OF", direction: OUT)
represents: Riding @relationship(type: "REPRESENTS", direction: OUT)
sponsored: [Bill!]! @relationship(type: "SPONSORED", direction: OUT)
ballots: [Ballot!]! @relationship(type: "CAST_BY", direction: IN)
voted: [Vote!]! @relationship(type: "VOTED", direction: OUT, properties: "VotedProperties")
sponsoredPetitions: [Petition!]! @relationship(type: "SPONSORED", direction: OUT)
expenses: [Expense!]! @relationship(type: "INCURRED", direction: OUT)
metWith: [Lobbyist!]! @relationship(type: "MET_WITH", direction: IN, properties: "MetWithProperties")
servedOn: [Committee!]! @relationship(type: "SERVES_ON", direction: OUT, properties: "ServedOnProperties")
roles: [Role!]! @relationship(type: "HOLDS_ROLE", direction: OUT)
speeches: [Statement!]! @relationship(type: "MADE_BY", direction: IN)
mentionedIn: [NewsArticle!]! @relationship(type: "MENTIONED_IN", direction: OUT, properties: "MentionedInProperties")
}
type Senator @node {
id: ID! @unique
name: String!
name_with_title: String
current: Boolean!
updated_at: DateTime!
# Relationships
sponsored: [Bill!]! @relationship(type: "SPONSORED", direction: OUT)
}
type Party @node {
code: ID! @unique
name: String!
short_name: String
leader_name: String
seats: Int
updated_at: DateTime!
# Relationships
members: [MP!]! @relationship(type: "MEMBER_OF", direction: IN)
receivedDonations: [Donation!]! @relationship(type: "RECEIVED", direction: OUT)
}
type Riding @node {
id: ID! @unique
name: String!
province: String
population: Int
is_vacant: Boolean
vacant_since: Date
# Relationships
representedBy: [MP!]! @relationship(type: "REPRESENTS", direction: IN)
}
type Role @node {
id: ID! @unique
person_id: Int!
title: String!
role_type: String!
from_date: DateTime!
to_date: DateTime
order_of_precedence: Int
is_current: Boolean!
updated_at: DateTime!
# Relationships
heldBy: MP @relationship(type: "HOLDS_ROLE", direction: IN)
}
type Organization @node {
id: ID! @unique
name: String!
industry: String
ceo: String
# Relationships
lobbyists: [Lobbyist!]! @relationship(type: "WORKS_FOR", direction: IN)
registrations: [LobbyRegistration!]! @relationship(type: "ON_BEHALF_OF", direction: IN)
lobbiedOn: [Bill!]! @relationship(type: "LOBBIED_ON", direction: OUT, properties: "LobbiedOnProperties")
receivedContracts: [Contract!]! @relationship(type: "RECEIVED", direction: OUT)
receivedGrants: [Grant!]! @relationship(type: "RECEIVED", direction: OUT)
donated: [Party!]! @relationship(type: "DONATED", direction: OUT, properties: "DonatedProperties")
}
type Lobbyist @node {
id: ID! @unique
name: String!
firm: String
# Relationships
worksFor: Organization @relationship(type: "WORKS_FOR", direction: OUT)
registeredFor: [LobbyRegistration!]! @relationship(type: "REGISTERED_FOR", direction: OUT)
metWith: [MP!]! @relationship(type: "MET_WITH", direction: OUT, properties: "MetWithProperties")
}
# ============================================
# Legislative Entities
# ============================================
# Parliament and Session - Foundational structure
type Parliament @node {
number: Int! @unique
ordinal: String! # "45th"
election_date: Date!
opening_date: Date
dissolution_date: Date
party_in_power: String
prime_minister: String
total_seats: Int!
is_current: Boolean!
updated_at: DateTime!
# Relationships
sessions: [Session!]! @relationship(type: "HAS_SESSION", direction: OUT)
bills: [Bill!]! @relationship(type: "FROM_PARLIAMENT", direction: IN)
}
type Session @node {
id: ID! @unique # "45-1"
parliament_number: Int!
session_number: Int!
start_date: Date!
end_date: Date
prorogation_date: Date
is_current: Boolean!
updated_at: DateTime!
# Relationships
parliament: Parliament! @relationship(type: "HAS_SESSION", direction: IN)
bills: [Bill!]! @relationship(type: "FROM_SESSION", direction: IN)
votes: [Vote!]! @relationship(type: "FROM_SESSION", direction: IN)
documents: [Document!]! @relationship(type: "FROM_SESSION", direction: IN)
}
type Bill @node {
# Basic identifiers
number: String!
session: String!
parliament: Int
session_number: Int
# Titles
title: String
title_fr: String
# Summary & description
summary: String
summary_fr: String
full_summary_available: Boolean
# Status & progress
status: String
status_fr: String
stage: String
latest_event: String
# Bill classification
bill_type: String
bill_type_fr: String
is_government_bill: Boolean
is_private_member_bill: Boolean
originating_chamber: String
originating_chamber_fr: String
is_proforma: Boolean
bill_form: String
# Sponsor info
sponsor_name: String
sponsor_mp_id: String
# Dates - Introduction & reading stages
introduced_date: Date
passed_house_first_reading: Date
passed_house_second_reading: Date
passed_house_third_reading: Date
passed_senate_first_reading: Date
passed_senate_second_reading: Date
passed_senate_third_reading: Date
royal_assent: Date
# Legacy date fields (for backward compatibility)
passed_date: Date
royal_assent_date: Date
# Statute info (if passed into law)
statute_year: Int
statute_chapter: String
# Cross-session relationships
reinstated_from_previous: Boolean
reinstated_from_bill: String
# Metadata
updated_at: DateTime!
# Amendment tracking
has_amendments: Boolean # True if bill has been amended at committee/report stage
total_versions: Int # Number of published versions (1=just first reading, 2+=amended)
latest_version_stage: String # Stage of most recent version
last_amendment_check: DateTime # When bill was last checked for amendments
checked_at_royal_assent: Boolean # True if bill was checked at Royal Assent (final check)
# Full narrative text (continuous reading format from LEGISinfo XML)
full_text_en: String
full_text_fr: String
full_text_updated_at: DateTime
# Relationships
sponsor: MP @relationship(type: "SPONSORED", direction: IN)
senatorSponsor: Senator @relationship(type: "SPONSORED", direction: IN)
votes: [Vote!]! @relationship(type: "SUBJECT_OF", direction: IN)
hansardDebates: [Statement!]! @relationship(type: "MENTIONS", direction: IN, properties: "MentionsProperties")
debates: [Debate!]! @relationship(type: "DISCUSSED", direction: IN)
referredTo: [Committee!]! @relationship(type: "REFERRED_TO", direction: OUT)
lobbiedOnBy: [Organization!]! @relationship(type: "LOBBIED_ON", direction: IN, properties: "LobbiedOnProperties")
citedIn: [Case!]! @relationship(type: "CITED_IN", direction: OUT)
fromSession: Session @relationship(type: "FROM_SESSION", direction: OUT)
fromParliament: Parliament @relationship(type: "FROM_PARLIAMENT", direction: OUT)
# Bill structure (parsed from LEGISinfo XML)
parts: [BillPart!]! @relationship(type: "HAS_PART", direction: OUT)
sections: [BillSection!]! @relationship(type: "HAS_SECTION", direction: OUT)
versions: [BillVersion!]! @relationship(type: "HAS_VERSION", direction: OUT)
amendmentEvents: [BillAmendmentEvent!]! @relationship(type: "HAS_AMENDMENT_EVENT", direction: OUT)
# News mentions
mentionedIn: [NewsArticle!]! @relationship(type: "MENTIONED_IN", direction: OUT, properties: "MentionedInProperties")
}
# ============================================
# Bill Structure (Hierarchical Text)
# ============================================
type BillVersion @node {
id: ID! @unique
bill_id: String!
version_number: Int!
stage: String! # "first-reading", "committee", "third-reading", "royal-assent"
publication_type_name: String # "First Reading", "As amended by committee"
publication_date: DateTime
has_amendments: Boolean!
xml_url: String
pdf_url: String
updated_at: DateTime!
# Full narrative text (version-specific)
full_text_en: String
full_text_fr: String
full_text_extracted: Boolean
# Relationships
bill: Bill @relationship(type: "HAS_VERSION", direction: IN)
}
type BillAmendmentEvent @node {
id: ID! @unique
bill_id: String!
event_type: String! # "committee_report_with_amendments", "senate_amendment"
description_en: String!
description_fr: String
event_date: DateTime
chamber: String! # "House" or "Senate"
stage: String! # "Consideration in committee", "Report stage"
committee_code: String
committee_name: String
report_id: Int
report_number: Int
number_of_amendments: Int
updated_at: DateTime!
# Relationships
bill: Bill @relationship(type: "HAS_AMENDMENT_EVENT", direction: IN)
}
type BillPart @node {
id: ID! @unique
bill_id: String!
number: Int!
title_en: String
title_fr: String
anchor_id: String! @unique # bill:45-1:c-234:part-1
sequence: Int!
updated_at: DateTime!
# Relationships
bill: Bill @relationship(type: "HAS_PART", direction: IN)
sections: [BillSection!]! @relationship(type: "HAS_SECTION", direction: OUT)
}
type BillSection @node {
id: ID! @unique
bill_id: String!
number: String! # Can be "1", "2", or "2.1" for standalone subsections
marginal_note_en: String # Section title/description
marginal_note_fr: String
text_en: String # Direct section text (not in subsections)
text_fr: String
anchor_id: String! @unique # bill:45-1:c-234:s2
sequence: Int!
updated_at: DateTime!
# Amendment tracking - stores historical text versions as JSON
# Format: [{"version": 1, "stage": "first-reading", "text": "...", "date": "2024-01-01"}, ...]
text_history_en: String # JSON array of version-tagged historical English text
text_history_fr: String # JSON array of version-tagged historical French text
has_amendments: Boolean # True if text changed between versions
current_version: Int # Version number of current text
# JIT-generated plain language explanations (populated on first user request)
# Cached in Neo4j for all future requests - only generates once per section
explanation_en: String # 2-3 paragraph plain-language explanation
explanation_fr: String
explanation_model: String # Model used (e.g., "claude-sonnet-4-5")
explanation_generated_at: DateTime
explanation_tokens: Int # Combined input + output tokens for cost tracking
# Relationships (can be in a Part or directly in Bill)
part: BillPart @relationship(type: "HAS_SECTION", direction: IN)
bill: Bill @relationship(type: "HAS_SECTION", direction: IN)
subsections: [BillSubsection!]! @relationship(type: "HAS_SUBSECTION", direction: OUT)
}
type BillSubsection @node {
id: ID! @unique
section_id: String!
number: String! # "(1)", "(2)" - stored without parentheses
text_en: String
text_fr: String
anchor_id: String! @unique # bill:45-1:c-234:s2.1
sequence: Int!
updated_at: DateTime!
# Amendment tracking
text_history_en: String # JSON array of version-tagged historical text
text_history_fr: String
has_amendments: Boolean
current_version: Int
# Relationships
section: BillSection @relationship(type: "HAS_SUBSECTION", direction: IN)
paragraphs: [BillParagraph!]! @relationship(type: "HAS_PARAGRAPH", direction: OUT)
}
type BillParagraph @node {
id: ID! @unique
subsection_id: String!
letter: String! # "a", "b", "c" - stored without parentheses
text_en: String
text_fr: String
anchor_id: String! @unique # bill:45-1:c-234:s2.1.a
sequence: Int!
updated_at: DateTime!
# Amendment tracking
text_history_en: String # JSON array of version-tagged historical text
text_history_fr: String
has_amendments: Boolean
current_version: Int
# Relationships
subsection: BillSubsection @relationship(type: "HAS_PARAGRAPH", direction: IN)
subparagraphs: [BillSubparagraph!]! @relationship(type: "HAS_SUBPARAGRAPH", direction: OUT)
}
type BillSubparagraph @node {
id: ID! @unique
paragraph_id: String!
numeral: String! # "i", "ii", "iii" - Roman numerals
text_en: String
text_fr: String
anchor_id: String! @unique # bill:45-1:c-234:s2.1.a.i
sequence: Int!
updated_at: DateTime!
# Amendment tracking
text_history_en: String
text_history_fr: String
has_amendments: Boolean
current_version: Int
# Relationships
paragraph: BillParagraph @relationship(type: "HAS_SUBPARAGRAPH", direction: IN)
}
type BillDefinition @node {
id: ID! @unique
bill_id: String!
term_en: String!
term_fr: String
definition_en: String!
definition_fr: String
section_id: String # Which section contains this definition
updated_at: DateTime!
# Relationships
bill: Bill @relationship(type: "HAS_DEFINITION", direction: IN)
section: BillSection @relationship(type: "DEFINES", direction: IN)
}
type Vote @node {
# Primary identifiers - handle both property naming conventions
# Some votes have vote_number (from votes_xml_import), others have id (from lightweight_update)
id: ID! @cypher(statement: "RETURN toString(COALESCE(this.vote_number, this.id)) AS value", columnName: "value")
vote_number: Int @cypher(statement: "RETURN COALESCE(this.vote_number, toInteger(this.id)) AS value", columnName: "value")
number: Int! @cypher(statement: "RETURN COALESCE(this.vote_number, this.number, toInteger(this.id)) AS value", columnName: "value")
# Session info
parliament_number: Int
session_number: Int
# Vote details - handle both property naming conventions
date: DateTime! @cypher(statement: "RETURN COALESCE(this.date_time, this.date) AS value", columnName: "value")
result: String!
yeas: Int! @cypher(statement: "RETURN COALESCE(this.num_yeas, this.yeas) AS value", columnName: "value")
nays: Int! @cypher(statement: "RETURN COALESCE(this.num_nays, this.nays) AS value", columnName: "value")
paired: Int @cypher(statement: "RETURN COALESCE(this.num_paired, this.paired) AS value", columnName: "value")
description: String @cypher(statement: "RETURN COALESCE(this.subject, this.description) AS value", columnName: "value")
# Additional metadata
bill_number: String
vote_type: String
vote_type_id: Int
updated_at: DateTime
# Relationships
ballots: [Ballot!]! @relationship(type: "CAST_IN", direction: IN)
subjectOf: Bill @relationship(type: "SUBJECT_OF", direction: OUT)
fromSession: Session @relationship(type: "FROM_SESSION", direction: OUT)
}
type Ballot @node {
id: ID! @unique
vote_number: Int!
person_id: Int!
vote_value: String!
is_yea: Boolean!
is_nay: Boolean!
is_paired: Boolean!
person_first_name: String
person_last_name: String
person_salutation: String
constituency_name: String
province_territory: String
caucus_short_name: String
updated_at: DateTime!
# Relationships
castIn: Vote! @relationship(type: "CAST_IN", direction: OUT)
castBy: MP! @relationship(type: "CAST_BY", direction: OUT)
}
type Debate @node {
id: ID! @unique
date: Date!
topic: String
hansard_url: String
parliament: Int
session: String
# Video information (CPAC)
cpac_video_url: String # CPAC stream or episode URL
cpac_episode_id: String # CPAC episode identifier
video_start_time: DateTime # When video recording started
video_duration: Int # Duration in seconds
# Relationships
speakers: [MP!]! @relationship(type: "SPOKE_AT", direction: IN, properties: "SpokeAtProperties")
discussed: Bill @relationship(type: "DISCUSSED", direction: OUT)
}
# ============================================
# Hansard Documents & Statements
# ============================================
type Document @node {
id: ID! @unique
date: Date
number: Int
session_id: String
document_type: String # "D" = Debates, "E" = Evidence (Committee)
source_id: String
downloaded: Boolean
public: Boolean
xml_source_url: String
updated_at: DateTime
# TF-IDF keyword extraction (JSON string arrays)
keywords_en: String # JSON: [{"word": "keyword", "weight": 0.95}, ...]
keywords_fr: String # JSON: [{"word": "mot-clé", "weight": 0.95}, ...]
# Enhanced Hansard XML document metadata
creation_timestamp: DateTime # When document was created/published (MetaCreationTime)
speaker_of_day: String # Speaker of the House for this sitting
hansard_document_id: String # Official Hansard document identifier
parliament_number: Int # Parliament number (e.g., 45)
session_number: Int # Session number (e.g., 1)
volume: String # Hansard volume number
# Relationships
statements: [Statement!]! @relationship(type: "PART_OF", direction: IN)
speakers: [MP!]! @relationship(type: "SPOKE_AT", direction: IN, properties: "SpokeAtProperties")
presentedTo: Committee @relationship(type: "PRESENTED_TO", direction: OUT)
fromSession: Session @relationship(type: "FROM_SESSION", direction: OUT)
}
type Statement @node {
id: ID! @unique
document_id: Int
time: DateTime
politician_id: Int # OpenParliament politician ID
member_id: Int
who_en: String # Speaker display name (e.g., "Pierre Poilievre (Leader of the Opposition, CPC)")
who_fr: String
content_en: String # Full statement text in English
content_fr: String # Full statement text in French
h1_en: String # Top-level heading (e.g., "Government Orders")
h1_fr: String
h2_en: String # Sub-heading (e.g., "Budget Implementation Act, 2024")
h2_fr: String
h3_en: String # Detail heading
h3_fr: String
statement_type: String # "debate", "question", "answer", "interjection", etc.
wordcount: Int
procedural: Boolean
bill_debated_id: Int # OpenParliament bill ID
bill_debate_stage: String # "1", "2", "3" (reading stages)
slug: String
updated_at: DateTime
# Threading fields
thread_id: String # Conversation group identifier
parent_statement_id: Int # ID of statement this replies to
sequence_in_thread: Int # Order within conversation (0 = root)
# Enhanced Hansard XML metadata (from direct XML ingestion)
person_db_id: Int # House of Commons stable person database ID (Affiliation@DbId)
role_type_code: Int # Parliamentary role classification (1=PM, 2=MP, 9=Opposition Leader, 15=Speaker, 60107=Presiding Officer)
intervention_id: String # Hansard XML Intervention ID (distinct from statement id)
paragraph_ids: [String!] # ParaText IDs for precise paragraph-level citations
timestamp_hour: Int # Structured hour from Timestamp element (0-23)
timestamp_minute: Int # Structured minute from Timestamp element (0-59)
floor_language: String # Language spoken on floor (en/fr)
intervention_type: String # Type attribute from Intervention element
# Government response (for written questions)
# Finds the matching government response by looking for a Statement with:
# - Same h3_en (question number)
# - Same document_id (same Hansard sitting)
# - Liberal party member (government response) - must end with 'Lib.)'
# Returns all answer fields in a single query (more efficient than 6 separate queries)
answer: WrittenQuestionAnswer
@cypher(
statement: """
OPTIONAL MATCH (a:Statement)
WHERE a.h3_en = this.h3_en
AND a.document_id = this.document_id
AND a.who_en ENDS WITH 'Lib.)'
WITH a
LIMIT 1
RETURN {
id: toString(a.id),
time: a.time,
who_en: a.who_en,
who_fr: a.who_fr,
content_en: a.content_en,
content_fr: a.content_fr
} AS result
"""
columnName: "result"
)
# Relationships
madeBy: MP @relationship(type: "MADE_BY", direction: OUT)
partOf: Document @relationship(type: "PART_OF", direction: OUT)
mentions: Bill @relationship(type: "MENTIONS", direction: OUT, properties: "MentionsProperties")
replyTo: Statement @relationship(type: "REPLIES_TO", direction: OUT)
replies: [Statement!]! @relationship(type: "REPLIES_TO", direction: IN)
factChecks: [FactCheck!]! @relationship(type: "VERIFIED_BY", direction: OUT)
}
# Relationship properties for Statement → Bill (MENTIONS)
type MentionsProperties @relationshipProperties {
debate_stage: String # Which reading stage: "1", "2", or "3"
start_position: Int # Start character position in text
end_position: Int # End character position in text
raw_text: String # Original text that matched
confidence: Float # Confidence score (0-1)
}
# ============================================
# Written Questions
# ============================================
# Written Questions metadata from OurCommons website
# These are formal questions asked by MPs to ministers that require written responses
type WrittenQuestion @node {
id: ID! @unique # Format: "wq-45-1-762"
question_number: String! # "Q-762"
parliament_number: Int! # 45
session_number: Int! # 1
session_id: String! # "45-1"
date_asked: Date
asker_name: String # MP name who asked the question
asker_constituency: String
responding_department: String # Minister/department who responds
status: String # "Awaiting response", "Answered", "Withdrawn"
due_date: Date # Response deadline
answer_date: Date # When answered (if answered)
sessional_paper: String # Sessional paper reference (e.g., "8555-451-762")
question_text: String # Full text of the question asked
topics: [String!] # Topic tags
ourcommons_url: String # Link to OurCommons page
updated_at: DateTime!
# Relationships
askedBy: MP @relationship(type: "ASKED_BY", direction: OUT)
hansardQuestion: Statement @relationship(type: "HAS_HANSARD_QUESTION", direction: OUT)
hansardAnswer: Statement @relationship(type: "HAS_HANSARD_ANSWER", direction: OUT)
}
# ============================================
# Debate Browse/Detail Types
# ============================================
type DebateSummary {
document: DocumentSummary!
statement_count: Int!
speaker_count: Int!
top_topics: [String!]!
is_question_period: Boolean
}
type DocumentSummary {
id: ID!
date: Date
session_id: String
document_type: String
number: Int
keywords_en: String
keywords_fr: String
}
type DebateCalendarDay {
date: String!
hasHouseDebates: Boolean!
hasQuestionPeriod: Boolean!
hasCommittee: Boolean!
hasScheduledMeeting: Boolean!
scheduledMeetings: [ScheduledMeetingInfo!]!
}
type ScheduledMeetingInfo {
committee_code: String!
committee_name: String!
number: Int!
in_camera: Boolean!
}
type DebateDetail {
document: DocumentInfo!
statements: [StatementInfo!]!
sections: [String!]!
statement_count: Int!
}
type DocumentInfo {
id: ID!
date: Date
session_id: String
document_type: String
number: Int
xml_source_url: String
}
type MPInfo {
id: ID!
name: String
party: String
riding: String
photo_url: String
photo_url_source: String
}
# Written question with answer (for government MP pages)
type WrittenQuestionWithAnswer {
question: StatementInfo!
answer: WrittenQuestionAnswer!
partOf: DocumentInfo!
}
type StatementInfo {
id: ID!
time: DateTime
who_en: String
who_fr: String
content_en: String
content_fr: String
h1_en: String
h1_fr: String
h2_en: String
h2_fr: String
h3_en: String
h3_fr: String
statement_type: String
politician_id: Int
thread_id: String
parent_statement_id: Int
sequence_in_thread: Int
wordcount: Int
procedural: Boolean
madeBy: MPInfo
partOf: DocumentInfo
}
type WrittenQuestionAnswer {
id: String
time: DateTime
who_en: String
who_fr: String
content_en: String
content_fr: String
}
type Committee @node {
code: ID! @unique
name: String # Made nullable to handle edge cases in @neo4j/graphql query generation
mandate: String
chamber: String # Nullable - historical committees may not have a specified chamber
# Relationships
members: [MP!]! @relationship(type: "SERVES_ON", direction: IN, properties: "ServedOnProperties")
bills: [Bill!]! @relationship(type: "REFERRED_TO", direction: IN)
meetings: [Meeting!]! @relationship(type: "HELD_MEETING", direction: OUT)
evidence: [Document!]! @relationship(type: "PRESENTED_TO", direction: IN)
mentionedIn: [NewsArticle!]! @relationship(type: "MENTIONED_IN", direction: OUT, properties: "MentionedInProperties")
# Activity tracking fields (computed)
latestMeetingDate: Date
@cypher(
statement: """
MATCH (this)-[:HELD_MEETING]->(m:Meeting)
WHERE m.date IS NOT NULL
WITH m
ORDER BY m.date DESC
LIMIT 1
RETURN m.date AS date
"""
columnName: "date"
)
latestMeetingNumber: Int
@cypher(
statement: """
MATCH (this)-[:HELD_MEETING]->(m:Meeting)
WHERE m.date IS NOT NULL
WITH m
ORDER BY m.date DESC
LIMIT 1
RETURN m.number AS number
"""
columnName: "number"
)
totalMeetingsCount: Int
@cypher(
statement: """
MATCH (this)-[:HELD_MEETING]->(m:Meeting)
RETURN count(m) AS count
"""
columnName: "count"
)
}
type Meeting @node {
id: ID
ourcommons_meeting_id: String
committee_code: String!
date: Date
time_description: String
subject: String
status: String
webcast_available: Boolean
webcast: Boolean
televised: Boolean
travel: Boolean
source: String
imported_at: String
number: Int
in_camera: Boolean
has_evidence: Boolean
meeting_url: String
session: String
session_id: String
parliament: Int
evidence_id: String
start_time: String
end_time: String
updated_at: DateTime
created_at: DateTime
# Relationships
heldBy: Committee @relationship(type: "HELD_MEETING", direction: IN)
evidence: CommitteeEvidence @relationship(type: "HAS_EVIDENCE", direction: OUT)
}
type CommitteeEvidence @node {
id: ID! @unique
committee_code: String!
meeting_number: Int!
date: Date
title: String
parliament_number: Int
session_number: Int
publication_status: String
source_xml_url: String
updated_at: DateTime!
# Relationships
meeting: Meeting @relationship(type: "HAS_EVIDENCE", direction: IN)
testimonies: [CommitteeTestimony!]! @relationship(type: "GIVEN_IN", direction: IN)
speakers: [MP!]! @relationship(type: "SPOKE_AT", direction: IN, properties: "SpokeAtProperties")
committee: Committee @relationship(type: "EVIDENCE_FOR", direction: OUT)
}
type CommitteeTestimony @node {
id: ID! @unique
intervention_id: String
speaker_name: String
organization: String
role: String
text: String!
is_witness: Boolean!
person_db_id: Int
timestamp_hour: Int
timestamp_minute: Int
floor_language: String
updated_at: DateTime!
# Relationships
evidence: CommitteeEvidence! @relationship(type: "GIVEN_IN", direction: OUT)
speaker: MP @relationship(type: "TESTIFIED_BY", direction: OUT)
}
type Petition @node {
number: ID! @unique
title: String!
text: String!
signatures: Int!
status: String!
created_date: Date!
closed_date: Date
category: String
# Relationships
sponsor: MP @relationship(type: "SPONSORED", direction: IN)
}
# ============================================
# Financial Entities
# ============================================
type Expense @node {
id: ID! @unique
mp_id: String!
fiscal_year: Int!
quarter: Int!
category: String
amount: Float!
description: String
# Relationships
incurredBy: MP @relationship(type: "INCURRED", direction: IN)
}
type Contract @node {
id: ID! @unique
vendor: String!
amount: Float!
department: String!
date: Date!
delivery_date: Date
description: String!
owner_org: String
# Relationships
receivedBy: Organization @relationship(type: "RECEIVED", direction: IN)
}
type Grant @node {
id: ID! @unique
recipient: String!
amount: Float!
program_name: String!
program_purpose: String
agreement_date: Date!
agreement_year: Int!
start_date: Date
end_date: Date
owner_org: String!
recipient_city: String
recipient_province: String
# Relationships
receivedBy: Organization @relationship(type: "RECEIVED", direction: IN)
}
type Donation @node {
id: ID! @unique
donor_name: String!
amount: Float!
date: Date!
contribution_year: Int!
political_party: String!
recipient_type: String!
recipient_name: String!
electoral_district: String
donor_city: String
donor_province: String
# Relationships
receivedBy: Party @relationship(type: "RECEIVED", direction: IN)
}
# ============================================
# Lobbying
# ============================================
type LobbyRegistration @node {
id: ID! @unique
reg_number: String!
client_org_name: String!
registrant_name: String!
effective_date: Date!
end_date: Date
active: Boolean!
subject_matters: [String!]
government_institutions: [String!]
# Relationships
registeredBy: Lobbyist @relationship(type: "REGISTERED_FOR", direction: IN)
onBehalfOf: Organization @relationship(type: "ON_BEHALF_OF", direction: OUT)
}
type LobbyCommunication @node {
id: ID! @unique
client_org_name: String!
registrant_name: String
date: Date!
dpoh_names: [String!]
dpoh_titles: [String!]
institutions: [String!]
subject_matters: [String!]
# Relationships
organization: Organization @relationship(type: "COMMUNICATION_BY", direction: OUT)
lobbyist: Lobbyist @relationship(type: "CONDUCTED_BY", direction: OUT)
contacted: [MP!]! @relationship(type: "CONTACTED", direction: OUT)
}
# ============================================
# GC InfoBase (Departmental Performance & Spending)
# ============================================
type Department @node {
id: ID! @unique
name: String!
# Relationships
results: [DepartmentalResult!]! @relationship(type: "FROM_DEPARTMENT", direction: IN)
expenditures: [ProgramExpenditure!]! @relationship(type: "FROM_DEPARTMENT", direction: IN)
atipRequests: [ATIPRequest!]! @relationship(type: "FROM_DEPARTMENT", direction: IN)
}
type DepartmentalResult @node {
id: ID! @unique
organization: String!
organization_id: String!
fiscal_year: String!
program: String!
program_id: String!
indicator_name: String!
indicator_id: String!
target: String
actual_result: String
met_target: Boolean
updated_at: DateTime!
# Relationships
department: Department @relationship(type: "FROM_DEPARTMENT", direction: OUT)
}
type ProgramExpenditure @node {
id: ID! @unique
organization: String!
organization_id: String!
fiscal_year: String!
program: String!
program_id: String!
planned_spending: Float
actual_spending: Float
variance: Float
fte_planned: Float
fte_actual: Float
updated_at: DateTime!
# Relationships
department: Department @relationship(type: "FROM_DEPARTMENT", direction: OUT)
}
# ============================================
# ATIP (Access to Information Requests)
# ============================================
type ATIPRequest @node {
id: ID! @unique
request_number: String!
year: Int!
month: Int!
date: String!
organization: String!
organization_code: String!
summary_en: String
summary_fr: String
disposition: String!
disposition_description: String!
pages: Int
updated_at: DateTime!
# Relationships
department: Department @relationship(type: "FROM_DEPARTMENT", direction: OUT)
}
# Government Consultation type
type Consultation @node {
id: ID! @unique
registration_number: String!
title_en: String!
title_fr: String
description_en: String
description_fr: String
start_date: String
end_date: String
start_year: Int
status: String!
status_description: String!
subjects: [String!]!
subject_descriptions: [String!]!
organization: String!
organization_code: String!
partner_departments: String
profile_page_en: String
profile_page_fr: String
report_available_online: Boolean!
report_link_en: String
report_link_fr: String
updated_at: DateTime!
# Relationships
department: Department @relationship(type: "FROM_DEPARTMENT", direction: OUT)
}
# Departmental Travel Expense type
type DepartmentalTravel @node {
id: ID! @unique
ref_number: String!
organization: String!
organization_code: String!
disclosure_group: String
title_en: String
title_fr: String
name: String!
purpose_en: String
purpose_fr: String
start_date: String
end_date: String
travel_year: Int
destination_en: String
destination_fr: String
airfare: Float!
other_transport: Float!
lodging: Float!
meals: Float!
other_expenses: Float!
total: Float!
updated_at: DateTime!
# Relationships
department: Department @relationship(type: "FROM_DEPARTMENT", direction: OUT)
}
# Departmental Hospitality Expense type
type DepartmentalHospitality @node {
id: ID! @unique
ref_number: String!
organization: String!
organization_code: String!
disclosure_group: String
title_en: String
title_fr: String
name: String!
purpose_en: String
purpose_fr: String
start_date: String
end_date: String
hospitality_year: Int
attendees: Int
location_en: String
location_fr: String
total: Float!
updated_at: DateTime!
# Relationships
department: Department @relationship(type: "FROM_DEPARTMENT", direction: OUT)
}
# ============================================
# Legal (CanLII)
# ============================================
type Case @node {
id: ID! @unique
citation: String!
court: String!
date: Date!
summary: String
canlii_url: String!
# Relationships
citedBills: [Bill!]! @relationship(type: "CITED_IN", direction: IN)
citesLegislation: [Legislation!]! @relationship(type: "CITES", direction: OUT)
citesCases: [Case!]! @relationship(type: "CITES", direction: OUT)
}
type Legislation @node {
id: ID! @unique
title: String!
jurisdiction: String!
type: String!
date: Date
# Relationships
citedBy: [Case!]! @relationship(type: "CITES", direction: IN)
}
# ============================================
# Relationship Properties
# ============================================
type VotedProperties @relationshipProperties {
position: String! # "yea", "nay", "paired"
}
type MetWithProperties @relationshipProperties {
first_contact: Date!
last_contact: Date
}
type ServedOnProperties @relationshipProperties {
role: String
start_date: Date
}
type SpokeAtProperties @relationshipProperties {
timestamp: DateTime # For Hansard statements
statement_id: String # Statement node ID (for Hansard)
testimony_id: String # CommitteeTestimony node ID (for committees)
intervention_id: String # XML intervention ID
person_db_id: Int # House of Commons person database ID
timestamp_hour: Int # Structured hour (0-23)
timestamp_minute: Int # Structured minute (0-59)
}
type LobbiedOnProperties @relationshipProperties {
date: Date!
subject: String
}
type DonatedProperties @relationshipProperties {
via: String # "individual" or "corporate"
}
# ============================================
# Custom Types for Analytics
# ============================================
# MP Summary for Scorecard (plain object, not a Node)
type MPSummary {
id: ID!
name: String!
given_name: String
family_name: String
party: String
riding: String
current: Boolean!
cabinet_position: String
email: String
phone: String
updated_at: DateTime!
}
type MPScorecard {
mp: MP!
bills_sponsored: Int!
bills_passed: Int!
votes_participated: Int!
petitions_sponsored: Int!
total_petition_signatures: Int!
current_year_expenses: Float!
lobbyist_meetings: Int!
question_period_interjections: Int!
voting_participation_rate: Float!
party_discipline_score: Float!
legislative_success_rate: Float!
committee_activity_index: Float!
}
type MPAverages {
party_code: String!
party_name: String!
avg_voting_participation_rate: Float
avg_party_discipline_score: Float
avg_legislative_success_rate: Float
avg_committee_activity_index: Float
avg_bills_sponsored: Float
avg_bills_passed: Float
avg_current_year_expenses: Float
mp_count: Int!
}
type MPExpenseSummary {
mp: MP!
total_expenses: Float!
}
type ParliamentStats {
parliament: ParliamentInfo!
bill_count: Int!
vote_count: Int!
document_count: Int!
session_count: Int!
}
type ParliamentInfo {
number: Int!
ordinal: String!
election_date: Date!
opening_date: Date
dissolution_date: Date
party_in_power: String
prime_minister: String
total_seats: Int!
}
type SessionStats {
session: SessionInfo!
bill_count: Int!
vote_count: Int!
document_count: Int!
}
type SessionInfo {
id: ID!
parliament_number: Int!
session_number: Int!
start_date: Date!
end_date: Date
prorogation_date: Date
is_current: Boolean!
}
# TODO: Fix MPInterjectionStats validation error
# type MPInterjectionStats {
# mp: MP!
# interjection_count: Int!
# }
# TODO: Implement as custom resolver (type currently disabled)
# type GlobalExpenseStats {
# average_per_quarter: Float!
# median_per_quarter: Float!
# total_quarters: Int!
# }
type BillLobbyingActivity {
bill_number: String!
bill_session: String!
bill_title: String
bill_status: String
organizations_lobbying: Int!
total_lobbying_events: Int!
organizations: [OrganizationLobbyingSummary!]!
communications: [LobbyingCommunicationSummary!]!
}
type OrganizationLobbyingSummary {
name: String!
industry: String
lobbying_count: Int!
}
type LobbyingCommunicationSummary {
id: ID!
date: String!
subject: [String!]!
lobbyist_names: [String!]!
government_officials: [String!]!
organization_name: String!
organization_industry: String
}
type ConflictOfInterest {
mp: MP!
organization: Organization!
bill: Bill!
suspicion_score: Int!
}
type PartySpendingTrend {
quarter: Int!
period: String!
parties: [PartySpendingSummary!]!
total_all_parties: Float!
}
type PartySpendingSummary {
party: String!
total_expenses: Float!
mp_count: Int!
average_per_mp: Float!
}
type CommitteeActivityMetrics {
committee: Committee!
total_meetings: Int!
meetings_last_30_days: Int!
meetings_last_90_days: Int!
total_evidence_documents: Int!
active_bills_count: Int!
member_count: Int!
avg_statements_per_meeting: Float!
}
# News article - can be either:
# 1. Lazy-loaded from Google News RSS (no @node, ephemeral)
# 2. Persisted in Neo4j with entity relationships (when ingested by pipeline)
type NewsArticle @node {
id: ID! @unique
title: String!
url: String! @unique
source: String! # "cbc", "globe", "national_post", "ctv", "ipolitics"
summary: String # First 500 chars of article (copyright-safe)
published_at: DateTime
ingested_at: DateTime!
image_url: String
# For backwards compatibility with lazy-loaded news
published_date: String # @deprecated - use published_at
description: String # @deprecated - use summary
last_updated: String # @deprecated - use ingested_at
# Entity relationships (who/what is mentioned in this article)
mentionedMPs: [MP!]! @relationship(type: "MENTIONED_IN", direction: IN)
mentionedBills: [Bill!]! @relationship(type: "MENTIONED_IN", direction: IN)
mentionedCommittees: [Committee!]! @relationship(type: "MENTIONED_IN", direction: IN)
}
# Relationship property for news mentions (optional metadata)
type MentionedInProperties @relationshipProperties {
confidence: Float # Entity extraction confidence score (0.0-1.0)
mention_count: Int # How many times entity appears in article
}
# ============================================
# Custom Types for MP Counts
# ============================================
type MPCount {
count: Int!
}
# ============================================
# Government Spending Aggregation Types
# ============================================
type ContractorSummary {
vendor: String!
total_amount: Float!
contract_count: Int!
}
type DonorSummary {
donor_name: String!
donor_city: String
donor_province: String
total_amount: Float!
donation_count: Int!
parties: [String!]!
}
type PartyFundraisingSummary {
party: String!
year: Int!
total_amount: Float!
donation_count: Int!
unique_donors: Int!
average_donation: Float!
}
type GrantRecipientSummary {
recipient: String!
recipient_city: String
recipient_province: String
total_amount: Float!
grant_count: Int!
programs: [String!]!
}
type GrantProgramSummary {
program_name: String!
program_purpose: String
department: String!
total_amount: Float!
grant_count: Int!
unique_recipients: Int!
}
# ============================================
# Advanced MP Analysis Types
# ============================================
type MPVoteRecord {
vote_number: Int!
date: String!
subject: String
result: String!
position: String!
bill_number: String
bill_title: String
}
type PartyDisciplineRecord {
mp_id: ID!
mp_name: String!
party: String!
total_votes: Int!
aligned_votes: Int!
discipline_rate: Float!
}
type MPPerformanceComparison {
mp_id: ID!
mp_name: String!
party: String!
riding: String
bills_sponsored: Int!
bills_passed: Int!
votes_participated: Int!
committee_memberships: Int!
total_statements: Int!
}
type MPCommitteeInfo {
code: String!
name: String!
role: String
start_date: String
}
type MPCommitteeActivitySummary {
mp_id: ID!
mp_name: String!
committees: [MPCommitteeInfo!]!
total_committees: Int!
evidence_statements: Int!
}
type MPVotingParticipationStats {
mp_id: ID!
mp_name: String!
party: String!
votes_participated: Int!
total_votes: Int!
participation_rate: Float!
yea_votes: Int!
nay_votes: Int!
paired_votes: Int!
}
# ============================================
# Bill Analysis Types
# ============================================
type PartyBillsSummary {
party: String!
total_bills: Int!
passed_bills: Int!
in_committee: Int!
second_reading: Int!
}
type BillVoteSummary {
vote_number: Int!
date: String!
result: String!
yeas: Int!
nays: Int!
}
type BillProgressDetails {
bill_number: String!
session: String!
title: String
status: String
introduced_date: String
sponsor_name: String
sponsor_party: String
is_government_bill: Boolean
originating_chamber: String
votes: [BillVoteSummary!]!
}
type MPBillsAnalysisSummary {
mp_id: ID!
mp_name: String!
party: String!
total_bills: Int!
passed_bills: Int!
government_bills: Int!
private_member_bills: Int!
success_rate: Float!
}
# ============================================
# Extended Lobbying Types
# ============================================
type LobbyingClientSummary {
client_name: String!
communication_count: Int!
top_subjects: [String!]!
}
# ============================================
# Fact-Check Types
# ============================================
enum FactCheckVerdict {
TRUE
FALSE
MISLEADING
NEEDS_CONTEXT
UNVERIFIABLE
}
type FactCheckCitation {
url: String!
title: String!
excerpt: String
published_date: String
source_type: String
}
type FactCheck @node {
id: ID! @unique
claim_text: String!
claim_text_hash: String!
verdict: String!
confidence: Float!
rationale: String!
rationale_short: String
citations: String # JSON string of citations array
model_used: String
processing_time_ms: Int
verification_mode: String!
checked_at: DateTime!
created_at: DateTime!
source_statement_id: String
# Relationship to source statement (if fact-checking a Hansard statement)
sourceStatement: Statement @relationship(type: "VERIFIED_BY", direction: IN)
}
# ============================================
# Cross-Source Search Types
# ============================================
type CrossSourceSearchResult {
id: ID!
name: String!
source_type: String!
extra: String
}
type CrossSourceSearchResults {
total_count: Int!
results: [CrossSourceSearchResult!]!
}
# ============================================
# Custom Queries (Accountability Analytics)
# ============================================
type Query {
# Custom query to get a single meeting by ID (workaround for @neo4j/graphql ID filtering issue)
meeting(id: ID!): Meeting
@cypher(
statement: """
MATCH (m:Meeting)
WHERE toString(m.id) = $id OR m.ourcommons_meeting_id = $id
RETURN m
LIMIT 1
"""
columnName: "m"
)
# MP Performance Scorecard
# Test query to debug MPScorecard issues
testMPScorecard(mpId: ID!): MP
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
RETURN mp
"""
columnName: "mp"
)
mpScorecard(mpId: ID!): MPScorecard
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
WITH mp,
CASE
WHEN date().month < 4 THEN date().year
ELSE date().year + 1
END AS current_fiscal_year
// Calculate each metric separately to avoid Cartesian products
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:SPONSORED]->(bill:Bill)
RETURN count(DISTINCT bill) AS bills_sponsored,
count(DISTINCT CASE WHEN bill.status = 'Passed' THEN bill END) AS bills_passed
}
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:VOTED]->(vote:Vote)
RETURN count(DISTINCT vote) AS votes_participated
}
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:SPONSORED]->(petition:Petition)
RETURN count(DISTINCT petition) AS petitions_sponsored,
sum(petition.signatures) AS total_petition_signatures
}
CALL {
WITH mp, current_fiscal_year
OPTIONAL MATCH (mp)-[:INCURRED]->(expense:Expense {fiscal_year: current_fiscal_year})
RETURN sum(expense.amount) AS current_year_expenses
}
CALL {
WITH mp
OPTIONAL MATCH (mp)<-[:MET_WITH]-(lobbyist:Lobbyist)
RETURN count(DISTINCT lobbyist) AS lobbyist_meetings
}
CALL {
WITH mp
OPTIONAL MATCH (mp)<-[:MADE_BY]-(statement:Statement {statement_type: 'interjection'})
WHERE statement.h1_en CONTAINS 'Oral Question'
RETURN count(DISTINCT statement) AS question_period_interjections
}
// Calculate voting participation rate (% of all votes participated in)
CALL {
WITH votes_participated
MATCH (v:Vote)
WITH votes_participated, count(DISTINCT v) AS total_votes
RETURN CASE WHEN total_votes > 0
THEN (toFloat(votes_participated) / toFloat(total_votes)) * 100.0
ELSE 0.0 END AS voting_participation_rate
}
// Calculate party discipline score (% of votes aligned with party majority)
CALL {
WITH mp
OPTIONAL MATCH (mp)-[voted:VOTED]->(vote:Vote)
WITH mp, voted, vote
OPTIONAL MATCH (vote)<-[party_votes:VOTED]-(party_mp:MP)
WHERE party_mp.party = mp.party
WITH mp, vote, voted,
count(DISTINCT CASE WHEN party_votes.position = voted.position THEN party_mp END) AS same_position_count,
count(DISTINCT party_mp) AS total_party_votes
WITH mp,
count(DISTINCT vote) AS total_mp_votes,
count(DISTINCT CASE WHEN toFloat(same_position_count) / toFloat(total_party_votes) > 0.5 THEN vote END) AS aligned_votes
RETURN CASE WHEN total_mp_votes > 0
THEN (toFloat(aligned_votes) / toFloat(total_mp_votes)) * 100.0
ELSE 0.0 END AS party_discipline_score
}
// Calculate legislative success rate (% of bills that passed)
CALL {
WITH bills_sponsored, bills_passed
RETURN CASE WHEN bills_sponsored > 0
THEN (toFloat(bills_passed) / toFloat(bills_sponsored)) * 100.0
ELSE 0.0 END AS legislative_success_rate
}
// Calculate committee activity index (weighted score)
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:SERVES_ON]->(committee:Committee)
OPTIONAL MATCH (mp)<-[:MADE_BY]-(s:Statement)-[:PART_OF]->(d:Document {document_type: 'E'})
WITH mp, count(DISTINCT committee) AS committee_memberships, count(DISTINCT s) AS committee_statements
// Weight: 1 point per membership + 0.1 points per statement
RETURN (toFloat(committee_memberships) + toFloat(committee_statements) * 0.1) AS committee_activity_index
}
// Collect all variables for final return
WITH mp,
bills_sponsored,
bills_passed,
votes_participated,
petitions_sponsored,
total_petition_signatures,
current_year_expenses,
lobbyist_meetings,
question_period_interjections,
voting_participation_rate,
party_discipline_score,
legislative_success_rate,
committee_activity_index
// Return scorecard with explicitly projected MP fields
RETURN {
mp: {
id: mp.id,
name: mp.name,
given_name: mp.given_name,
family_name: mp.family_name,
party: mp.party,
riding: mp.riding,
current: mp.current,
cabinet_position: mp.cabinet_position,
email: mp.email,
phone: mp.phone,
updated_at: mp.updated_at
},
bills_sponsored: bills_sponsored,
bills_passed: bills_passed,
votes_participated: votes_participated,
petitions_sponsored: petitions_sponsored,
total_petition_signatures: COALESCE(total_petition_signatures, 0),
current_year_expenses: COALESCE(current_year_expenses, 0.0),
lobbyist_meetings: lobbyist_meetings,
question_period_interjections: question_period_interjections,
voting_participation_rate: voting_participation_rate,
party_discipline_score: party_discipline_score,
legislative_success_rate: legislative_success_rate,
committee_activity_index: committee_activity_index
} AS scorecard
"""
columnName: "scorecard"
)
# Case-insensitive MP search using full-text index for performance
# Requires mp_name_search full-text index on MP nodes
searchMPs(
searchTerm: String
party: String
current: Boolean
cabinetOnly: Boolean
limit: Int = 500
): [MP!]!
@cypher(
statement: """
CALL {
// Branch 1: When search term is provided - use full-text index
WITH $searchTerm AS term
WHERE term IS NOT NULL AND trim(coalesce(term, '')) <> ''
CALL db.index.fulltext.queryNodes('mp_name_search', term + '*')
YIELD node AS mp, score
RETURN mp, score
UNION ALL
// Branch 2: When no search term - return all MPs
WITH $searchTerm AS term
WHERE term IS NULL OR trim(coalesce(term, '')) = ''
MATCH (mp:MP)
RETURN mp, 1.0 AS score
}
WITH mp, max(score) AS score
WHERE ($current IS NULL OR mp.current = $current)
AND ($party IS NULL OR mp.party = $party)
AND ($cabinetOnly IS NULL OR $cabinetOnly = false OR mp.cabinet_position IS NOT NULL)
RETURN mp
ORDER BY
CASE WHEN $searchTerm IS NOT NULL AND trim(coalesce($searchTerm, '')) <> '' THEN score ELSE 0 END DESC,
mp.name ASC
LIMIT $limit
"""
columnName: "mp"
)
# Paginated MPs with offset-based pagination for optimal performance
# Supports server-side party filtering (multiple parties)
# Uses full-text index for search performance
paginatedMPs(
parties: [String!]
current: Boolean = true
cabinetOnly: Boolean
searchTerm: String
limit: Int = 24
offset: Int = 0
): [MP!]!
@cypher(
statement: """
CALL {
// Branch 1: When search term is provided - use full-text index
WITH $searchTerm AS term
WHERE term IS NOT NULL AND trim(coalesce(term, '')) <> ''
CALL db.index.fulltext.queryNodes('mp_name_search', term + '*')
YIELD node AS mp, score
RETURN mp, score
UNION ALL
// Branch 2: When no search term - return all MPs
WITH $searchTerm AS term
WHERE term IS NULL OR trim(coalesce(term, '')) = ''
MATCH (mp:MP)
RETURN mp, 1.0 AS score
}
WITH mp, max(score) AS score
WHERE ($current IS NULL OR mp.current = $current)
AND ($cabinetOnly IS NULL OR $cabinetOnly = false OR mp.cabinet_position IS NOT NULL)
AND ($parties IS NULL OR size($parties) = 0 OR mp.party IN $parties)
WITH mp, score
ORDER BY
CASE WHEN $searchTerm IS NOT NULL AND trim(coalesce($searchTerm, '')) <> '' THEN score ELSE 0 END DESC,
mp.name ASC
SKIP $offset
LIMIT $limit
RETURN mp
"""
columnName: "mp"
)
# Count MPs matching filters (for pagination info)
# Uses full-text index for search performance
countMPs(
parties: [String!]
current: Boolean = true
cabinetOnly: Boolean
searchTerm: String
): MPCount!
@cypher(
statement: """
CALL {
// Branch 1: When search term is provided - use full-text index
WITH $searchTerm AS term
WHERE term IS NOT NULL AND trim(coalesce(term, '')) <> ''
CALL db.index.fulltext.queryNodes('mp_name_search', term + '*')
YIELD node AS mp
RETURN mp
UNION ALL
// Branch 2: When no search term - return all MPs
WITH $searchTerm AS term
WHERE term IS NULL OR trim(coalesce(term, '')) = ''
MATCH (mp:MP)
RETURN mp
}
WITH DISTINCT mp
WHERE ($current IS NULL OR mp.current = $current)
AND ($cabinetOnly IS NULL OR $cabinetOnly = false OR mp.cabinet_position IS NOT NULL)
AND ($parties IS NULL OR size($parties) = 0 OR mp.party IN $parties)
RETURN {count: count(mp)} AS result
"""
columnName: "result"
)
# Server-side randomized MPs with optional party filtering
randomMPs(
parties: [String!]
limit: Int = 8
): [MP!]!
@cypher(
statement: """
MATCH (mp:MP)
WHERE mp.current = true
AND ($parties IS NULL OR size($parties) = 0 OR mp.party IN $parties)
WITH mp, rand() AS r
ORDER BY r
LIMIT $limit
RETURN mp
"""
columnName: "mp"
)
# TODO: Re-enable mpInterjectionLeaderboard after fixing MPInterjectionStats validation
# MPs ranked by Question Period interjections
# mpInterjectionLeaderboard(
# party: String
# limit: Int = 100
# ): [MPInterjectionStats!]!
# @cypher(
# statement: """
# MATCH (mp:MP)
# WHERE mp.current = true
# AND ($party IS NULL OR mp.party = $party)
# OPTIONAL MATCH (mp)<-[:MADE_BY]-(statement:Statement {statement_type: 'interjection'})
# WHERE statement.h1_en CONTAINS 'Oral Question'
# WITH mp, count(DISTINCT statement) AS interjection_count
# RETURN {
# mp: mp,
# interjection_count: interjection_count
# } AS result
# ORDER BY interjection_count DESC, mp.name ASC
# LIMIT $limit
# """
# columnName: "result"
# )
# Case-insensitive Bill search with filters
searchBills(
searchTerm: String
status: String
session: String
bill_type: String
is_government_bill: Boolean
originating_chamber: String
limit: Int = 100
): [Bill!]!
@cypher(
statement: """
MATCH (b:Bill)
WHERE ($status IS NULL OR b.status = $status)
AND ($session IS NULL OR b.session = $session)
AND ($bill_type IS NULL OR b.bill_type = $bill_type)
AND ($is_government_bill IS NULL OR b.is_government_bill = $is_government_bill)
AND ($originating_chamber IS NULL OR b.originating_chamber = $originating_chamber)
AND (
$searchTerm IS NULL OR $searchTerm = '' OR
toLower(COALESCE(b.title, '')) CONTAINS toLower($searchTerm) OR
toLower(COALESCE(b.number, '')) CONTAINS toLower($searchTerm)
)
WITH b
ORDER BY b.introduced_date DESC
LIMIT $limit
RETURN b
"""
columnName: "b"
)
# Case-insensitive Lobbying search
searchLobbyRegistrations(
searchTerm: String
active: Boolean
limit: Int = 50
): [LobbyRegistration!]!
@cypher(
statement: """
MATCH (l:LobbyRegistration)
WHERE ($active IS NULL OR l.active = $active)
AND (
$searchTerm IS NULL OR $searchTerm = '' OR
toLower(COALESCE(l.client_org_name, '')) CONTAINS toLower($searchTerm)
)
RETURN l
ORDER BY l.effective_date DESC
LIMIT $limit
"""
columnName: "l"
)
# Top Spenders (MPs by expenses)
# Custom resolver in server.ts handles this query with proper integer conversion
topSpenders(fiscalYear: Int, limit: Int = 10): [MPExpenseSummary!]!
# Party Spending Trends (quarterly spending by party)
partySpendingTrends(fiscalYear: Int): [PartySpendingTrend!]!
@cypher(
statement: """
MATCH (mp:MP)-[:INCURRED]->(e:Expense)
WHERE $fiscalYear IS NULL OR e.fiscal_year = $fiscalYear
WITH
CASE WHEN $fiscalYear IS NULL THEN e.fiscal_year ELSE $fiscalYear END AS fy,
e.quarter AS quarter,
mp.party AS party,
e.amount AS amount,
mp
WITH fy, quarter, party, sum(amount) AS total_expenses, count(DISTINCT mp.id) AS mp_count
WITH fy, quarter, collect({
party: party,
total_expenses: total_expenses,
mp_count: mp_count,
average_per_mp: total_expenses / toFloat(mp_count)
}) AS parties
WITH fy, quarter, parties, reduce(total = 0.0, p IN parties | total + p.total_expenses) AS total_all_parties
RETURN {
quarter: quarter,
period: CASE WHEN $fiscalYear IS NULL THEN 'FY' + toString(fy) + '-Q' + toString(quarter) ELSE 'Q' + toString(quarter) END,
parties: parties,
total_all_parties: total_all_parties
} AS trend
ORDER BY CASE WHEN $fiscalYear IS NULL THEN fy ELSE 0 END, quarter
"""
columnName: "trend"
)
# Global Expense Statistics
# TODO: Implement as custom resolver (currently disabled due to type validation issue)
# globalExpenseStats: GlobalExpenseStats!
# @cypher(
# statement: """
# MATCH (mp:MP)-[:INCURRED]->(e:Expense)
# WITH e.fiscal_year AS fiscal_year, e.quarter AS quarter, mp.id AS mp_id, sum(e.amount) AS quarter_total
# WITH collect(quarter_total) AS all_quarter_totals
# RETURN {
# average_per_quarter: reduce(sum = 0.0, x IN all_quarter_totals | sum + x) / size(all_quarter_totals),
# median_per_quarter: all_quarter_totals[size(all_quarter_totals) / 2],
# total_quarters: size(all_quarter_totals)
# } AS stats
# """
# columnName: "stats"
# )
# Bill Lobbying Activity
billLobbying(billNumber: String!, session: String!): BillLobbyingActivity
@cypher(
statement: """
MATCH (bill:Bill {number: $billNumber, session: $session})
// Fetch organization summaries
OPTIONAL MATCH (org:Organization)-[l:LOBBIED_ON]->(bill)
WHERE org IS NOT NULL
WITH bill, org, count(l) as lobbying_count
WITH bill,
count(DISTINCT org) as organizations_lobbying,
sum(lobbying_count) as total_lobbying_events,
collect(DISTINCT {name: org.name, industry: org.industry, lobbying_count: lobbying_count}) as organizations
// Fetch all lobbying communications for this bill
OPTIONAL MATCH (comm:LobbyCommunication)-[:COMMUNICATION_BY]->(org2:Organization)-[:LOBBIED_ON]->(bill)
WITH bill, organizations_lobbying, total_lobbying_events, organizations,
collect(DISTINCT {
id: comm.id,
date: toString(comm.date),
subject: comm.subject_matters,
lobbyist_names: COALESCE(comm.dpoh_names, []),
government_officials: COALESCE(comm.dpoh_titles, []),
organization_name: comm.client_org_name,
organization_industry: org2.industry
}) as communications
WHERE size(organizations) > 0 OR organizations_lobbying = 0
RETURN {
bill_number: bill.number,
bill_session: bill.session,
bill_title: bill.title,
bill_status: bill.status,
organizations_lobbying: COALESCE(organizations_lobbying, 0),
total_lobbying_events: COALESCE(total_lobbying_events, 0),
organizations: CASE WHEN organizations_lobbying > 0 THEN organizations ELSE [] END,
communications: CASE WHEN size(communications) > 0 AND communications[0].id IS NOT NULL THEN communications ELSE [] END
} AS activity
"""
columnName: "activity"
)
# Detect Conflicts of Interest
conflictsOfInterest(limit: Int = 20): [ConflictOfInterest!]!
@cypher(
statement: """
MATCH (org:Organization)-[:LOBBIED_ON]->(bill:Bill)
MATCH (org)-[:DONATED]->(party:Party)
MATCH (party)<-[:MEMBER_OF]-(mp:MP)-[v:VOTED]->(vote:Vote)-[:SUBJECT_OF]->(bill)
WHERE v.position = 'yea'
AND exists((org)-[:RECEIVED]->(:Contract))
WITH mp, org, bill, count(*) AS suspicion_score
RETURN {
mp: mp,
organization: org,
bill: bill,
suspicion_score: suspicion_score
} AS conflict
ORDER BY suspicion_score DESC
LIMIT $limit
"""
columnName: "conflict"
)
# MP News Articles
# Note: This is implemented as a custom resolver in server.ts
mpNews(mpName: String!, limit: Int = 10): [NewsArticle!]!
# ============================================
# Optimized Lobbying Search Queries
# ============================================
# Full-text search for lobbyists (optimized)
searchLobbyists(
searchTerm: String!
limit: Int = 20
): [Lobbyist!]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('lobbyist_search', $searchTerm)
YIELD node, score
WITH node AS lobbyist, score
WHERE score > 0.5
RETURN lobbyist
ORDER BY score DESC
LIMIT $limit
"""
columnName: "lobbyist"
)
# Full-text search for organizations (optimized)
searchOrganizations(
searchTerm: String!
limit: Int = 20
): [Organization!]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('organization_search', $searchTerm)
YIELD node, score
WITH node AS org, score
WHERE score > 0.5
RETURN org
ORDER BY score DESC
LIMIT $limit
"""
columnName: "org"
)
# Full-text search for bills (optimized)
searchBillsFullText(
searchTerm: String!
status: String
session: String
limit: Int = 50
): [Bill!]!
@cypher(
statement: """
CALL db.index.fulltext.queryNodes('bill_search', $searchTerm)
YIELD node, score
WITH node AS bill, score
WHERE score > 0.5
AND ($status IS NULL OR bill.status = $status)
AND ($session IS NULL OR bill.session = $session)
RETURN bill
ORDER BY score DESC, bill.introduced_date DESC
LIMIT $limit
"""
columnName: "bill"
)
# ============================================
# Hansard Queries
# ============================================
# Get recent speeches by an MP
mpSpeeches(
mpId: ID!
limit: Int = 20
documentType: String # Filter by "D" (Debates) or "E" (Evidence/Committee)
): [Statement!]!
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})<-[:MADE_BY]-(s:Statement)
WHERE $documentType IS NULL OR
exists((s)-[:PART_OF]->(:Document {document_type: $documentType}))
WITH s
ORDER BY s.time DESC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Get debate statements for a specific bill
billDebates(
billNumber: String!
session: String!
limit: Int = 50
debateStage: String # Filter by reading stage: "1", "2", or "3"
): [Statement!]!
@cypher(
statement: """
MATCH (b:Bill {number: $billNumber, session: $session})<-[r:MENTIONS]-(s:Statement)
WHERE $debateStage IS NULL OR r.debate_stage = $debateStage
WITH s, r
ORDER BY s.id ASC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Full-text search across Hansard content
searchHansard(
query: String!
limit: Int = 50
language: String = "en" # "en" or "fr"
): [Statement!]!
@cypher(
statement: """
CALL {
WITH $query AS query, $language AS language
CALL db.index.fulltext.queryNodes(
CASE WHEN language = 'fr' THEN 'statement_content_fr' ELSE 'statement_content_en' END,
query
) YIELD node, score
RETURN node, score
}
WITH node AS s, score
ORDER BY score DESC, s.time DESC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Get written questions (Questions on the Order Paper)
writtenQuestions(
limit: Int = 50
answered: Boolean # Filter by whether question has an answer
mpId: ID # Filter by MP who asked
session: String # Filter by parliamentary session (e.g., "45-1")
): [Statement!]!
@cypher(
statement: """
MATCH (s:Statement)-[:PART_OF]->(d:Document)
WHERE s.h2_en CONTAINS 'Questions on the Order Paper'
AND s.h3_en IS NOT NULL
AND s.h3_en <> ''
AND s.h3_en CONTAINS 'Question No'
AND NOT s.who_en CONTAINS 'Lib.'
AND s.who_en IS NOT NULL
AND s.who_en <> ''
AND ($mpId IS NULL OR EXISTS((s)<-[:MADE_BY]-(:MP {id: $mpId})))
AND ($session IS NULL OR d.session_id = $session)
# Filter by answered status if requested
OPTIONAL MATCH (a:Statement)
WHERE a.h3_en = s.h3_en
AND a.document_id = s.document_id
AND a.who_en CONTAINS 'Lib.'
WITH s, a
WHERE $answered IS NULL OR ($answered = true AND a IS NOT NULL) OR ($answered = false AND a IS NULL)
WITH s
ORDER BY s.time DESC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Get written questions asked by a specific MP
# Optional session parameter filters to specific parliamentary session (e.g., "45-1")
mpWrittenQuestions(
mpId: ID!
limit: Int = 50
session: String
): [Statement!]!
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})<-[:MADE_BY]-(s:Statement)-[:PART_OF]->(d:Document)
WHERE s.h2_en CONTAINS 'Questions on the Order Paper'
AND s.h3_en IS NOT NULL
AND s.h3_en <> ''
AND s.h3_en CONTAINS 'Question No'
AND ($session IS NULL OR d.session_id = $session)
WITH s
ORDER BY s.time DESC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Get list of parliamentary sessions that have written questions
# Returns session IDs like "45-1", "44-1", etc. in descending order
writtenQuestionSessions: [String!]!
@cypher(
statement: """
MATCH (s:Statement)-[:PART_OF]->(d:Document)
WHERE s.h2_en CONTAINS 'Questions on the Order Paper'
AND s.h3_en CONTAINS 'Question No'
AND d.session_id IS NOT NULL
WITH DISTINCT d.session_id AS session_id
ORDER BY session_id DESC
RETURN session_id
"""
columnName: "session_id"
)
# Get written questions answered by a government MP
# Returns opposition questions with this MP's answers
# For use on Liberal/government MP pages
mpAnsweredQuestions(
mpId: ID!
limit: Int = 50
session: String
): [WrittenQuestionWithAnswer!]!
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
// Find answers provided by this government MP
MATCH (mp)<-[:MADE_BY]-(answer:Statement)-[:PART_OF]->(d:Document)
WHERE answer.h2_en CONTAINS 'Questions on the Order Paper'
AND answer.h3_en IS NOT NULL
AND answer.h3_en <> ''
AND answer.h3_en CONTAINS 'Question No'
AND answer.who_en CONTAINS 'Lib.'
AND ($session IS NULL OR d.session_id = $session)
// Find the original opposition question for each answer
MATCH (question:Statement)-[:PART_OF]->(d)
WHERE question.h3_en = answer.h3_en
AND NOT question.who_en CONTAINS 'Lib.'
// Get the MP who made the question
OPTIONAL MATCH (question)-[:MADE_BY]->(question_mp:MP)
WITH question, answer, d, question_mp
ORDER BY answer.time DESC
LIMIT $limit
RETURN {
question: {
id: toString(question.id),
time: question.time,
who_en: question.who_en,
who_fr: question.who_fr,
content_en: question.content_en,
content_fr: question.content_fr,
h1_en: question.h1_en,
h1_fr: question.h1_fr,
h2_en: question.h2_en,
h2_fr: question.h2_fr,
h3_en: question.h3_en,
h3_fr: question.h3_fr,
statement_type: question.statement_type,
politician_id: question.politician_id,
thread_id: question.thread_id,
parent_statement_id: question.parent_statement_id,
sequence_in_thread: question.sequence_in_thread,
wordcount: question.wordcount,
procedural: question.procedural,
madeBy: CASE WHEN question_mp IS NOT NULL THEN {
id: question_mp.id,
name: question_mp.name,
party: question_mp.party,
riding: question_mp.riding,
photo_url: question_mp.photo_url,
photo_url_source: question_mp.photo_url_source
} ELSE null END,
partOf: {
id: toString(d.id),
date: d.date,
session_id: d.session_id,
document_type: d.document_type,
number: d.number,
xml_source_url: d.xml_source_url
}
},
answer: {
id: toString(answer.id),
time: answer.time,
who_en: answer.who_en,
who_fr: answer.who_fr,
content_en: answer.content_en,
content_fr: answer.content_fr
},
partOf: {
id: toString(d.id),
date: d.date,
session_id: d.session_id,
document_type: d.document_type,
number: d.number,
xml_source_url: d.xml_source_url
}
} AS result
"""
columnName: "result"
)
# Search written questions by keyword
searchWrittenQuestions(
searchTerm: String!
limit: Int = 50
language: String = "en"
): [Statement!]!
@cypher(
statement: """
CALL {
WITH $searchTerm AS query, $language AS language
CALL db.index.fulltext.queryNodes(
CASE WHEN language = 'fr' THEN 'statement_content_fr' ELSE 'statement_content_en' END,
query
) YIELD node, score
RETURN node, score
}
WITH node AS s, score
WHERE s.h2_en CONTAINS 'Questions on the Order Paper'
AND s.h3_en IS NOT NULL
AND s.h3_en <> ''
AND s.h3_en CONTAINS 'Question No'
AND NOT s.who_en CONTAINS 'Lib.'
AND s.who_en IS NOT NULL
AND s.who_en <> ''
WITH s, score
ORDER BY score DESC, s.time DESC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Get a specific Hansard document with all its statements
hansardDocument(id: ID!): Document
@cypher(
statement: """
MATCH (d:Document {id: $id})
RETURN d
"""
columnName: "d"
)
# List recent debates (browse view)
recentDebates(
limit: Int = 20
documentType: String # "D" (Debates) or "E" (Evidence)
questionPeriodOnly: Boolean = false
startDate: String # Filter by date range (YYYY-MM-DD)
endDate: String # Filter by date range (YYYY-MM-DD)
): [DebateSummary!]!
@cypher(
statement: """
MATCH (d:Document)
WHERE d.public = true
AND ($documentType IS NULL OR d.document_type = $documentType)
AND ($startDate IS NULL OR d.date >= $startDate)
AND ($endDate IS NULL OR d.date <= $endDate)
OPTIONAL MATCH (d)<-[:PART_OF]-(s:Statement)
WITH d, s
WHERE NOT $questionPeriodOnly OR s.h1_en CONTAINS 'Oral Question' OR s.h1_en CONTAINS 'Question Period'
WITH d,
count(DISTINCT s) AS statement_count,
count(DISTINCT s.politician_id) AS speaker_count,
collect(DISTINCT s.h2_en)[0..3] AS top_topics
WHERE statement_count > 0
RETURN {
document: {
id: d.id,
date: d.date,
session_id: d.session_id,
document_type: d.document_type,
number: d.number,
keywords_en: d.keywords_en,
keywords_fr: d.keywords_fr
},
statement_count: statement_count,
speaker_count: speaker_count,
top_topics: [topic IN top_topics WHERE topic IS NOT NULL]
} AS summary
ORDER BY d.date DESC
LIMIT $limit
"""
columnName: "summary"
)
# Get full debate with all statements (for debate detail page)
debateWithStatements(
documentId: ID!
includeThreading: Boolean = true
): DebateDetail
@cypher(
statement: """
MATCH (d:Document {id: toInteger($documentId)})
MATCH (d)<-[:PART_OF]-(s:Statement)
OPTIONAL MATCH (s)-[:MADE_BY]->(mp:MP)
WITH d, s, mp
ORDER BY s.id ASC
WITH d,
collect({
id: s.id,
time: s.time,
who_en: s.who_en,
who_fr: s.who_fr,
content_en: s.content_en,
content_fr: s.content_fr,
h1_en: s.h1_en,
h2_en: s.h2_en,
h3_en: s.h3_en,
statement_type: s.statement_type,
politician_id: s.politician_id,
thread_id: s.thread_id,
parent_statement_id: s.parent_statement_id,
sequence_in_thread: s.sequence_in_thread,
wordcount: s.wordcount,
procedural: s.procedural,
madeBy: CASE WHEN mp IS NOT NULL THEN {
id: mp.id,
name: mp.name,
party: mp.party,
photo_url: mp.photo_url,
photo_url_source: mp.photo_url_source
} ELSE null END,
partOf: {
id: d.id,
date: d.date,
document_type: d.document_type
}
}) AS statements,
count(DISTINCT s.h1_en) AS section_count,
collect(DISTINCT s.h1_en) AS sections
RETURN {
document: {
id: d.id,
date: d.date,
session_id: d.session_id,
document_type: d.document_type,
number: d.number,
xml_source_url: d.xml_source_url
},
statements: statements,
sections: [section IN sections WHERE section IS NOT NULL],
statement_count: size(statements)
} AS detail
"""
columnName: "detail"
)
# Get calendar data for debates (for calendar view)
# Custom resolver in server.ts that merges Neo4j data with OpenParliament scheduled meetings
debatesCalendarData(
startDate: String!
endDate: String!
): [DebateCalendarDay!]!
# Question Period debates only
questionPeriodDebates(
limit: Int = 10
sinceDate: Date
): [DebateSummary!]!
@cypher(
statement: """
MATCH (d:Document)<-[:PART_OF]-(s:Statement)
WHERE d.public = true
AND d.document_type = 'D'
AND (s.h1_en CONTAINS 'Oral Question' OR s.h1_en CONTAINS 'Question Period')
AND ($sinceDate IS NULL OR d.date >= date($sinceDate))
WITH d,
count(DISTINCT s) AS statement_count,
count(DISTINCT s.politician_id) AS speaker_count,
collect(DISTINCT s.h2_en)[0..5] AS top_topics
RETURN {
document: {
id: d.id,
date: d.date,
session_id: d.session_id,
document_type: d.document_type,
number: d.number,
keywords_en: d.keywords_en,
keywords_fr: d.keywords_fr
},
statement_count: statement_count,
speaker_count: speaker_count,
top_topics: [topic IN top_topics WHERE topic IS NOT NULL],
is_question_period: true
} AS summary
ORDER BY d.date DESC
LIMIT $limit
"""
columnName: "summary"
)
# ============================================
# Committee Queries
# ============================================
# Get recent testimony/evidence for a committee
committeeTestimony(committeeCode: String!, limit: Int = 20): [Statement!]!
@cypher(
statement: """
MATCH (c:Committee {code: $committeeCode})<-[:SERVES_ON]-(mp:MP)<-[:MADE_BY]-(s:Statement)-[:PART_OF]->(d:Document {document_type: 'E'})
WITH s, d
ORDER BY s.time DESC
LIMIT $limit
RETURN s
"""
columnName: "s"
)
# Committee activity metrics
committeeActivityMetrics(committeeCode: String!): CommitteeActivityMetrics
@cypher(
statement: """
MATCH (c:Committee {code: $committeeCode})
OPTIONAL MATCH (c)-[:HELD_MEETING]->(m:Meeting)
OPTIONAL MATCH (c)<-[:PRESENTED_TO]-(e:Document {document_type: 'E'})
OPTIONAL MATCH (c)<-[:REFERRED_TO]-(b:Bill)
WITH c, m, e, b,
date() - duration({days: 30}) AS thirtyDaysAgo,
date() - duration({days: 90}) AS ninetyDaysAgo
WITH c,
count(DISTINCT m) AS totalMeetings,
count(DISTINCT CASE WHEN m.date >= thirtyDaysAgo THEN m END) AS meetings30,
count(DISTINCT CASE WHEN m.date >= ninetyDaysAgo THEN m END) AS meetings90,
count(DISTINCT e) AS evidenceDocs,
count(DISTINCT CASE WHEN b.status IN ['In Committee', 'Reported'] THEN b END) AS activeBills,
size((c)<-[:SERVES_ON]-()) AS memberCount
OPTIONAL MATCH (c)-[:HELD_MEETING]->(allM:Meeting)
OPTIONAL MATCH (allM)<-[:PART_OF]-(s:Statement)
WITH c, totalMeetings, meetings30, meetings90, evidenceDocs, activeBills, memberCount,
count(DISTINCT s) AS totalStatements,
count(DISTINCT allM) AS meetingsWithStatements
RETURN {
committee: c,
total_meetings: toInteger(totalMeetings),
meetings_last_30_days: toInteger(meetings30),
meetings_last_90_days: toInteger(meetings90),
total_evidence_documents: toInteger(evidenceDocs),
active_bills_count: toInteger(activeBills),
member_count: toInteger(memberCount),
avg_statements_per_meeting: CASE WHEN meetingsWithStatements > 0
THEN toFloat(totalStatements) / toFloat(meetingsWithStatements)
ELSE 0.0
END
}
"""
columnName: "committeeActivityMetrics"
)
# Party performance averages for comparison
mpPartyAverages(partyCode: String!): MPAverages
@cypher(
statement: """
MATCH (party:Party {code: $partyCode})<-[:MEMBER_OF]-(mp:MP)
WHERE mp.current = true
WITH party, mp,
CASE
WHEN date().month < 4 THEN date().year
ELSE date().year + 1
END AS current_fiscal_year
// Calculate total votes in the system for participation rate
CALL {
MATCH (v:Vote)
RETURN count(DISTINCT v) AS total_votes
}
// For each MP, calculate their metrics
CALL {
WITH mp, current_fiscal_year
OPTIONAL MATCH (mp)-[:SPONSORED]->(bill:Bill)
RETURN count(DISTINCT bill) AS mp_bills_sponsored,
count(DISTINCT CASE WHEN bill.status = 'Passed' THEN bill END) AS mp_bills_passed
}
CALL {
WITH mp
OPTIONAL MATCH (mp)-[voted:VOTED]->(vote:Vote)
WITH mp, voted, vote, count(DISTINCT vote) AS mp_votes_participated
// Calculate party alignment (votes where MP voted same as majority of their party)
OPTIONAL MATCH (vote)<-[party_votes:VOTED]-(party_mp:MP)-[:MEMBER_OF]->(mp_party:Party)
WHERE mp_party.code = mp.party
WITH mp, vote, voted, mp_votes_participated,
count(DISTINCT CASE WHEN party_votes.position = voted.position THEN party_mp END) AS same_position_count,
count(DISTINCT party_mp) AS total_party_votes
WITH mp, mp_votes_participated,
count(DISTINCT CASE WHEN toFloat(same_position_count) / toFloat(total_party_votes) > 0.5 THEN vote END) AS aligned_votes
RETURN mp_votes_participated, aligned_votes
}
CALL {
WITH mp, current_fiscal_year
OPTIONAL MATCH (mp)-[:INCURRED]->(expense:Expense {fiscal_year: current_fiscal_year})
RETURN sum(expense.amount) AS mp_current_year_expenses
}
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:SERVES_ON]->(committee:Committee)
OPTIONAL MATCH (mp)<-[:MADE_BY]-(s:Statement)-[:PART_OF]->(d:Document {document_type: 'E'})
WITH mp, count(DISTINCT committee) AS committee_memberships, count(DISTINCT s) AS committee_statements
// Weight: 1 point per membership + 0.1 points per statement
RETURN (committee_memberships + committee_statements * 0.1) AS mp_committee_activity
}
// Calculate averages across all party MPs
WITH party, total_votes,
avg(mp_bills_sponsored) AS avg_bills_sponsored,
avg(mp_bills_passed) AS avg_bills_passed,
avg(mp_current_year_expenses) AS avg_current_year_expenses,
avg(CASE WHEN total_votes > 0
THEN (toFloat(mp_votes_participated) / toFloat(total_votes)) * 100.0
ELSE 0.0 END) AS avg_voting_participation_rate,
avg(CASE WHEN mp_votes_participated > 0
THEN (toFloat(aligned_votes) / toFloat(mp_votes_participated)) * 100.0
ELSE 0.0 END) AS avg_party_discipline_score,
avg(CASE WHEN mp_bills_sponsored > 0
THEN (toFloat(mp_bills_passed) / toFloat(mp_bills_sponsored)) * 100.0
ELSE 0.0 END) AS avg_legislative_success_rate,
avg(mp_committee_activity) AS avg_committee_activity_index,
count(DISTINCT mp) AS mp_count
RETURN {
party_code: party.code,
party_name: party.name,
avg_voting_participation_rate: avg_voting_participation_rate,
avg_party_discipline_score: avg_party_discipline_score,
avg_legislative_success_rate: avg_legislative_success_rate,
avg_committee_activity_index: avg_committee_activity_index,
avg_bills_sponsored: avg_bills_sponsored,
avg_bills_passed: avg_bills_passed,
avg_current_year_expenses: avg_current_year_expenses,
mp_count: mp_count
} AS averages
"""
columnName: "averages"
)
# ============================================
# Parliament & Session Queries
# ============================================
# Get current parliament
currentParliament: Parliament
@cypher(
statement: """
MATCH (p:Parliament {is_current: true})
RETURN p
"""
columnName: "p"
)
# Get current session
currentSession: Session
@cypher(
statement: """
MATCH (s:Session {is_current: true})
RETURN s
"""
columnName: "s"
)
# Get statistics for a parliament (bills, votes, debates)
parliamentStats(parliamentNumber: Int!): ParliamentStats
@cypher(
statement: """
MATCH (p:Parliament {number: $parliamentNumber})
WITH p.number AS number,
p.ordinal AS ordinal,
p.election_date AS election_date,
p.opening_date AS opening_date,
p.dissolution_date AS dissolution_date,
p.party_in_power AS party_in_power,
p.prime_minister AS prime_minister,
p.total_seats AS total_seats,
p
OPTIONAL MATCH (p)<-[:FROM_PARLIAMENT]-(b:Bill)
OPTIONAL MATCH (p)-[:HAS_SESSION]->(s:Session)
OPTIONAL MATCH (s)<-[:FROM_SESSION]-(v:Vote)
OPTIONAL MATCH (s)<-[:FROM_SESSION]-(d:Document)
RETURN {
parliament: {
number: number,
ordinal: ordinal,
election_date: election_date,
opening_date: opening_date,
dissolution_date: dissolution_date,
party_in_power: party_in_power,
prime_minister: prime_minister,
total_seats: total_seats
},
bill_count: count(DISTINCT b),
vote_count: count(DISTINCT v),
document_count: count(DISTINCT d),
session_count: count(DISTINCT s)
} AS stats
"""
columnName: "stats"
)
# Get statistics for a session (bills, votes, debates)
sessionStats(sessionId: ID!): SessionStats
@cypher(
statement: """
MATCH (s:Session {id: $sessionId})
WITH s.id AS id,
s.parliament_number AS parliament_number,
s.session_number AS session_number,
s.start_date AS start_date,
s.end_date AS end_date,
s.prorogation_date AS prorogation_date,
s.is_current AS is_current,
s
OPTIONAL MATCH (s)<-[:FROM_SESSION]-(b:Bill)
OPTIONAL MATCH (s)<-[:FROM_SESSION]-(v:Vote)
OPTIONAL MATCH (s)<-[:FROM_SESSION]-(d:Document)
RETURN {
session: {
id: id,
parliament_number: parliament_number,
session_number: session_number,
start_date: start_date,
end_date: end_date,
prorogation_date: prorogation_date,
is_current: is_current
},
bill_count: count(DISTINCT b),
vote_count: count(DISTINCT v),
document_count: count(DISTINCT d)
} AS stats
"""
columnName: "stats"
)
# ============================================
# Written Questions Queries
# ============================================
# Get written questions asked by a specific MP
writtenQuestionsByMP(
mpId: ID!
session: String
limit: Int = 50
): [WrittenQuestion!]!
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})<-[:ASKED_BY]-(wq:WrittenQuestion)
WHERE $session IS NULL OR wq.session_id = $session
RETURN wq
ORDER BY wq.date_asked DESC
LIMIT $limit
"""
columnName: "wq"
)
# Get written questions by status
writtenQuestionsByStatus(
status: String!
session: String
limit: Int = 50
): [WrittenQuestion!]!
@cypher(
statement: """
MATCH (wq:WrittenQuestion)
WHERE toLower(wq.status) CONTAINS toLower($status)
AND ($session IS NULL OR wq.session_id = $session)
RETURN wq
ORDER BY wq.date_asked DESC
LIMIT $limit
"""
columnName: "wq"
)
# Get written questions by topic
writtenQuestionsByTopic(
topic: String!
session: String
limit: Int = 50
): [WrittenQuestion!]!
@cypher(
statement: """
MATCH (wq:WrittenQuestion)
WHERE any(t IN wq.topics WHERE toLower(t) CONTAINS toLower($topic))
AND ($session IS NULL OR wq.session_id = $session)
RETURN wq
ORDER BY wq.date_asked DESC
LIMIT $limit
"""
columnName: "wq"
)
# Get all written questions for a session with optional filters
allWrittenQuestions(
session: String = "45-1"
status: String
limit: Int = 100
offset: Int = 0
): [WrittenQuestion!]!
@cypher(
statement: """
MATCH (wq:WrittenQuestion)
WHERE wq.session_id = $session
AND ($status IS NULL OR toLower(wq.status) CONTAINS toLower($status))
RETURN wq
ORDER BY wq.date_asked DESC
SKIP $offset
LIMIT $limit
"""
columnName: "wq"
)
# Count written questions by status for a session
writtenQuestionStats(session: String = "45-1"): WrittenQuestionStats
@cypher(
statement: """
MATCH (wq:WrittenQuestion {session_id: $session})
WITH count(wq) as total,
sum(CASE WHEN toLower(wq.status) CONTAINS 'answer' THEN 1 ELSE 0 END) as answered,
sum(CASE WHEN toLower(wq.status) CONTAINS 'await' THEN 1 ELSE 0 END) as awaiting
RETURN {
total: total,
answered: answered,
awaiting: awaiting
} as stats
"""
columnName: "stats"
)
# ============================================
# Bill Amendment Queries
# ============================================
# Get all amended sections for a bill
# Returns sections where text changed between versions
billAmendedSections(
billNumber: String!
session: String!
): [BillSectionAmendment!]!
@cypher(
statement: """
MATCH (b:Bill {number: $billNumber, session: $session})
OPTIONAL MATCH (b)-[:HAS_SECTION]->(bs:BillSection)
WHERE bs.has_amendments = true
WITH bs
WHERE bs IS NOT NULL
RETURN {
id: bs.id,
number: bs.number,
anchor_id: bs.anchor_id,
marginal_note_en: bs.marginal_note_en,
text_en: bs.text_en,
text_history_en: bs.text_history_en,
has_amendments: bs.has_amendments,
current_version: bs.current_version
} as section
ORDER BY bs.sequence
"""
columnName: "section"
)
# Get amendment summary for a bill
# Quick overview of how many sections were amended
billAmendmentSummary(
billNumber: String!
session: String!
): BillAmendmentSummary
@cypher(
statement: """
MATCH (b:Bill {number: $billNumber, session: $session})
OPTIONAL MATCH (b)-[:HAS_SECTION]->(bs:BillSection {has_amendments: true})
OPTIONAL MATCH (b)-[:HAS_SECTION]->(allSec:BillSection)
OPTIONAL MATCH (b)-[:HAS_VERSION]->(bv:BillVersion)
OPTIONAL MATCH (b)-[:HAS_AMENDMENT_EVENT]->(ae:BillAmendmentEvent)
WITH b,
count(DISTINCT bs) as amended_sections,
count(DISTINCT allSec) as total_sections,
count(DISTINCT bv) as version_count,
collect(DISTINCT {
event_type: ae.event_type,
description_en: ae.description_en,
event_date: ae.event_date,
chamber: ae.chamber,
committee_name: ae.committee_name
}) as events
RETURN {
bill_number: b.number,
session: b.session,
has_amendments: b.has_amendments,
total_versions: b.total_versions,
latest_version_stage: b.latest_version_stage,
amended_section_count: amended_sections,
total_section_count: total_sections,
amendment_events: [e IN events WHERE e.event_type IS NOT NULL]
} as summary
"""
columnName: "summary"
)
# Get text history for a specific section
# Returns the JSON-parsed version history
sectionTextHistory(
anchorId: String!
): SectionTextHistory
@cypher(
statement: """
OPTIONAL MATCH (bs:BillSection {anchor_id: $anchorId})
OPTIONAL MATCH (bss:BillSubsection {anchor_id: $anchorId})
OPTIONAL MATCH (bp:BillParagraph {anchor_id: $anchorId})
WITH COALESCE(bs, bss, bp) as node
WHERE node IS NOT NULL
RETURN {
anchor_id: node.anchor_id,
current_text: node.text_en,
text_history_en: node.text_history_en,
has_amendments: node.has_amendments,
current_version: node.current_version
} as history
"""
columnName: "history"
)
# ============================================
# Government Spending Aggregation Queries
# ============================================
# Top contractors by total contract value
topContractors(
department: String
year: Int
limit: Int = 20
): [ContractorSummary!]!
@cypher(
statement: """
MATCH (c:Contract)
WHERE ($department IS NULL OR toLower(c.department) CONTAINS toLower($department))
AND ($year IS NULL OR date(c.date).year = $year)
WITH c.vendor AS vendor, sum(c.amount) AS total_amount, count(c) AS contract_count
RETURN {
vendor: vendor,
total_amount: total_amount,
contract_count: contract_count
} AS contractor
ORDER BY total_amount DESC
LIMIT $limit
"""
columnName: "contractor"
)
# Top political donors by total donation amount
topPoliticalDonors(
party: String
year: Int
limit: Int = 20
): [DonorSummary!]!
@cypher(
statement: """
MATCH (d:Donation)
WHERE ($party IS NULL OR toLower(d.political_party) CONTAINS toLower($party))
AND ($year IS NULL OR d.contribution_year = $year)
WITH d.donor_name AS donor_name,
d.donor_city AS donor_city,
d.donor_province AS donor_province,
sum(d.amount) AS total_amount,
count(d) AS donation_count,
collect(DISTINCT d.political_party) AS parties
RETURN {
donor_name: donor_name,
donor_city: donor_city,
donor_province: donor_province,
total_amount: total_amount,
donation_count: donation_count,
parties: parties
} AS donor
ORDER BY total_amount DESC
LIMIT $limit
"""
columnName: "donor"
)
# Party fundraising totals by year
partyFundraising(
year: Int
): [PartyFundraisingSummary!]!
@cypher(
statement: """
MATCH (d:Donation)
WHERE $year IS NULL OR d.contribution_year = $year
WITH d.political_party AS party,
d.contribution_year AS year,
sum(d.amount) AS total_amount,
count(d) AS donation_count,
count(DISTINCT d.donor_name) AS unique_donors
RETURN {
party: party,
year: year,
total_amount: total_amount,
donation_count: donation_count,
unique_donors: unique_donors,
average_donation: total_amount / toFloat(donation_count)
} AS fundraising
ORDER BY total_amount DESC
"""
columnName: "fundraising"
)
# Top grant recipients by total grant value
topGrantRecipients(
program: String
year: Int
limit: Int = 20
): [GrantRecipientSummary!]!
@cypher(
statement: """
MATCH (g:Grant)
WHERE ($program IS NULL OR toLower(g.program_name) CONTAINS toLower($program))
AND ($year IS NULL OR g.agreement_year = $year)
WITH g.recipient AS recipient,
g.recipient_city AS recipient_city,
g.recipient_province AS recipient_province,
sum(g.amount) AS total_amount,
count(g) AS grant_count,
collect(DISTINCT g.program_name) AS programs
RETURN {
recipient: recipient,
recipient_city: recipient_city,
recipient_province: recipient_province,
total_amount: total_amount,
grant_count: grant_count,
programs: programs
} AS grantRecipient
ORDER BY total_amount DESC
LIMIT $limit
"""
columnName: "grantRecipient"
)
# Grant program spending summary
grantProgramSpending(
year: Int
limit: Int = 50
): [GrantProgramSummary!]!
@cypher(
statement: """
MATCH (g:Grant)
WHERE $year IS NULL OR g.agreement_year = $year
WITH g.program_name AS program_name,
g.program_purpose AS program_purpose,
g.owner_org AS department,
sum(g.amount) AS total_amount,
count(g) AS grant_count,
count(DISTINCT g.recipient) AS unique_recipients
RETURN {
program_name: program_name,
program_purpose: program_purpose,
department: department,
total_amount: total_amount,
grant_count: grant_count,
unique_recipients: unique_recipients
} AS program
ORDER BY total_amount DESC
LIMIT $limit
"""
columnName: "program"
)
# ============================================
# Advanced MP Analysis Queries
# ============================================
# MP voting history with bill details
mpVotingHistory(
mpId: ID!
limit: Int = 50
): [MPVoteRecord!]!
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})-[v:VOTED]->(vote:Vote)
OPTIONAL MATCH (vote)-[:CONCERNS]->(bill:Bill)
RETURN {
vote_number: vote.vote_number,
date: toString(vote.date_time),
subject: vote.subject,
result: vote.result,
position: v.position,
bill_number: bill.number,
bill_title: bill.title
} AS record
ORDER BY vote.date_time DESC
LIMIT $limit
"""
columnName: "record"
)
# Analyze party discipline (% of votes aligned with party majority)
partyDisciplineAnalysis(
party: String
): [PartyDisciplineRecord!]!
@cypher(
statement: """
MATCH (mp:MP)-[v:VOTED]->(vote:Vote)
WHERE mp.current = true
AND ($party IS NULL OR mp.party = $party)
WITH mp, vote, v.position AS mp_position
MATCH (vote)<-[pv:VOTED]-(party_mp:MP)
WHERE party_mp.party = mp.party
WITH mp, vote, mp_position,
collect(pv.position) AS party_votes
WITH mp, vote, mp_position,
reduce(yeas = 0, pos IN party_votes | CASE WHEN pos = 'yea' THEN yeas + 1 ELSE yeas END) AS party_yeas,
reduce(nays = 0, pos IN party_votes | CASE WHEN pos = 'nay' THEN nays + 1 ELSE nays END) AS party_nays
WITH mp,
count(vote) AS total_votes,
count(CASE WHEN (party_yeas > party_nays AND mp_position = 'yea') OR (party_nays > party_yeas AND mp_position = 'nay') THEN 1 END) AS aligned_votes
RETURN {
mp_id: mp.id,
mp_name: mp.name,
party: mp.party,
total_votes: total_votes,
aligned_votes: aligned_votes,
discipline_rate: CASE WHEN total_votes > 0 THEN toFloat(aligned_votes) / toFloat(total_votes) * 100.0 ELSE 0.0 END
} AS record
ORDER BY record.discipline_rate ASC
"""
columnName: "record"
)
# Compare MP performance metrics
compareMPPerformance(
mpIds: [ID!]!
): [MPPerformanceComparison!]!
@cypher(
statement: """
UNWIND $mpIds AS mpId
MATCH (mp:MP {id: mpId})
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:SPONSORED]->(bill:Bill)
RETURN count(DISTINCT bill) AS bills_sponsored,
count(DISTINCT CASE WHEN bill.status = 'Passed' THEN bill END) AS bills_passed
}
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:VOTED]->(vote:Vote)
RETURN count(DISTINCT vote) AS votes_participated
}
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:SERVES_ON]->(committee:Committee)
RETURN count(DISTINCT committee) AS committee_memberships
}
CALL {
WITH mp
OPTIONAL MATCH (mp)<-[:MADE_BY]-(s:Statement)
RETURN count(DISTINCT s) AS total_statements
}
RETURN {
mp_id: mp.id,
mp_name: mp.name,
party: mp.party,
riding: mp.riding,
bills_sponsored: bills_sponsored,
bills_passed: bills_passed,
votes_participated: votes_participated,
committee_memberships: committee_memberships,
total_statements: total_statements
} AS comparison
"""
columnName: "comparison"
)
# MP committee activity details
mpCommitteeActivity(
mpId: ID!
): MPCommitteeActivitySummary
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
OPTIONAL MATCH (mp)-[s:SERVES_ON]->(c:Committee)
WITH mp, collect({
code: c.code,
name: c.name,
role: s.role,
start_date: toString(s.start_date)
}) AS committees
OPTIONAL MATCH (mp)<-[:MADE_BY]-(stmt:Statement)-[:PART_OF]->(doc:Document {document_type: 'E'})
WITH mp, committees, count(DISTINCT stmt) AS evidence_statements
RETURN {
mp_id: mp.id,
mp_name: mp.name,
committees: committees,
total_committees: size(committees),
evidence_statements: evidence_statements
} AS summary
"""
columnName: "summary"
)
# MP voting participation stats
mpVotingParticipation(
mpId: ID!
): MPVotingParticipationStats
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
CALL {
WITH mp
OPTIONAL MATCH (mp)-[v:VOTED]->(vote:Vote)
RETURN count(DISTINCT vote) AS mp_votes,
count(DISTINCT CASE WHEN v.position = 'yea' THEN vote END) AS yea_votes,
count(DISTINCT CASE WHEN v.position = 'nay' THEN vote END) AS nay_votes,
count(DISTINCT CASE WHEN v.position = 'paired' THEN vote END) AS paired_votes
}
CALL {
MATCH (v:Vote)
RETURN count(DISTINCT v) AS total_votes
}
RETURN {
mp_id: mp.id,
mp_name: mp.name,
party: mp.party,
votes_participated: mp_votes,
total_votes: total_votes,
participation_rate: CASE WHEN total_votes > 0 THEN toFloat(mp_votes) / toFloat(total_votes) * 100.0 ELSE 0.0 END,
yea_votes: yea_votes,
nay_votes: nay_votes,
paired_votes: paired_votes
} AS stats
"""
columnName: "stats"
)
# ============================================
# Bill Analysis Queries
# ============================================
# Compare party bills sponsored
partyBillsComparison: [PartyBillsSummary!]!
@cypher(
statement: """
MATCH (mp:MP)-[:SPONSORED]->(bill:Bill)
WHERE mp.current = true
WITH mp.party AS party, bill
RETURN {
party: party,
total_bills: count(DISTINCT bill),
passed_bills: count(DISTINCT CASE WHEN bill.status = 'Passed' THEN bill END),
in_committee: count(DISTINCT CASE WHEN bill.status = 'In Committee' THEN bill END),
second_reading: count(DISTINCT CASE WHEN bill.status = 'At Second Reading' THEN bill END)
} AS summary
ORDER BY summary.total_bills DESC
"""
columnName: "summary"
)
# Bill legislative progress details
billLegislativeProgress(
billNumber: String!
session: String!
): BillProgressDetails
@cypher(
statement: """
MATCH (bill:Bill {number: $billNumber, session: $session})
OPTIONAL MATCH (bill)<-[:SPONSORED]-(sponsor:MP)
OPTIONAL MATCH (bill)<-[:CONCERNS]-(vote:Vote)
WITH bill, sponsor, collect({
vote_number: vote.vote_number,
date: toString(vote.date_time),
result: vote.result,
yeas: vote.num_yeas,
nays: vote.num_nays
}) AS votes
RETURN {
bill_number: bill.number,
session: bill.session,
title: bill.title,
status: bill.status,
introduced_date: toString(bill.introduced_date),
sponsor_name: sponsor.name,
sponsor_party: sponsor.party,
is_government_bill: bill.is_government_bill,
originating_chamber: bill.originating_chamber,
votes: [v IN votes WHERE v.vote_number IS NOT NULL]
} AS progress
"""
columnName: "progress"
)
# Analyze MP bill sponsorship patterns
mpBillsAnalysis(
mpId: ID!
): MPBillsAnalysisSummary
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
OPTIONAL MATCH (mp)-[:SPONSORED]->(bill:Bill)
WITH mp, bill
RETURN {
mp_id: mp.id,
mp_name: mp.name,
party: mp.party,
total_bills: count(DISTINCT bill),
passed_bills: count(DISTINCT CASE WHEN bill.status = 'Passed' THEN bill END),
government_bills: count(DISTINCT CASE WHEN bill.is_government_bill = true THEN bill END),
private_member_bills: count(DISTINCT CASE WHEN bill.is_government_bill = false THEN bill END),
success_rate: CASE WHEN count(DISTINCT bill) > 0
THEN toFloat(count(DISTINCT CASE WHEN bill.status = 'Passed' THEN bill END)) / toFloat(count(DISTINCT bill)) * 100.0
ELSE 0.0 END
} AS analysis
"""
columnName: "analysis"
)
# ============================================
# Extended Lobbying Queries
# ============================================
# Search lobbying communications
searchLobbyCommunications(
searchTerm: String
startDate: Date
endDate: Date
limit: Int = 50
): [LobbyCommunication!]!
@cypher(
statement: """
MATCH (lc:LobbyCommunication)
WHERE ($searchTerm IS NULL OR toLower(lc.client_org_name) CONTAINS toLower($searchTerm)
OR any(subj IN lc.subject_matters WHERE toLower(subj) CONTAINS toLower($searchTerm)))
AND ($startDate IS NULL OR lc.date >= $startDate)
AND ($endDate IS NULL OR lc.date <= $endDate)
RETURN lc
ORDER BY lc.date DESC
LIMIT $limit
"""
columnName: "lc"
)
# Top lobbying clients by communication count
topLobbyingClients(
limit: Int = 20
): [LobbyingClientSummary!]!
@cypher(
statement: """
MATCH (lc:LobbyCommunication)
WITH lc.client_org_name AS client_name, count(lc) AS communication_count,
collect(DISTINCT lc.subject_matters) AS all_subjects
WITH client_name, communication_count,
reduce(subjects = [], s IN all_subjects | subjects + s) AS flat_subjects
RETURN {
client_name: client_name,
communication_count: communication_count,
top_subjects: flat_subjects[0..5]
} AS client
ORDER BY communication_count DESC
LIMIT $limit
"""
columnName: "client"
)
# ============================================
# Cross-Source Search Queries
# ============================================
# Search across multiple source types
searchAcrossSources(
searchTerm: String!
sources: [String!]
limit: Int = 20
): CrossSourceSearchResults
@cypher(
statement: """
WITH $searchTerm AS term, $sources AS sources, $limit AS resultLimit
// Search MPs if included
CALL {
WITH term, sources, resultLimit
WITH term, sources, resultLimit
WHERE sources IS NULL OR 'mps' IN sources
MATCH (mp:MP)
WHERE toLower(mp.name) CONTAINS toLower(term)
RETURN mp.id AS id, mp.name AS name, 'mp' AS source_type, mp.party AS extra
LIMIT resultLimit
}
// Search Bills if included
CALL {
WITH term, sources, resultLimit
WITH term, sources, resultLimit
WHERE sources IS NULL OR 'bills' IN sources
MATCH (b:Bill)
WHERE toLower(b.title) CONTAINS toLower(term) OR toLower(b.number) CONTAINS toLower(term)
RETURN b.id AS id, b.title AS name, 'bill' AS source_type, b.status AS extra
LIMIT resultLimit
}
// Search Contracts if included
CALL {
WITH term, sources, resultLimit
WITH term, sources, resultLimit
WHERE sources IS NULL OR 'contracts' IN sources
MATCH (c:Contract)
WHERE toLower(c.vendor) CONTAINS toLower(term) OR toLower(c.description) CONTAINS toLower(term)
RETURN c.id AS id, c.vendor AS name, 'contract' AS source_type, toString(c.amount) AS extra
LIMIT resultLimit
}
// Search Grants if included
CALL {
WITH term, sources, resultLimit
WITH term, sources, resultLimit
WHERE sources IS NULL OR 'grants' IN sources
MATCH (g:Grant)
WHERE toLower(g.recipient) CONTAINS toLower(term) OR toLower(g.program_name) CONTAINS toLower(term)
RETURN g.id AS id, g.recipient AS name, 'grant' AS source_type, g.program_name AS extra
LIMIT resultLimit
}
// Combine results
WITH collect({id: id, name: name, source_type: source_type, extra: extra}) AS results
RETURN {
total_count: size(results),
results: results
} AS searchResults
"""
columnName: "searchResults"
)
# ============================================
# Cross-Dataset Financial Analysis Queries
# ============================================
# Trace money flow for an entity (contracts, grants, donations, lobbying)
traceMoneyFlow(
entityName: String!
year: Int
includeContracts: Boolean = true
includeGrants: Boolean = true
includeContributions: Boolean = true
includeLobbying: Boolean = true
): MoneyFlowAnalysis
@cypher(
statement: """
WITH $entityName AS entity, $year AS year
// Get contracts
CALL {
WITH entity, year
WITH entity, year WHERE $includeContracts = true
MATCH (c:Contract)
WHERE toLower(c.vendor) CONTAINS toLower(entity)
AND (year IS NULL OR c.contract_year = year)
RETURN sum(c.amount) AS contract_total,
count(c) AS contract_count,
collect(DISTINCT c.department)[0..5] AS contract_depts
}
// Get grants
CALL {
WITH entity, year
WITH entity, year WHERE $includeGrants = true
MATCH (g:Grant)
WHERE toLower(g.recipient) CONTAINS toLower(entity)
AND (year IS NULL OR g.agreement_year = year)
RETURN sum(g.amount) AS grant_total,
count(g) AS grant_count,
collect(DISTINCT g.program_name)[0..5] AS grant_programs
}
// Get political contributions
CALL {
WITH entity, year
WITH entity, year WHERE $includeContributions = true
MATCH (d:Donation)
WHERE toLower(d.donor_name) CONTAINS toLower(entity)
AND (year IS NULL OR d.year = year)
RETURN sum(d.amount) AS donation_total,
count(d) AS donation_count,
collect(DISTINCT d.party)[0..5] AS donation_parties
}
// Get lobbying activity
CALL {
WITH entity
WITH entity WHERE $includeLobbying = true
OPTIONAL MATCH (lr:LobbyRegistration)
WHERE toLower(lr.client_name) CONTAINS toLower(entity)
OPTIONAL MATCH (lc:LobbyCommunication)
WHERE toLower(lc.client_name) CONTAINS toLower(entity)
RETURN count(DISTINCT lr) AS lobby_registrations,
count(DISTINCT lc) AS lobby_communications,
collect(DISTINCT lr.subject_matters)[0..5] AS lobby_subjects
}
RETURN {
entity_name: entity,
contracts: {
total_amount: COALESCE(contract_total, 0.0),
count: COALESCE(contract_count, 0),
departments: COALESCE(contract_depts, [])
},
grants: {
total_amount: COALESCE(grant_total, 0.0),
count: COALESCE(grant_count, 0),
programs: COALESCE(grant_programs, [])
},
donations: {
total_amount: COALESCE(donation_total, 0.0),
count: COALESCE(donation_count, 0),
parties: COALESCE(donation_parties, [])
},
lobbying: {
registration_count: COALESCE(lobby_registrations, 0),
communication_count: COALESCE(lobby_communications, 0),
subjects: COALESCE(lobby_subjects, [])
}
} AS analysis
"""
columnName: "analysis"
)
# Analyze MP financial exposure
analyzeMPFinances(
mpId: ID!
fiscalYear: Int
includeLobbying: Boolean = true
): MPFinanceAnalysis
@cypher(
statement: """
MATCH (mp:MP {id: $mpId})
// Get expenses
CALL {
WITH mp
OPTIONAL MATCH (mp)-[:INCURRED]->(e:Expense)
WHERE $fiscalYear IS NULL OR e.fiscal_year = $fiscalYear
WITH e, COALESCE(e.fiscal_year, 2024) AS fy
RETURN sum(e.amount) AS total_expenses,
collect({category: e.category, amount: e.amount})[0..10] AS expense_details,
max(fy) AS latest_year
}
// Get lobbying contacts
CALL {
WITH mp
WITH mp WHERE $includeLobbying = true
OPTIONAL MATCH (mp)<-[:MET_WITH]-(lc:LobbyCommunication)
RETURN count(DISTINCT lc) AS lobby_count,
collect(DISTINCT lc.client_name)[0..10] AS lobby_orgs
}
RETURN {
mp: {
id: mp.id,
name: mp.name,
party: mp.party,
riding: mp.riding
},
expenses: {
total: COALESCE(total_expenses, 0.0),
by_category: expense_details,
fiscal_year: latest_year
},
lobbying_contacts: {
count: COALESCE(lobby_count, 0),
organizations: COALESCE(lobby_orgs, [])
}
} AS analysis
"""
columnName: "analysis"
)
# Compare party fundraising
comparePartyFunding(
year: Int
includeDonorAnalysis: Boolean = true
minDonation: Float = 0
): PartyFundingComparison
@cypher(
statement: """
MATCH (d:Donation)
WHERE ($year IS NULL OR d.year = $year)
AND d.amount >= $minDonation
WITH d.party AS party,
sum(d.amount) AS total_raised,
count(d) AS donor_count,
avg(d.amount) AS average_donation,
collect(DISTINCT d.donor_name)[0..5] AS top_donors
RETURN {
parties: collect({
party: party,
total_raised: total_raised,
donor_count: donor_count,
average_donation: average_donation,
top_donors: top_donors
}),
year: $year
} AS comparison
"""
columnName: "comparison"
)
# Government spending analysis by department
governmentSpendingAnalysis(
departmentName: String
year: Int
focus: String = "all"
limit: Int = 10
): DepartmentSpendingAnalysis
@cypher(
statement: """
WITH $departmentName AS dept, $year AS year, $limit AS resultLimit
// Get contracts for department
CALL {
WITH dept, year, resultLimit
MATCH (c:Contract)
WHERE (dept IS NULL OR toLower(c.department) CONTAINS toLower(dept))
AND (year IS NULL OR c.contract_year = year)
WITH sum(c.amount) AS total,
count(c) AS cnt,
collect({vendor: c.vendor, amount: c.amount})[0..resultLimit] AS vendors
RETURN total AS contract_total, cnt AS contract_count,
[v IN vendors | v.vendor] AS top_vendors
}
// Get grants for department
CALL {
WITH dept, year, resultLimit
MATCH (g:Grant)
WHERE (dept IS NULL OR toLower(g.owner_org) CONTAINS toLower(dept))
AND (year IS NULL OR g.agreement_year = year)
WITH sum(g.amount) AS total,
count(g) AS cnt,
collect(DISTINCT g.program_name)[0..resultLimit] AS programs
RETURN total AS grant_total, cnt AS grant_count, programs AS top_programs
}
RETURN {
department: COALESCE(dept, 'All Departments'),
contracts: {
total_amount: COALESCE(contract_total, 0.0),
count: COALESCE(contract_count, 0),
top_vendors: COALESCE(top_vendors, [])
},
grants: {
total_amount: COALESCE(grant_total, 0.0),
count: COALESCE(grant_count, 0),
top_programs: COALESCE(top_programs, [])
}
} AS analysis
"""
columnName: "analysis"
)
# Analyze industry influence on Parliament
analyzeIndustryInfluence(
industryKeyword: String!
limit: Int = 10
): IndustryInfluenceAnalysis
@cypher(
statement: """
WITH $industryKeyword AS keyword, $limit AS resultLimit
// Find lobbying registrations for this industry
CALL {
WITH keyword, resultLimit
MATCH (lr:LobbyRegistration)
WHERE toLower(lr.subject_matters) CONTAINS toLower(keyword)
OR toLower(lr.client_name) CONTAINS toLower(keyword)
WITH lr.client_name AS org, count(lr) AS reg_count
RETURN collect({organization: org, registration_count: reg_count})[0..resultLimit] AS orgs,
sum(reg_count) AS total_registrations
}
// Find lobbying communications
CALL {
WITH keyword, resultLimit
MATCH (lc:LobbyCommunication)
WHERE toLower(lc.subject_matters) CONTAINS toLower(keyword)
OR toLower(lc.client_name) CONTAINS toLower(keyword)
RETURN count(lc) AS communication_count,
collect(DISTINCT lc.dpoh_name)[0..5] AS top_officials
}
// Find related government institutions
CALL {
WITH keyword
MATCH (lr:LobbyRegistration)
WHERE toLower(lr.subject_matters) CONTAINS toLower(keyword)
UNWIND lr.government_institutions AS inst
WITH inst, count(*) AS cnt
RETURN collect({institution: inst, count: cnt})[0..5] AS institutions
}
RETURN {
industry: keyword,
active_organizations: orgs,
total_registrations: total_registrations,
communication_count: communication_count,
top_contacted_officials: top_officials,
targeted_institutions: institutions
} AS analysis
"""
columnName: "analysis"
)
# Cross-reference for potential conflicts of interest
conflictOfInterestCheck(
entityName: String!
year: Int
thresholdAmount: Float = 100000
): ConflictOfInterestAnalysis
@cypher(
statement: """
WITH $entityName AS entity, $year AS year, $thresholdAmount AS threshold
// Get political contributions
CALL {
WITH entity, year
MATCH (d:Donation)
WHERE toLower(d.donor_name) CONTAINS toLower(entity)
AND (year IS NULL OR d.year = year)
RETURN sum(d.amount) AS contrib_total,
count(d) AS contrib_count,
collect(DISTINCT d.party) AS contrib_parties
}
// Get government contracts above threshold
CALL {
WITH entity, year, threshold
MATCH (c:Contract)
WHERE toLower(c.vendor) CONTAINS toLower(entity)
AND c.amount >= threshold
AND (year IS NULL OR c.contract_year = year)
RETURN collect({
amount: c.amount,
department: c.department,
date: toString(c.contract_date)
})[0..10] AS contracts,
count(c) AS contract_count
}
// Get grants above threshold
CALL {
WITH entity, year, threshold
MATCH (g:Grant)
WHERE toLower(g.recipient) CONTAINS toLower(entity)
AND g.amount >= threshold
AND (year IS NULL OR g.agreement_year = year)
RETURN collect({
amount: g.amount,
program: g.program_name,
date: toString(g.agreement_date)
})[0..10] AS grants,
count(g) AS grant_count
}
// Get lobbying registrations
CALL {
WITH entity
MATCH (lr:LobbyRegistration)
WHERE toLower(lr.client_name) CONTAINS toLower(entity)
RETURN count(lr) AS lobby_count,
collect(DISTINCT lr.subject_matters)[0..5] AS lobby_subjects
}
// Determine flags
WITH entity,
contrib_total, contrib_count, contrib_parties,
contracts, contract_count,
grants, grant_count,
lobby_count, lobby_subjects
WITH entity,
contrib_total, contrib_count, contrib_parties,
contracts, contract_count,
grants, grant_count,
lobby_count, lobby_subjects,
CASE
WHEN contrib_count > 0 AND (contract_count > 0 OR grant_count > 0)
THEN ['Entity made political contributions AND received government funds']
ELSE []
END +
CASE
WHEN contrib_count > 0 AND lobby_count > 0
THEN ['Entity made political contributions AND engaged in lobbying']
ELSE []
END +
CASE
WHEN (contract_count > 0 OR grant_count > 0) AND lobby_count > 0
THEN ['Entity received government funds AND engaged in lobbying']
ELSE []
END AS flags
RETURN {
entity_name: entity,
contributions: {
total: COALESCE(contrib_total, 0.0),
count: COALESCE(contrib_count, 0),
parties: COALESCE(contrib_parties, [])
},
contracts: {
count: COALESCE(contract_count, 0),
items: COALESCE(contracts, [])
},
grants: {
count: COALESCE(grant_count, 0),
items: COALESCE(grants, [])
},
lobbying: {
registration_count: COALESCE(lobby_count, 0),
subjects: COALESCE(lobby_subjects, [])
},
potential_concerns: flags,
has_concerns: size(flags) > 0
} AS analysis
"""
columnName: "analysis"
)
# ============================================
# Fact-Check Queries
# ============================================
# Get a fact-check by its claim hash (for cache lookup)
getFactCheckByClaimHash(claimHash: String!): FactCheck
@cypher(
statement: """
MATCH (fc:FactCheck {claim_text_hash: $claimHash})
RETURN fc
ORDER BY fc.checked_at DESC
LIMIT 1
"""
columnName: "fc"
)
# Get all fact-checks for a specific statement
getFactChecksForStatement(statementId: ID!): [FactCheck!]!
@cypher(
statement: """
MATCH (s:Statement {id: $statementId})-[:VERIFIED_BY]->(fc:FactCheck)
RETURN fc
ORDER BY fc.checked_at DESC
"""
columnName: "fc"
)
# Get recent fact-checks for display
recentFactChecks(limit: Int = 10): [FactCheck!]!
@cypher(
statement: """
MATCH (fc:FactCheck)
RETURN fc
ORDER BY fc.checked_at DESC
LIMIT $limit
"""
columnName: "fc"
)
# ============================================
# GC InfoBase Queries (Departmental Performance & Spending)
# ============================================
# Get department spending by fiscal year
getDepartmentSpending(
department: String!
fiscalYear: String
program: String
): [DepartmentSpendingSummary!]!
@cypher(
statement: """
MATCH (e:ProgramExpenditure)
WHERE toLower(e.organization) CONTAINS toLower($department)
AND ($fiscalYear IS NULL OR e.fiscal_year = $fiscalYear)
AND ($program IS NULL OR toLower(e.program) CONTAINS toLower($program))
RETURN {
organization: e.organization,
fiscal_year: e.fiscal_year,
program: e.program,
planned_spending: e.planned_spending,
actual_spending: e.actual_spending,
variance: e.variance,
fte_planned: e.fte_planned,
fte_actual: e.fte_actual
} AS spending
ORDER BY e.fiscal_year DESC, e.actual_spending DESC
LIMIT 100
"""
columnName: "spending"
)
# Get department performance results
getDepartmentResults(
department: String!
fiscalYear: String
program: String
): [DepartmentResultSummary!]!
@cypher(
statement: """
MATCH (r:DepartmentalResult)
WHERE toLower(r.organization) CONTAINS toLower($department)
AND ($fiscalYear IS NULL OR r.fiscal_year = $fiscalYear)
AND ($program IS NULL OR toLower(r.program) CONTAINS toLower($program))
RETURN {
organization: r.organization,
fiscal_year: r.fiscal_year,
program: r.program,
indicator_name: r.indicator_name,
target: r.target,
actual_result: r.actual_result,
met_target: r.met_target
} AS result
ORDER BY r.fiscal_year DESC, r.program
LIMIT 100
"""
columnName: "result"
)
# Compare planned vs actual spending for a department
comparePlanVsActual(
department: String!
fiscalYear: String!
): DepartmentComparison
@cypher(
statement: """
MATCH (e:ProgramExpenditure)
WHERE toLower(e.organization) CONTAINS toLower($department)
AND e.fiscal_year = $fiscalYear
WITH e.organization AS org,
e.fiscal_year AS year,
sum(e.planned_spending) AS total_planned,
sum(e.actual_spending) AS total_actual,
sum(e.fte_planned) AS fte_planned,
sum(e.fte_actual) AS fte_actual,
count(e) AS program_count
OPTIONAL MATCH (r:DepartmentalResult)
WHERE toLower(r.organization) CONTAINS toLower($department)
AND r.fiscal_year = $fiscalYear
WITH org, year, total_planned, total_actual, fte_planned, fte_actual, program_count,
count(CASE WHEN r.met_target = true THEN 1 END) AS targets_met,
count(CASE WHEN r.met_target = false THEN 1 END) AS targets_missed,
count(CASE WHEN r.met_target IS NULL THEN 1 END) AS targets_unknown
RETURN {
department: org,
fiscal_year: year,
spending: {
planned: total_planned,
actual: total_actual,
variance: total_actual - total_planned,
variance_percent: CASE WHEN total_planned > 0 THEN ((total_actual - total_planned) / total_planned) * 100 ELSE 0 END
},
fte: {
planned: fte_planned,
actual: fte_actual,
variance: fte_actual - fte_planned
},
performance: {
targets_met: targets_met,
targets_missed: targets_missed,
targets_unknown: targets_unknown,
success_rate: CASE WHEN (targets_met + targets_missed) > 0
THEN (toFloat(targets_met) / toFloat(targets_met + targets_missed)) * 100
ELSE 0 END
},
program_count: program_count
} AS comparison
"""
columnName: "comparison"
)
# Get top spending departments
getTopSpendingDepartments(
fiscalYear: String
limit: Int = 20
): [TopDepartmentSpending!]!
@cypher(
statement: """
MATCH (e:ProgramExpenditure)
WHERE $fiscalYear IS NULL OR e.fiscal_year = $fiscalYear
WITH e.organization AS org,
e.fiscal_year AS year,
sum(e.actual_spending) AS total_spending,
sum(e.fte_actual) AS total_fte,
count(e) AS program_count
RETURN {
organization: org,
fiscal_year: year,
total_spending: total_spending,
total_fte: total_fte,
program_count: program_count
} AS dept
ORDER BY total_spending DESC
LIMIT $limit
"""
columnName: "dept"
)
# Search GC InfoBase data
searchGCInfoBase(
query: String!
fiscalYear: String
limit: Int = 50
): GCInfoBaseSearchResults
@cypher(
statement: """
WITH $query AS term, $fiscalYear AS year, $limit AS resultLimit
// Search expenditures
CALL {
WITH term, year, resultLimit
MATCH (e:ProgramExpenditure)
WHERE (toLower(e.organization) CONTAINS toLower(term) OR toLower(e.program) CONTAINS toLower(term))
AND (year IS NULL OR e.fiscal_year = year)
RETURN {
type: 'expenditure',
organization: e.organization,
program: e.program,
fiscal_year: e.fiscal_year,
actual_spending: e.actual_spending
} AS result
LIMIT resultLimit
}
// Search results
CALL {
WITH term, year, resultLimit
MATCH (r:DepartmentalResult)
WHERE (toLower(r.organization) CONTAINS toLower(term)
OR toLower(r.program) CONTAINS toLower(term)
OR toLower(r.indicator_name) CONTAINS toLower(term))
AND (year IS NULL OR r.fiscal_year = year)
RETURN {
type: 'result',
organization: r.organization,
program: r.program,
fiscal_year: r.fiscal_year,
indicator: r.indicator_name
} AS result
LIMIT resultLimit
}
WITH collect(result) AS results
RETURN {
total_count: size(results),
results: results
} AS searchResults
"""
columnName: "searchResults"
)
# ============================================
# ATIP (Access to Information) Queries
# ============================================
# Search ATIP requests
searchATIPRequests(
query: String
organization: String
year: Int
disposition: String
limit: Int = 50
): [ATIPRequest!]!
@cypher(
statement: """
WITH $query AS searchTerm, $organization AS org, $year AS requestYear, $disposition AS disp
MATCH (a:ATIPRequest)
WHERE (searchTerm IS NULL OR toLower(a.summary_en) CONTAINS toLower(searchTerm))
AND (org IS NULL OR toLower(a.organization) CONTAINS toLower(org))
AND (requestYear IS NULL OR a.year = requestYear)
AND (disp IS NULL OR a.disposition = disp)
RETURN a
ORDER BY a.year DESC, a.month DESC
LIMIT $limit
"""
columnName: "a"
)
# Get ATIP statistics by organization
getATIPStatsByOrganization(
organization: String
year: Int
limit: Int = 20
): [ATIPOrgStats!]!
@cypher(
statement: """
WITH $organization AS org, $year AS requestYear
MATCH (a:ATIPRequest)
WHERE (org IS NULL OR toLower(a.organization) CONTAINS toLower(org))
AND (requestYear IS NULL OR a.year = requestYear)
WITH a.organization AS organization, a.organization_code AS code,
count(a) AS total_requests,
sum(COALESCE(a.pages, 0)) AS total_pages,
count(CASE WHEN a.disposition = 'DA' THEN 1 END) AS disclosed_all,
count(CASE WHEN a.disposition = 'DP' THEN 1 END) AS disclosed_part,
count(CASE WHEN a.disposition = 'NR' THEN 1 END) AS no_records
RETURN {
organization: organization,
organization_code: code,
total_requests: total_requests,
total_pages: total_pages,
disclosed_all: disclosed_all,
disclosed_part: disclosed_part,
no_records: no_records
} AS stats
ORDER BY total_requests DESC
LIMIT $limit
"""
columnName: "stats"
)
# Get ATIP trends over time
getATIPTrends(
organization: String
years: Int = 5
): ATIPTrends
@cypher(
statement: """
WITH $organization AS org
// Get recent years
MATCH (a:ATIPRequest)
WHERE org IS NULL OR toLower(a.organization) CONTAINS toLower(org)
WITH DISTINCT a.year AS year
ORDER BY year DESC
LIMIT $years
WITH collect(year) AS targetYears
// Aggregate by year
MATCH (a:ATIPRequest)
WHERE a.year IN targetYears
AND ($organization IS NULL OR toLower(a.organization) CONTAINS toLower($organization))
WITH a.year AS year,
count(a) AS requests,
sum(COALESCE(a.pages, 0)) AS pages
ORDER BY year
WITH collect({year: year, requests: requests, pages: pages}) AS byYear
// Get disposition breakdown
MATCH (a:ATIPRequest)
WHERE $organization IS NULL OR toLower(a.organization) CONTAINS toLower($organization)
WITH byYear, a.disposition_description AS disposition, count(a) AS count
WITH byYear, collect({disposition: disposition, count: count}) AS byDisposition
// Get totals
MATCH (a:ATIPRequest)
WHERE $organization IS NULL OR toLower(a.organization) CONTAINS toLower($organization)
WITH byYear, byDisposition,
count(a) AS total_requests,
sum(COALESCE(a.pages, 0)) AS total_pages
RETURN {
organization: COALESCE($organization, 'All Organizations'),
by_year: byYear,
by_disposition: byDisposition,
total_requests: total_requests,
total_pages: total_pages
} AS trends
"""
columnName: "trends"
)
# ============================================
# Consultations Queries
# ============================================
# Search government consultations
searchConsultations(
query: String
organization: String
subject: String
status: String
year: Int
limit: Int = 50
): [Consultation!]!
@cypher(
statement: """
WITH $query AS searchTerm, $organization AS org, $subject AS subj, $status AS stat, $year AS startYear
MATCH (c:Consultation)
WHERE (searchTerm IS NULL OR toLower(c.title_en) CONTAINS toLower(searchTerm) OR toLower(c.description_en) CONTAINS toLower(searchTerm))
AND (org IS NULL OR toLower(c.organization) CONTAINS toLower(org))
AND (subj IS NULL OR subj IN c.subjects)
AND (stat IS NULL OR c.status = stat)
AND (startYear IS NULL OR c.start_year = startYear)
RETURN c
ORDER BY c.start_date DESC
LIMIT $limit
"""
columnName: "c"
)
# Get open consultations
getOpenConsultations(
organization: String
subject: String
limit: Int = 50
): [Consultation!]!
@cypher(
statement: """
WITH $organization AS org, $subject AS subj
MATCH (c:Consultation)
WHERE c.status = 'O'
AND (org IS NULL OR toLower(c.organization) CONTAINS toLower(org))
AND (subj IS NULL OR subj IN c.subjects)
RETURN c
ORDER BY c.end_date ASC
LIMIT $limit
"""
columnName: "c"
)
# Get consultation statistics by organization
getConsultationStatsByOrganization(
organization: String
year: Int
limit: Int = 20
): [ConsultationOrgStats!]!
@cypher(
statement: """
WITH $organization AS org, $year AS startYear
MATCH (c:Consultation)
WHERE (org IS NULL OR toLower(c.organization) CONTAINS toLower(org))
AND (startYear IS NULL OR c.start_year = startYear)
WITH c.organization AS organization, c.organization_code AS code,
count(c) AS total_consultations,
count(CASE WHEN c.status = 'O' THEN 1 END) AS open_count,
count(CASE WHEN c.status = 'C' THEN 1 END) AS completed_count,
count(CASE WHEN c.report_available_online = true THEN 1 END) AS with_reports
RETURN {
organization: organization,
organization_code: code,
total_consultations: total_consultations,
open_count: open_count,
completed_count: completed_count,
with_reports: with_reports
} AS stats
ORDER BY total_consultations DESC
LIMIT $limit
"""
columnName: "stats"
)
# Get consultation statistics by subject
getConsultationStatsBySubject(
year: Int
limit: Int = 20
): [ConsultationSubjectStats!]!
@cypher(
statement: """
WITH $year AS startYear
MATCH (c:Consultation)
WHERE startYear IS NULL OR c.start_year = startYear
UNWIND c.subjects AS subject
WITH subject,
count(c) AS total_consultations,
count(CASE WHEN c.status = 'O' THEN 1 END) AS open_count,
count(CASE WHEN c.status = 'C' THEN 1 END) AS completed_count
RETURN {
subject_code: subject,
total_consultations: total_consultations,
open_count: open_count,
completed_count: completed_count
} AS stats
ORDER BY total_consultations DESC
LIMIT $limit
"""
columnName: "stats"
)
# ============================================
# Departmental Travel & Hospitality Queries
# ============================================
# Search departmental travel expenses
searchDepartmentalTravel(
department: String
name: String
destination: String
year: Int
minAmount: Float
disclosureGroup: String
limit: Int = 50
): [DepartmentalTravel!]!
@cypher(
statement: """
WITH $department AS dept, $name AS travelerName, $destination AS dest, $year AS travelYear, $minAmount AS minAmt, $disclosureGroup AS dGroup
MATCH (t:DepartmentalTravel)
WHERE (dept IS NULL OR toLower(t.organization) CONTAINS toLower(dept))
AND (travelerName IS NULL OR toLower(t.name) CONTAINS toLower(travelerName))
AND (dest IS NULL OR toLower(t.destination_en) CONTAINS toLower(dest))
AND (travelYear IS NULL OR t.travel_year = travelYear)
AND (minAmt IS NULL OR t.total >= minAmt)
AND (dGroup IS NULL OR toLower(t.disclosure_group) CONTAINS toLower(dGroup))
RETURN t
ORDER BY t.total DESC
LIMIT $limit
"""
columnName: "t"
)
# Search departmental hospitality expenses
searchDepartmentalHospitality(
department: String
name: String
location: String
year: Int
minAmount: Float
disclosureGroup: String
limit: Int = 50
): [DepartmentalHospitality!]!
@cypher(
statement: """
WITH $department AS dept, $name AS hostName, $location AS loc, $year AS hospYear, $minAmount AS minAmt, $disclosureGroup AS dGroup
MATCH (h:DepartmentalHospitality)
WHERE (dept IS NULL OR toLower(h.organization) CONTAINS toLower(dept))
AND (hostName IS NULL OR toLower(h.name) CONTAINS toLower(hostName))
AND (loc IS NULL OR toLower(h.location_en) CONTAINS toLower(loc))
AND (hospYear IS NULL OR h.hospitality_year = hospYear)
AND (minAmt IS NULL OR h.total >= minAmt)
AND (dGroup IS NULL OR toLower(h.disclosure_group) CONTAINS toLower(dGroup))
RETURN h
ORDER BY h.total DESC
LIMIT $limit
"""
columnName: "h"
)
# Get top travelers by spending
getTopTravelers(
department: String
year: Int
limit: Int = 20
): [TravelerStats!]!
@cypher(
statement: """
WITH $department AS dept, $year AS travelYear
MATCH (t:DepartmentalTravel)
WHERE (dept IS NULL OR toLower(t.organization) CONTAINS toLower(dept))
AND (travelYear IS NULL OR t.travel_year = travelYear)
WITH t.name AS name, t.organization AS organization, sum(t.total) AS total_spending, count(t) AS trip_count
RETURN {
name: name,
organization: organization,
total_spending: total_spending,
trip_count: trip_count
} AS stats
ORDER BY total_spending DESC
LIMIT $limit
"""
columnName: "stats"
)
# Get department travel spending summary
getDepartmentTravelSpending(
year: Int
limit: Int = 20
): [DepartmentSpendingStats!]!
@cypher(
statement: """
WITH $year AS travelYear
MATCH (t:DepartmentalTravel)
WHERE travelYear IS NULL OR t.travel_year = travelYear
WITH t.organization AS organization, t.organization_code AS code,
sum(t.total) AS total_spending,
count(t) AS record_count,
sum(t.airfare) AS airfare_total,
sum(t.lodging) AS lodging_total,
sum(t.meals) AS meals_total
RETURN {
organization: organization,
organization_code: code,
total_spending: total_spending,
record_count: record_count,
airfare_total: airfare_total,
lodging_total: lodging_total,
meals_total: meals_total
} AS stats
ORDER BY total_spending DESC
LIMIT $limit
"""
columnName: "stats"
)
# Get department hospitality spending summary
getDepartmentHospitalitySpending(
year: Int
limit: Int = 20
): [DepartmentHospitalityStats!]!
@cypher(
statement: """
WITH $year AS hospYear
MATCH (h:DepartmentalHospitality)
WHERE hospYear IS NULL OR h.hospitality_year = hospYear
WITH h.organization AS organization, h.organization_code AS code,
sum(h.total) AS total_spending,
count(h) AS record_count,
sum(h.attendees) AS total_attendees
RETURN {
organization: organization,
organization_code: code,
total_spending: total_spending,
record_count: record_count,
total_attendees: total_attendees
} AS stats
ORDER BY total_spending DESC
LIMIT $limit
"""
columnName: "stats"
)
# ============================================
# Visualizer Seat Data Queries
# ============================================
# Get seat counts by province and party for the Canada Visualizer
seatsByProvinceAndParty: [ProvinceSeatCount!]!
@cypher(
statement: """
MATCH (mp:MP {current: true})
WHERE mp.province IS NOT NULL AND mp.party IS NOT NULL
WITH mp.province AS province, mp.party AS party, count(mp) AS seat_count
ORDER BY province, seat_count DESC
RETURN {
province: province,
party: party,
seats: seat_count
} AS data
"""
columnName: "data"
)
# Get national seat totals by party
nationalSeatTotals: [PartySeatTotal!]!
@cypher(
statement: """
MATCH (mp:MP {current: true})
WHERE mp.party IS NOT NULL
WITH mp.party AS party, count(mp) AS seat_count
ORDER BY seat_count DESC
RETURN {
party: party,
seats: seat_count
} AS data
"""
columnName: "data"
)
}
# Province seat count for visualizer
type ProvinceSeatCount {
province: String!
party: String!
seats: Int!
}
# National party seat total
type PartySeatTotal {
party: String!
seats: Int!
}
# ATIP Organization Statistics type
type ATIPOrgStats {
organization: String!
organization_code: String!
total_requests: Int!
total_pages: Int!
disclosed_all: Int!
disclosed_part: Int!
no_records: Int!
}
# ATIP Trends type
type ATIPTrends {
organization: String!
by_year: [ATIPYearStats!]!
by_disposition: [ATIPDispositionStats!]!
total_requests: Int!
total_pages: Int!
}
type ATIPYearStats {
year: Int!
requests: Int!
pages: Int!
}
type ATIPDispositionStats {
disposition: String!
count: Int!
}
# Consultation Organization Statistics type
type ConsultationOrgStats {
organization: String!
organization_code: String!
total_consultations: Int!
open_count: Int!
completed_count: Int!
with_reports: Int!
}
# Consultation Subject Statistics type
type ConsultationSubjectStats {
subject_code: String!
total_consultations: Int!
open_count: Int!
completed_count: Int!
}
# Traveler Statistics type
type TravelerStats {
name: String!
organization: String!
total_spending: Float!
trip_count: Int!
}
# Department Travel Spending Statistics type
type DepartmentSpendingStats {
organization: String!
organization_code: String!
total_spending: Float!
record_count: Int!
airfare_total: Float!
lodging_total: Float!
meals_total: Float!
}
# Department Hospitality Spending Statistics type
type DepartmentHospitalityStats {
organization: String!
organization_code: String!
total_spending: Float!
record_count: Int!
total_attendees: Int!
}
# Written Question statistics type
type WrittenQuestionStats {
total: Int!
answered: Int!
awaiting: Int!
}
# ============================================
# Bill Amendment Types
# ============================================
# Represents an amended section with its history
type BillSectionAmendment {
id: String!
number: String!
anchor_id: String!
marginal_note_en: String
text_en: String
text_history_en: String # JSON string: [{"version": 1, "stage": "...", "text": "..."}, ...]
has_amendments: Boolean
current_version: Int
}
# Summary of amendments for a bill
type BillAmendmentSummary {
bill_number: String!
session: String!
has_amendments: Boolean
total_versions: Int
latest_version_stage: String
amended_section_count: Int!
total_section_count: Int!
amendment_events: [AmendmentEventInfo!]!
}
# Amendment event info (simplified for summary)
type AmendmentEventInfo {
event_type: String
description_en: String
event_date: DateTime
chamber: String
committee_name: String
}
# Text history for a specific section
type SectionTextHistory {
anchor_id: String!
current_text: String
text_history_en: String # JSON string with version history
has_amendments: Boolean
current_version: Int
}
# ============================================
# GC InfoBase Return Types
# ============================================
type DepartmentSpendingSummary {
organization: String!
fiscal_year: String!
program: String!
planned_spending: Float
actual_spending: Float
variance: Float
fte_planned: Float
fte_actual: Float
}
type DepartmentResultSummary {
organization: String!
fiscal_year: String!
program: String!
indicator_name: String!
target: String
actual_result: String
met_target: Boolean
}
type DepartmentComparison {
department: String!
fiscal_year: String!
spending: SpendingComparison!
fte: FTEComparison!
performance: PerformanceComparison!
program_count: Int!
}
type SpendingComparison {
planned: Float
actual: Float
variance: Float
variance_percent: Float
}
type FTEComparison {
planned: Float
actual: Float
variance: Float
}
type PerformanceComparison {
targets_met: Int!
targets_missed: Int!
targets_unknown: Int!
success_rate: Float!
}
type TopDepartmentSpending {
organization: String!
fiscal_year: String
total_spending: Float
total_fte: Float
program_count: Int!
}
type GCInfoBaseSearchResults {
total_count: Int!
results: [GCInfoBaseSearchResult!]!
}
type GCInfoBaseSearchResult {
type: String!
organization: String!
program: String
fiscal_year: String
actual_spending: Float
indicator: String
}
# ============================================
# Cross-Dataset Financial Analysis Types
# ============================================
# Money flow analysis for an entity
type MoneyFlowAnalysis {
entity_name: String!
contracts: ContractFlowSummary!
grants: GrantFlowSummary!
donations: DonationFlowSummary!
lobbying: LobbyingFlowSummary!
}
type ContractFlowSummary {
total_amount: Float!
count: Int!
departments: [String!]!
}
type GrantFlowSummary {
total_amount: Float!
count: Int!
programs: [String!]!
}
type DonationFlowSummary {
total_amount: Float!
count: Int!
parties: [String!]!
}
type LobbyingFlowSummary {
registration_count: Int!
communication_count: Int!
subjects: [String!]!
}
# MP finance analysis
type MPFinanceAnalysis {
mp: MPBasicInfo!
expenses: MPExpenseSummary!
lobbying_contacts: LobbyContactSummary!
}
type MPBasicInfo {
id: ID!
name: String!
party: String
riding: String
}
type MPExpenseSummary {
total: Float!
by_category: [ExpenseCategory!]!
fiscal_year: Int
}
type ExpenseCategory {
category: String!
amount: Float!
}
type LobbyContactSummary {
count: Int!
organizations: [String!]!
}
# Party funding comparison
type PartyFundingComparison {
parties: [PartyFundingSummary!]!
year: Int
}
type PartyFundingSummary {
party: String!
total_raised: Float!
donor_count: Int!
average_donation: Float!
top_donors: [String!]!
}
# Department spending analysis
type DepartmentSpendingAnalysis {
department: String!
contracts: DeptContractSummary!
grants: DeptGrantSummary!
}
type DeptContractSummary {
total_amount: Float!
count: Int!
top_vendors: [String!]!
}
type DeptGrantSummary {
total_amount: Float!
count: Int!
top_programs: [String!]!
}
# Industry influence analysis
type IndustryInfluenceAnalysis {
industry: String!
active_organizations: [IndustryOrgSummary!]!
total_registrations: Int!
communication_count: Int!
top_contacted_officials: [String!]!
targeted_institutions: [InstitutionSummary!]!
}
type IndustryOrgSummary {
organization: String!
registration_count: Int!
}
type InstitutionSummary {
institution: String!
count: Int!
}
# Conflict of interest analysis
type ConflictOfInterestAnalysis {
entity_name: String!
contributions: ContributionSummary!
contracts: ContractCheckSummary!
grants: GrantCheckSummary!
lobbying: LobbyCheckSummary!
potential_concerns: [String!]!
has_concerns: Boolean!
}
type ContributionSummary {
total: Float!
count: Int!
parties: [String!]!
}
type ContractCheckSummary {
count: Int!
items: [ContractItem!]!
}
type ContractItem {
amount: Float!
department: String
date: String
}
type GrantCheckSummary {
count: Int!
items: [GrantItem!]!
}
type GrantItem {
amount: Float!
program: String
date: String
}
type LobbyCheckSummary {
registration_count: Int!
subjects: [String!]!
}
`;