openapi: 3.1.0
info:
  title: SF BOS Search API
  version: 2.0.0
  description: Provider-neutral evidence retrieval, deterministic aggregation, snapshots, and change feeds over San Francisco Board of Supervisors records.
externalDocs:
  description: Model and agent access guide
  url: https://sfbos.info/llms.txt
servers:
  - url: https://sfbos.info
paths:
  /api/query:
    get:
      operationId: retrieveBoardEvidence
      summary: Retrieve an evidence bundle for a natural-language question
      description: Infers obvious names, years, and question intent, then searches legislative items, pages, recorded votes, and relevant public-comment summaries concurrently.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 500 }
        - name: format
          in: query
          schema: { type: string, enum: [json, md] }
      responses:
        "200":
          description: A compact evidence bundle with structured-record or page anchors and official PDF citations.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvidenceQueryResponse"
            text/markdown:
              schema: { type: string }
  /api/search:
    get:
      operationId: searchBoardRecords
      summary: Search Board of Supervisors records
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 300 }
        - name: year
          in: query
          schema: { type: integer, minimum: 2012, maximum: 2026 }
        - name: kind
          in: query
          schema: { type: string, enum: [agenda, minutes] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: mode
          in: query
          description: Hybrid reranking is used only when a provider-neutral embedding endpoint and page embeddings are configured; otherwise the response reports a lexical fallback.
          schema: { type: string, enum: [lexical, hybrid], default: lexical }
        - name: format
          in: query
          schema: { type: string, enum: [json, md] }
      responses:
        "200":
          description: Matching PDF pages, ranked by relevance.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
            text/markdown:
              schema: { type: string }
  /api/items:
    get:
      operationId: searchLegislativeItems
      summary: Search legislative files and their recorded roll calls
      description: Searches complete legislative-item blocks and related files for the same matter. Roll calls preserve the action immediately preceding the vote.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 300 }
        - name: voter
          in: query
          description: Recorded supervisor surname or full name.
          schema: { type: string, maxLength: 80 }
        - name: position
          in: query
          description: Recorded position. Requires voter. This is not an inferred stance on the underlying matter.
          schema: { type: string, enum: [aye, no, absent, excused] }
        - name: final
          in: query
          description: Restrict results to records with a roll call classified as a final action.
          schema: { type: boolean, default: false }
        - name: groupBy
          in: query
          description: Collapse repeated meetings by legislative file or normalized matter.
          schema: { type: string, enum: [none, file, matter], default: none }
        - name: from
          in: query
          schema: { type: integer, minimum: 2012, maximum: 2026, default: 2012 }
        - name: to
          in: query
          schema: { type: integer, minimum: 2012, maximum: 2026, default: 2026 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: format
          in: query
          schema: { type: string, enum: [json, md] }
      responses:
        "200":
          description: Matching legislative items with action-aware roll calls.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ItemSearchResponse"
            text/markdown:
              schema: { type: string }
  /api/comments:
    get:
      operationId: searchPublicComments
      summary: Search individual public-comment summaries
      description: Searches speaker-level, clerk-written summaries parsed from official meeting minutes. These are not verbatim transcripts.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 300 }
        - name: speaker
          in: query
          schema: { type: string, maxLength: 80 }
        - name: from
          in: query
          schema: { type: integer, minimum: 2012, maximum: 2026, default: 2012 }
        - name: to
          in: query
          schema: { type: integer, minimum: 2012, maximum: 2026, default: 2026 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: format
          in: query
          schema: { type: string, enum: [json, md] }
      responses:
        "200":
          description: Matching speaker statements from official minutes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommentSearchResponse"
            text/markdown:
              schema: { type: string }
  /api/aggregates/votes:
    get:
      operationId: aggregateRecordedVotes
      summary: Deterministically aggregate a supervisor's recorded positions
      description: Counts positions on their attached actions. It does not infer a stance on the underlying matter.
      parameters:
        - { name: voter, in: query, required: true, schema: { type: string } }
        - { name: position, in: query, schema: { type: string, enum: [aye, no, absent, excused] } }
        - { name: from, in: query, schema: { type: integer, minimum: 2012 } }
        - { name: to, in: query, schema: { type: integer, minimum: 2012 } }
        - { name: final, in: query, schema: { type: boolean, default: false } }
        - { name: groupBy, in: query, schema: { type: string, enum: [file, roll-call], default: file } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200": { description: Reconciled recorded positions and page-addressable evidence. }
  /api/aggregates/housing:
    get:
      operationId: aggregateHousingUnitMentions
      summary: Aggregate housing-related actions and extracted unit mentions
      description: Uses one most-recent matching action per file and an explicit maximum-unit-mention rule. The response includes cautions and source evidence.
      parameters:
        - { name: voter, in: query, required: true, schema: { type: string } }
        - { name: position, in: query, schema: { type: string, enum: [aye, no, absent, excused] } }
        - { name: from, in: query, schema: { type: integer, minimum: 2012 } }
        - { name: to, in: query, schema: { type: integer, minimum: 2012 } }
        - { name: final, in: query, schema: { type: boolean, default: false } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200": { description: Housing-unit mentions, addresses, deterministic totals, and citations. }
  /api/snapshots:
    get:
      operationId: listSnapshots
      summary: List versioned bulk snapshot feeds
      responses:
        "200": { description: Snapshot manifest. }
  /api/snapshots/{entity}:
    get:
      operationId: readSnapshot
      summary: Read a cursor-paginated JSON or NDJSON snapshot
      parameters:
        - { name: entity, in: path, required: true, schema: { type: string, enum: [documents, supervisors, recorded-positions, legislative-files] } }
        - { name: cursor, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 1000 } }
        - { name: format, in: query, schema: { type: string, enum: [json, ndjson], default: json } }
      responses:
        "200": { description: A stable-schema snapshot page. }
  /api/changes:
    get:
      operationId: readChangeFeed
      summary: Read the append-only entity change feed
      parameters:
        - { name: cursor, in: query, schema: { type: integer, minimum: 0, default: 0 } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 250 } }
      responses:
        "200": { description: Ordered entity changes and the next cursor. }
  /api/quality:
    get:
      operationId: readDataQuality
      summary: Read coverage, provenance, and reconciliation metrics
      responses:
        "200": { description: Current data-quality report. }
components:
  schemas:
    EvidenceQueryResponse:
      type: object
      required: [question, interpretation, guidance, legislativeItems, pageMatches, publicComments]
      properties:
        question: { type: string }
        interpretation:
          type: object
          required: [searchQuery, voter, recordedPosition, fromYear, toYear, voteIntent, housingIntent, commentIntent, finalOnly]
          properties:
            searchQuery: { type: string }
            voter: { type: [string, "null"] }
            recordedPosition: { type: [string, "null"], enum: [aye, no, null] }
            fromYear: { type: integer }
            toYear: { type: integer }
            voteIntent: { type: boolean }
            housingIntent: { type: boolean }
            commentIntent: { type: boolean }
            finalOnly: { type: boolean }
        guidance: { type: array, items: { type: string } }
        legislativeItems: { $ref: "#/components/schemas/ItemSearchResponse" }
        pageMatches: { $ref: "#/components/schemas/SearchResponse" }
        publicComments:
          oneOf:
            - { $ref: "#/components/schemas/CommentSearchResponse" }
            - { type: "null" }
    SearchResponse:
      type: object
      required: [query, interpretedQueries, filters, total, returned, source, retrieval, results]
      properties:
        query: { type: string }
        interpretedQueries: { type: array, items: { type: string } }
        filters: { type: object }
        total: { type: integer }
        returned: { type: integer }
        source: { type: string, enum: [postgres, preview] }
        retrieval:
          type: object
          required: [requested, used, embeddingModel, semanticCoverage, fallbackReason]
          properties:
            requested: { type: string, enum: [lexical, hybrid] }
            used: { type: string, enum: [lexical, hybrid] }
            embeddingModel: { type: [string, "null"] }
            semanticCoverage: { type: number, minimum: 0, maximum: 1 }
            fallbackReason: { type: [string, "null"] }
        results:
          type: array
          items:
            type: object
            required: [id, meetingDate, year, kind, title, transcriptUrl, markdownUrl, officialUrl, page, snippet, score]
            properties:
              id: { type: string }
              meetingDate: { type: string, format: date }
              year: { type: integer }
              kind: { type: string, enum: [agenda, minutes] }
              title: { type: string }
              transcriptUrl: { type: string, format: uri }
              markdownUrl: { type: string, format: uri }
              officialUrl: { type: string, format: uri }
              page: { type: integer }
              fileNumber:
                description: Present when transcriptUrl targets a structured legislative-record row.
                type: string
              snippet: { type: string }
              score: { type: number }
    RollCall:
      type: object
      required: [sequence, action, actionType, isFinal, ayes, noes, absent, excused]
      properties:
        sequence: { type: integer }
        action: { type: string }
        actionType: { type: string, enum: [final-passage, first-reading, adoption, approval, amendment, continuance, referral, tabling, rejection, rescission, other] }
        isFinal: { type: boolean }
        ayes: { type: array, items: { type: string } }
        noes: { type: array, items: { type: string } }
        absent: { type: array, items: { type: string } }
        excused: { type: array, items: { type: string } }
    ItemSearchResponse:
      type: object
      required: [query, interpretedQueries, filters, total, returned, results]
      properties:
        query: { type: string }
        interpretedQueries: { type: array, items: { type: string } }
        filters: { type: object }
        total: { type: integer }
        returned: { type: integer }
        results:
          type: array
          items:
            type: object
            required: [id, meetingDate, year, fileNumber, matter, title, transcriptUrl, markdownUrl, officialUrl, startPage, endPage, snippet, score, groupCount, extracted, rollCalls]
            properties:
              id: { type: string }
              meetingDate: { type: string, format: date }
              year: { type: integer }
              fileNumber: { type: string }
              matter: { type: string }
              title: { type: string }
              transcriptUrl: { type: string, format: uri }
              markdownUrl: { type: string, format: uri }
              officialUrl: { type: string, format: uri }
              startPage: { type: integer }
              endPage: { type: integer }
              snippet: { type: string }
              score: { type: number }
              groupCount: { type: integer }
              extracted:
                type: object
                required: [amounts, housingUnits, addresses, parties]
                properties:
                  amounts:
                    type: array
                    items:
                      type: object
                      required: [raw, value, currency, qualifier]
                      properties:
                        raw: { type: string }
                        value: { type: integer }
                        currency: { type: string, const: USD }
                        qualifier: { type: string, enum: [not-to-exceed, increase, decrease, appropriation, revenue, stated] }
                  housingUnits: { type: array, items: { type: integer } }
                  addresses: { type: array, items: { type: string } }
                  parties: { type: array, items: { type: string } }
              rollCalls:
                type: array
                items: { $ref: "#/components/schemas/RollCall" }
    CommentSearchResponse:
      type: object
      required: [query, interpretedQueries, filters, total, returned, results]
      properties:
        query: { type: string }
        interpretedQueries: { type: array, items: { type: string } }
        filters: { type: object }
        total: { type: integer }
        returned: { type: integer }
        results:
          type: array
          items:
            type: object
            required: [id, meetingDate, year, speaker, statement, transcriptUrl, markdownUrl, officialUrl, page, score]
            properties:
              id: { type: string }
              meetingDate: { type: string, format: date }
              year: { type: integer }
              speaker: { type: string }
              statement: { type: string }
              transcriptUrl: { type: string, format: uri }
              markdownUrl: { type: string, format: uri }
              officialUrl: { type: string, format: uri }
              page: { type: integer }
              score: { type: number }
