Skip to content

Bid Library Import Guide

Reference guide for importing Q&A pairs from Word documents into Knowledge Hub.


The bid library import pipeline extracts question-and-answer pairs from .docx tables, deduplicates them, classifies them by domain, generates vector embeddings, and stores them in Supabase as content_items records with content_type = "q_a_pair" and platform = "extraction".

Pipeline stages (9 steps):

.docx files --> Extract tables --> Exact dedup (MD5)
--> Near-duplicate detection (flagged, not removed)
--> Keyword classification (7 categories)
--> Quality validation (empty/fragment detection)
--> Idempotency check (skip existing records)
--> Embedding generation (OpenAI text-embedding-3-large @ 1024 dims)
--> Supabase storage (service_role, bypasses RLS)

When to use:

  • First import for a new client’s bid library documents
  • When updated or additional bid library documents are received
  • After re-exporting corrected documents from the original authors

Source files (scripts/):

FilePurpose
import_bid_library.pyCLI orchestrator — chains all stages
extract_docx_tables.pyTable format detection, header mapping, Q&A extraction
dedup.pyExact dedup (MD5) and near-duplicate detection (SequenceMatcher)
keyword_classifier.py7-category keyword-based classification
kb_pipeline/embed.pyOpenAI embedding generation
kb_pipeline/store.pySupabase REST insert via service_role key
kb_pipeline/config.pyEnvironment loading, model constants, pricing

  • .docx only (Word Open XML)
  • .pdf and .xlsx are not supported
  • Temporary files (names starting with ~) are automatically skipped

The extraction script detects four table layout patterns, plus a positional fallback for tables with empty or unrecognised headers.

Used by the “2026 Audit” series of documents.

NoSectionQuestionStandard ResponseAdvanced ResponseNotes

Used by the “DRAFT 2026” series of documents. No advanced response column.

NoSectionQuestionStandard ResponseNotes

Alternative ordering where the section column appears after the answers.

NoQuestionStandard AnswerAdvanced AnswerSectionNotes

When all header cells are empty or contain unrecognised text, the script falls back to positional assignment based on column count:

  • 5 columns: column 0 = question, column 1 = standard answer, no advanced
  • 6+ columns: column 0 = question, column 1 = standard answer, column 2 = advanced answer

In positional mode, the first row is treated as data (not a header row). In all other modes, the first row is consumed as a header row and data starts from row 2.

Recognised header variants (full _HEADER_MAP)

Section titled “Recognised header variants (full _HEADER_MAP)”

The script normalises headers by stripping whitespace, lowercasing, and removing trailing punctuation before matching. The complete set of recognised variants:

Question columns (mapped to question):

  • question, questions, query, requirement, requirements, suggested questions

Standard response columns (mapped to standard):

  • standard response, standard answer, standard, response, answer, answer for standard audit system, answer for standard audits, standard configuration answer

Advanced response columns (mapped to advanced):

  • advanced response, advanced answer, advanced, enhanced response, enhanced answer, answer for advanced audits, advanced audits answer

Section columns (mapped to section):

  • section, category, topic, area

Number columns (mapped to number):

  • no, no., #, number, ref, id

Notes columns (mapped to notes):

  • notes, comments, note, comment

When column 0 is detected as question but subsequent columns have empty headers, the script infers:

  • First empty column after question = standard
  • Second empty column after question = advanced

This handles Audit template files where the header row has “Question” in column 0 but blank cells for the answer columns.

Section names come from two sources, in priority order:

  1. Column value: If the table has a section column and the cell for that row is non-empty, the cell value is used
  2. Document headings: The script tracks Heading 1/2/3 styles as it walks through the document body. The most recently encountered heading is assigned as the section name for tables that follow it
  • At least 2 rows (1 header + 1 data row), or 1 row in positional fallback mode
  • A detectable question column (by header or position)
  • A detectable standard answer column (by header or position)
  • Non-Q&A tables (e.g., formatting tables, cover pages) are silently skipped

  1. Python dependencies installed:

    Terminal window
    pip install -r requirements.txt

    Key packages: python-docx (extraction), openai (embeddings)

  2. Environment variables in .env at the project root:

    SUPABASE_URL=https://rovrymhhffssilaftdwd.supabase.co
    SUPABASE_SECRET_KEY=<service_role key>
    OPENAI_API_KEY=sk-...

    The pipeline uses SUPABASE_SECRET_KEY (service_role), not the anon key. This bypasses RLS, which is required for pipeline inserts.

  3. Documents placed in a directory (typically .planning/client-documentation/)

Run extraction, dedup, and classification without generating embeddings or writing to Supabase:

Terminal window
PYTHONUNBUFFERED=1 python3 scripts/import_bid_library.py \
.planning/client-documentation/ \
--dry-run

What to look for in dry run output:

  • [1/9] — Correct number of .docx files found (the .pdf is ignored)
  • [2/9] — Per-file extraction counts. Non-Q&A documents (e.g., “Sector-Intelligence-Brief”) should show 0 pairs — this is correct
  • [3/9] — Exact duplicates removed (questions repeated across Audit and DRAFT versions of the same document)
  • [4/9] — Near-duplicate candidates flagged for review. These are NOT removed automatically
  • [5/9] — Classification breakdown across the 7 categories. Check for a reasonable distribution and note the unclassified count
  • [6/9] — Quality validation. Empty and fragment items are flagged with warnings but still imported. Review the quality report at the end
Terminal window
PYTHONUNBUFFERED=1 python3 scripts/import_bid_library.py \
.planning/client-documentation/ \
--batch-name "initial-bid-library-2026"

Options:

FlagDefaultPurpose
--dry-runoffExtract + dedup + classify + validate only, no embed/store
--skip-embedoffStore records without generating embeddings
--near-dedup-threshold0.85Similarity threshold for near-duplicate detection
--batch-nameauto (bid-library-YYYYMMDD-HHMMSS)Label for this import batch (stored in metadata)
--forceoffSkip idempotency check — import even if matching records already exist

Timing estimate: For ~180 unique Q&A pairs, expect roughly 2-3 minutes (dominated by embedding API calls at ~10 pairs per progress update).

After a full import, verify the results using Supabase MCP or direct SQL:

-- Count imported items
SELECT COUNT(*)
FROM content_items
WHERE content_type = 'q_a_pair'
AND platform = 'extraction';
-- Classification breakdown
SELECT primary_domain, COUNT(*)
FROM content_items
WHERE content_type = 'q_a_pair'
GROUP BY primary_domain
ORDER BY count DESC;
-- Check a specific batch
SELECT COUNT(*), MIN(created_at), MAX(created_at)
FROM content_items
WHERE metadata->>'import_batch' = 'initial-bid-library-2026';
-- Items missing embeddings
SELECT COUNT(*)
FROM content_items
WHERE content_type = 'q_a_pair'
AND embedding IS NULL;
-- Spot-check: view first 5 items
SELECT id, title, primary_domain, primary_subtopic,
classification_confidence, metadata->>'source_file' AS source
FROM content_items
WHERE content_type = 'q_a_pair'
ORDER BY created_at DESC
LIMIT 5;

Documents like “Sector-Intelligence-Brief-Liam-Final.docx” do not contain Q&A tables. The extraction script correctly returns 0 pairs for these — this is expected behaviour, not an error.

Near-duplicates across Audit and DRAFT versions

Section titled “Near-duplicates across Audit and DRAFT versions”

The same Q&A content often appears in both the “2026 Audit” and “DRAFT 2026” versions of a document. Exact duplicates (identical question text after normalisation) are removed automatically. Near-duplicates (similar but not identical wording) are flagged for manual review but not removed. Both versions are stored.

The source documents contain 207 table rows that look like Q&A entries, but 2 rows have empty question cells (formatting/spacing rows in the original Word documents). These are correctly skipped, resulting in 205 extracted pairs before dedup.

Rows where the question cell is empty after stripping whitespace are silently skipped. This handles formatting rows, section dividers, and other non-content rows in the source tables.

The keyword-based classifier achieves approximately 93% classification rate. Roughly 13 out of 179 unique pairs may remain unclassified — these are pairs whose question and answer text does not contain enough keyword matches for any of the 7 categories. Unclassified pairs are still stored and searchable; they simply have empty primary_domain and primary_subtopic fields.

Always prefix the command with PYTHONUNBUFFERED=1 when running the script in the background or monitoring output in real time. Without this, Python buffers stdout and progress updates are invisible until the script completes.

If you attempt to update a record by UUID and the UUID does not match any row, Supabase returns 200 OK with 0 rows affected. This is a Supabase REST API behaviour, not a bug. Always verify updates by re-querying.

The pipeline uses SUPABASE_SECRET_KEY (service_role) to bypass RLS. The anon key will fail with permission errors because the pipeline does not authenticate as a Supabase Auth user.


For the pipeline to extract Q&A pairs from a new .docx file:

  1. File format: .docx (Word Open XML). Not .doc, .pdf, or .xlsx
  2. Table structure: At least one table with:
    • A header row containing recognised column names (or all-empty headers for positional fallback)
    • One or more data rows below the header
    • A question column (required)
    • A standard answer column (required)
    • An advanced answer column (optional)
  3. Cell content: Plain text in table cells. Paragraph breaks within a cell are preserved (joined with \n). Formatting (bold, italic, etc.) is stripped — only text content is extracted.

Use any of the recognised header variants listed in section 2. The most reliable options are:

  • Question: Question or Requirement
  • Standard answer: Standard Response or Standard Answer
  • Advanced answer: Advanced Response or Advanced Answer (if applicable)
  • Section: Section or Category
  • Number: No or #
  • Notes: Notes or Comments

Headers are case-insensitive and trailing punctuation is stripped. Question:, QUESTION, and question all resolve to the same canonical name.

If a new document uses header text that is not currently recognised (e.g., “Tender Question” or “Supplier Response”), add it to the _HEADER_MAP dictionary in scripts/extract_docx_tables.py:

_HEADER_MAP = {
# ... existing entries ...
# Add new variants:
"tender question": "question",
"supplier response": "standard",
}

The key is the lowercased, stripped header text. The value is one of the canonical names: question, standard, advanced, section, number, or notes.

Each Q&A pair becomes a content_items record with:

FieldValue
titleQuestion text, truncated at word boundary near 120 chars (with ... suffix)
contentQ: {question}\n\n{standard}\n{advanced} — includes question text for search
content_typeq_a_pair
platformextraction
primary_domainKeyword classifier result (e.g., security)
primary_subtopicKeyword classifier result (e.g., data-protection)
classification_confidence0.0—1.0, based on keyword score distribution
ai_summaryFirst ~200 chars of answer text, truncated at word boundary
ai_keywords[primary_domain, section-name-slug]
metadata.source_fileOriginal .docx filename
metadata.section_nameSection heading from the document
metadata.table_indexWhich table in the document (0-indexed)
metadata.row_indexWhich row in the table (0-indexed from data start)
metadata.has_standardtrue/false
metadata.has_advancedtrue/false
metadata.import_batchBatch name (auto-generated or --batch-name value)

Embedding input: The embedding combines the question text (title), first 500 chars of combined answer text (ai_summary slot), and the full combined answer text (both standard and advanced). This ensures semantic search finds Q&A pairs by both question and answer content.

Truncation: Title and ai_summary use word-boundary-aware truncation. If the text exceeds the limit, it is cut at the last space before the limit (provided that space is within 70% of the limit), followed by .... This avoids mid-word cuts like "What is your approac..." in favour of "What is your approach to...".


  1. Count check: Run the count query from section 3. The number of stored items should match the “Unique classified” count from the import summary.

  2. Classification breakdown: Run the classification query. Expect a reasonable distribution across categories. A high unclassified count (more than ~10%) may indicate new question patterns that need keyword additions.

  3. Embedding check: Run the missing-embeddings query. All items should have embeddings unless --skip-embed was used.

  4. Search test: Run a semantic search to verify embeddings are working:

    Terminal window
    bun run scripts/kb-search.ts "data protection policy" --limit 5

    Q&A pairs about GDPR and data protection should appear in results.

  5. Spot check: Review 5—10 items via the browse page or direct SQL to verify titles, content, and classifications look correct.

The import flags near-duplicate pairs but stores all of them. After import:

  1. Review the near-duplicate candidates from the import output

  2. For each pair, decide whether both versions should be kept (different nuance) or one should be removed

  3. Remove unwanted duplicates via the content review page (when built) or direct SQL:

    DELETE FROM content_items WHERE id = '<uuid-of-duplicate>';
  • Use the browse page with filter content_type = q_a_pair to review imported items
  • Check items with classification_confidence < 0.4 for possible misclassification
  • Items with empty primary_domain may benefit from manual classification or keyword additions to the classifier

ErrorCauseFix
ModuleNotFoundError: No module named 'docx'python-docx not installedpip install python-docx
ModuleNotFoundError: No module named 'openai'OpenAI package not installedpip install openai
ERROR: Could not import embedding moduleMissing OpenAI key or packageCheck OPENAI_API_KEY in .env
ERROR: Could not import store moduleMissing Supabase configCheck SUPABASE_URL and SUPABASE_SECRET_KEY in .env
No .docx files foundWrong directory path or only non-docx filesVerify the directory path and file extensions
Store error for pair #N: ...Supabase insert failureCheck error message — usually a constraint violation or network issue
0 pairs for a document you expected to have Q&AUnrecognised table formatCheck headers against the _HEADER_MAP or add new variants
Progress output not visiblePython output bufferingPrefix command with PYTHONUNBUFFERED=1

Yes (by default). The import script checks whether each Q&A pair already exists in Supabase by matching the first 80 characters of the question text against the title field of existing q_a_pair records. Existing records are skipped and reported in the output.

To override the idempotency check and force re-import of all records (e.g., after updating the content format), use --force:

Terminal window
PYTHONUNBUFFERED=1 python3 scripts/import_bid_library.py \
.planning/client-documentation/ \
--batch-name "re-import-2026" \
--force

Note: --force does not delete existing records. If you want to replace records rather than add duplicates, delete the previous batch first (see below), then re-import.

To remove all records from a specific import batch:

-- Check what would be deleted
SELECT COUNT(*), metadata->>'import_batch' AS batch
FROM content_items
WHERE metadata->>'import_batch' = 'initial-bid-library-2026'
GROUP BY batch;
-- Delete the batch
DELETE FROM content_items
WHERE metadata->>'import_batch' = 'initial-bid-library-2026';

The import_batch metadata field is specifically designed for this purpose. Every record from a single import run shares the same batch name.

If the import fails partway through (e.g., network error during embedding or storage), simply re-run the same command. The idempotency check will skip records that were already stored and only import the remaining ones.

If you prefer a clean re-import:

  1. Note the batch name from the import output
  2. Delete any partially stored records for that batch (see above)
  3. Re-run the full import with the same --batch-name

If too many pairs are unclassified, add keywords to the CATEGORY_KEYWORDS dictionary in scripts/keyword_classifier.py. Each category has subtopics, and each subtopic has a list of keywords:

CATEGORY_KEYWORDS = {
"security": {
"data-protection": [
"data protection", "gdpr", ...
"new keyword here", # add new keywords
],
},
# ...
}

Multi-word phrases are weighted more heavily in scoring (2x for 2-word phrases, 3x for 3+ word phrases, capped at 3x).

The keyword classifier assigns one of these categories as primary_domain:

CategoryCovers
securityData protection, cyber security, encryption, access control, ISO 27001
complianceStandards (ISO 9001, Cyber Essentials), regulatory, audit, certification
implementationDeployment, migration, onboarding, integration
supportSLAs, helpdesk, maintenance, incident management
corporateCompany info, financials, insurance, references, staffing
product-featureFunctionality, technical architecture, reporting, usability
methodologyApproach, project management, quality, delivery

Each category has 3—5 subtopics. The classifier assigns both a primary_domain and primary_subtopic, plus optional secondary_domain and secondary_subtopic when multiple categories score above zero.


As of the initial import, the following documents are in .planning/client-documentation/:

DocumentTypeExpected pairs
2026 Audit - … FAQs.docxAudit 6-col~30
2026 Audit - … Functionality.docxAudit 6-col~25
2026 Audit - … Implementation & Support.docxAudit 6-col~45
2026 Audit - … Security & Compliance.docxAudit 6-col~50
DRAFT 2026 … Implementation & Support.docxDRAFT 5-col~45
DRAFT 2026 … FAQs - Copy (1).docxDRAFT 5-col~30
DRAFT 2026 … Security and Compliance - Copy.docxDRAFT 5-col~50
Sector-Intelligence-Brief-Liam-Final.docxNon-Q&A0 (correct)
Telehouse-South-Fact-Sheet-2025.pdfPDF (skipped)n/a

The Audit and DRAFT versions of the same topic (e.g., “Security & Compliance”) contain overlapping content. Exact duplicates are removed; near-duplicates are flagged for review.