Skip to content

Data Entry Point Reference

DRIFT WARNING — added 2026-05-06. The Phase 0 schema investigation (docs/plans/content-items-cleanup-phase-0-investigation.md) confirmed substantial drift across §1–§12 of this document: stale line numbers, fields tables incomplete, post-S205 governance_review_statuspublication_status rename not propagated, embed-cap values stale (5,000 → 24,000), claims of “no content_history” wrong (DB trigger writes v1 server-side from ingest_source), claims of “no dedup” wrong on multiple EPs, function/route names stale (e.g. §11 RSS uses intelligence-poll not intelligence/run, processFeedSource not processSource). Per-path drift cited in docs/plans/phase-0-investigation/0.1-*.md. Phase 0.4 (drift correction) is PAUSED pending Phase 0.6 synthesis output. Do not rely on field tables, line numbers, or “fields-not-written” lists in this document for code accuracy until refresh complete. (S35 W4 smoke-test subsection in §1 IS current — added post-banner 2026-05-06.)

Last validated: 28/04/2026 (S205C reference-doc refresh — confirms S205 WP-A1 typed provenance + WP-A2 pipeline_runs on EP9 still wired; no entry-point pipeline drift since fd1c41f5)

S184 WP1 (21/04/2026): TS-side cross-system dedup complete. All insert-capable EPs now soft-block on exact-hash match by stamping content_items.dedup_status='suspected_duplicate' and writing the existing id into metadata.suspected_duplicate_of. Spec §6 decisions:

  • D1 soft-block — insert proceeds, no 409. Humans reconcile via UI.
  • D2 admin-only skip_dedup=true override — exposed on EPs 3/4/5/6/9/10. Non-admins silently ignored; EP11 is automated (no override).
  • D3 RSS many-to-many — same source_url → insert junction only (content_item_workspaces), no duplicate item. Different URL + same content hash → stamp suspected_duplicate.
  • EP10 variant — exact match on bid-outcome new_entry is skip-and-log (not stamp), because bid-outcome is a post-won admin workflow where re-integration of an existing response would be wasteful.

Full dedup matrix refreshed below.

S174 refresh scope: S164b content_items.ai_summarycontent_items.summary column rename propagated throughout (feed_articles.ai_summary is intentionally unchanged — it stores the RSS-filter LLM summary). S166–168 Plan A/B/C/D markdown canonicalisation and structural chunking reflected in the per-entry-point pipelines. S167 POST /api/items chunking gap fix verified wired. New Entry Point 11 (RSS Feed Promotion) added — articles that pass the intelligence pipeline’s relevance filter are promoted from feed_articles to content_items via lib/intelligence/pipeline.ts storeAsContentItem(). Compliance matrix rebuilt with chunking steps; known-gap added for Batch Item Creation API (Entry Point 6) which does not currently invoke regenerateChunks.

This document is the canonical reference for every path that writes to content_items. It records what each entry point actually does — which fields it sets, which processing steps it runs, and what it omits.


Entry pointSource fileClassificationEntity extractionRelationship extractionTemporal referencesDate extraction (regex)EmbeddingAI summaryLayer inferenceQuality scoreDedup checkAdmin skip_dedupContent historySource document tracking
1. Python URL Ingestionscripts/kb_pipeline/pipeline.pyYes (Python)YesYesYesNoYesYesYes (S134)NoURL hard-skip + content-hash soft-blockn/a (pipeline)Yes (S153)No
2. Python Markdown Ingestionscripts/ingest_markdown.pyYes (Python)YesYesYesNoYesYesYes (S134)NoContent-hash soft-block + title-normn/a (pipeline)Yes (S153)No
3. File Upload APIapp/api/upload/route.tsYes (TS shared)Yes (via classify)Yes (via classify)Yes (via classify + regex)YesYesYesYesYesdetect_reupload + content-hash soft-blockYes (form field)YesYes
4. URL Ingest APIapp/api/ingest/url/route.tsYes (TS shared)Yes (via classify)Yes (via classify)Yes (via classify + regex)YesYesYesYesYesURL hard-skip + content-hash soft-blockYesYesNo
5. Manual Item Creation APIapp/api/items/route.tsOptional (TS shared)Optional (via classify)Optional (via classify)Optional (via classify)NoOptionalOptionalYesYesContent-hash soft-blockYesYesNo
6. Batch Item Creation APIapp/api/items/batch/route.tsYes (TS shared)Yes (via classify)Yes (via classify)Yes (via classify)NoYesYesYesYesContent-hash soft-block per itemYes (batch-wide)YesOptional (param)
7. Batch Reclassificationscripts/batch-reclassify.tsYes (direct Anthropic)YesYesYesNoYes (regenerated)Yes (summary field)YesNon/a (UPDATE only)n/aNoNo
8. Q&A Importscripts/import_bid_library.pyYes (keyword classifier)Optional (--entities)Optional (--entities)NoNoYesNo (truncated answer only)YesNoContent-hash soft-block + idempotency checkn/a (pipeline)NoNo
9. MCP create_content_itemlib/mcp/tools/content.tsYes (TS shared)Yes (via classify)Yes (via classify)Yes (via classify)NoYes (skip for drafts)Yes (skip for drafts)Yes (skip for drafts)NoContent-hash soft-blockYes (tool arg)Yes (S186)Yes (S205 WP-A1 typed)
10. Bid Outcome KB Integrationapp/api/procurement/[id]/outcome/integrate/route.tsNoNoNoNoNoYesNo (static template)NoNoContent-hash skip-and-log (bid-outcome variant)YesNoNo
11. RSS Feed Promotionlib/intelligence/pipeline.ts (S160-series)Yes (via classifyContent)Yes (via classify)Yes (via classify)Yes (via classify)NoYes (via classify)Yes (via classify)No (not called)NoSource-url M2M + content-hash soft-blockn/a (pipeline)NoNo
12. Markdown Batch Ingest UIapp/api/ingest/markdown/route.ts + lib/ingest/markdown-orchestrator.tsYes (TS shared)Yes (via classify)Yes (via classify)Yes (via classify)NoYesYesYesNoPre-flight content-hash soft-block + filename matchYes (per-file, admin-only)Yes (trigger)No (markdown — no source_documents row)

Legend: “TS shared” = delegates to lib/ai/classify.ts classifyContent(). “Python” = uses kb_pipeline/classify.py. “via classify” = performed as part of the shared classification step. Chunking via regenerateChunks() is listed in the per-entry-point Processing Pipeline sections below and in Appendix B — it is not a column in this summary matrix to keep the width manageable.


NamePython URL Ingestion
TriggerCLI: python3 scripts/ingest.py <url>
Source filescripts/kb_pipeline/pipeline.pyprocess_url() (line 44)
LanguagePython
Auth requirementNone (uses SUPABASE_SECRET_KEY directly)

A no-op trigger path short-circuits main() before any URL collection or DB connection — used to validate env-mount + import chain without performing any ingest:

  • --smoke-test argparse flag (S34 commit 268ea58f) — local dev / manual invocation: python3 scripts/ingest.py --smoke-test. Logs smoke-test: env + imports OK and exits 0.

This path runs AFTER parser.parse_args() (so the --env=prod SUPABASE_URL fragment assertion still fires when the smoke-test flag is not set) and BEFORE URL collection / DB connection, so it requires no Supabase credentials, no taxonomy snapshot, and no network access beyond image-bundled deps.

ColumnValue / Source
titleExtracted from page (or override_title)
contentFull extracted text
source_urlInput URL
source_domainExtracted from URL
thumbnail_urlExtracted from page OG tags
content_typeExtracted or override_content_type
platformExtracted or override_platform
author_nameExtracted or override_author
captured_dateExtracted from page
metadataExtracted metadata dict + extra_metadata
primary_domainFrom classification
primary_subtopicFrom classification
secondary_domainFrom classification
secondary_subtopicFrom classification
classification_confidenceFrom classification
suggested_titleFrom classification
summaryFrom classification (overwritten by summary executive if generated)
ai_keywordsFrom classification
classification_reasoningFrom classification
classified_atdatetime.now(utc).isoformat()
embeddingOpenAI text-embedding-3-large (1024d)
summary_dataStructured summary JSON (executive, detailed, takeaways, model, tokens)

created_by, updated_by, brief, detail, reference, source_document_id, file_path, user_tags, priority, governance_review_status, answer_standard, answer_advanced, source_file, layer, quality_score, quality_score_updated_at, lifecycle_type, expiry_date.

  1. Extractextract_url() fetches and parses the page (Jina Reader)
  2. Apply overrides — title, content_type, platform, author, extra_metadata
  3. Dedup (pre-embed) — URL-based duplicate check via is_duplicate(source_url=url)
  4. Classify — Claude Opus 4.6 via kb_pipeline/classify.py (returns entities, relationships, temporal references)
  5. Embedbuild_embedding_text() + generate_embedding() via OpenAI
  6. Dedup (post-embed) — Embedding similarity check via is_duplicate(embedding=embedding)
  7. Summarisegenerate_summary() produces structured executive/detailed/takeaways
  8. Storeinsert_content_item(record) — single INSERT
  9. Store entitiesstore_entities() into entity_mentions table
  10. Store relationshipsstore_relationships() into entity_relationships table
  11. Store temporal referencesmerge_item_metadata() stores ai_temporal_references in metadata
  12. Layer inferenceinfer_layer() + update_content_item() (non-blocking)
  13. Quality logging — Logs to ingestion_quality_log for missing thumbnails, short content, low confidence, review flags
  14. Markdown chunkingstore_chunks() from scripts/kb_pipeline/chunk.py:215 (S167 Plan C parity); splits markdown at heading boundaries (mirrors TS chunkByHeadings()) into content_chunks rows with per-chunk embeddings, invoked at scripts/kb_pipeline/pipeline.py:259. Chunk embedding input truncated to MAX_EMBEDDING_CHARS (24,000) per chunk.
  • No created_by / updated_by (runs as service role)
  • No content history versioning
  • No quality score calculation
  • No source document tracking
  • No guide section suggestion
  • No topic suggestion
  • No date extraction (regex-based)

No explicit truncation limit on content. The build_embedding_text() function truncates for embedding purposes only. Chunk embedding input is truncated to MAX_EMBEDDING_CHARS (24,000) per chunk in chunk.py.


NamePython Markdown Ingestion
TriggerCLI: python3 scripts/ingest_markdown.py <dir>
Source filescripts/ingest_markdown.pyprocess_markdown_file() (line 250)
LanguagePython
Auth requirementNone (uses SUPABASE_SECRET_KEY directly)
ColumnValue / Source
titleExtracted from H1 heading, bold title, or filename
contentCleaned markdown (MDX tags stripped)
source_urlNone
source_domainNone
content_type'article' (hardcoded)
platform'manual' (hardcoded)
author_nameFrom --author CLI flag
captured_dateFile modification time
source_fileRelative path from base directory
metadata{ingestion_source: 'markdown_file', source_folder, original_format: 'markdown'}
primary_domainFrom classification
primary_subtopicFrom classification
secondary_domainFrom classification
secondary_subtopicFrom classification
classification_confidenceFrom classification
suggested_titleFrom classification
summaryFrom classification (overwritten by summary executive if generated)
ai_keywordsFrom classification + folder tag + extra --tag
classification_reasoningFrom classification
classified_atdatetime.now(utc).isoformat()
embeddingOpenAI text-embedding-3-large (1024d)
summary_dataStructured summary JSON (via post-insert UPDATE)

created_by, updated_by, source_domain, thumbnail_url, brief, detail, reference, source_document_id, file_path, user_tags, priority, governance_review_status, answer_standard, answer_advanced, layer, quality_score, quality_score_updated_at, lifecycle_type, expiry_date.

  1. Read file — UTF-8 read from disk
  2. Extract title — H1 heading, bold title after “Article N”, or filename fallback
  3. Clean MDX tags — Strips <Note>, <CodeGroup>, etc. and documentation index blocks
  4. Skip-existing check — Optional (--skip-existing): queries source_file column
  5. Classify — Claude Opus 4.6 via kb_pipeline/classify.py
  6. Embedbuild_embedding_text() + generate_embedding() via OpenAI
  7. Dedup (post-embed) — Embedding similarity check only (no URL dedup — no URL)
  8. Build keywords — Classification keywords + folder tag + user tag
  9. Storeinsert_content_item(record) — single INSERT
  10. Entity storagestore_entities() + store_relationships() (non-blocking)
  11. Temporal referencesmerge_item_metadata() stores ai_temporal_references (non-blocking)
  12. Layer inferenceinfer_layer() + update_content_item() (non-blocking)
  13. Summarisegenerate_summary() + update_content_item() (post-insert UPDATE)
  14. Quality logging — Short content, low confidence, review flags
  • No content history versioning
  • No quality score calculation
  • No source document tracking
  • No created_by / updated_by
  • No date extraction (regex-based)
  • No markdown chunking (GAP). scripts/ingest_markdown.py does not invoke scripts/kb_pipeline/chunk.py store_chunks(), so items ingested by this path have content_items rows but no content_chunks rows. Search via search_content_chunks MCP tool will not return these items. Backfill-only fix available: bun run scripts/backfill-chunks.ts.

No explicit content truncation. MDX cleanup may reduce character count.


NameFile Upload API
TriggerPOST /api/upload (multipart form data)
Source fileapp/api/upload/route.tsPOST() (line 154)
LanguageTypeScript
Auth requirementAuthenticated user with admin or editor role

Initial INSERT (line 293):

ColumnValue / Source
titleForm field title or derived from filename
content'' (empty — updated after extraction)
suggested_titleSame as title
content_typeForm field content_type or inferred from MIME type
platform'manual'
metadata{original_filename, file_size, mime_type, ingestion_source: 'upload'}
governance_review_status'draft' if draft=true form field
author_nameForm field author
created_byAuthenticated user ID

Post-extraction UPDATE (line 508):

ColumnValue / Source
contentExtracted text (PDF via unpdf, DOCX via mammoth, MD/TXT passthrough)
file_path{itemId}/{filename} in Supabase Storage
metadataExtended with page_count, tables, temporal_references, extraction_failed
expiry_dateFrom regex date extraction (if high/medium confidence)
lifecycle_type'date_bound' (if expiry date found)
updated_byAuthenticated user ID

AI processing updates (via service client):

ColumnValue / Source
embeddingOpenAI text-embedding-3-large (1024d) via lib/ai/embed
primary_domainVia classifyContent()
primary_subtopicVia classifyContent()
secondary_domainVia classifyContent()
secondary_subtopicVia classifyContent()
classification_confidenceVia classifyContent()
classified_atVia classifyContent()
suggested_titleVia classifyContent()
summaryVia classifyContent() + generateSummary()
ai_keywordsVia classifyContent()
classification_reasoningVia classifyContent()
summary_dataVia generateSummary()
quality_scoreVia calculateAndRoundQualityScore()
quality_score_updated_atTimestamp
source_document_idFrom source_documents table insert
layerVia inferLayer() (direct column .update())
  1. Auth + role checkgetAuthorisedClient(['admin', 'editor'])
  2. Validate file — Size (max 50 MB), MIME type, magic bytes
  3. Detect re-uploaddetect_reupload RPC (filename + user + content hash)
  4. Create pipeline_run — Progress tracking record
  5. INSERT content_item — Empty content, basic metadata
  6. Upload to storage — Supabase Storage documents bucket
  7. Create source_document — Lineage tracking (version, parent, storage path)
  8. Link source_document — UPDATE source_document_id on content item
  9. Extract text — PDF (unpdf), DOCX (mammoth), MD/TXT (passthrough)
  10. Date extractionextractTemporalReferences(), findExpiryDate(), extractDates() (regex)
  11. UPDATE content_item — Extracted text, file_path, temporal refs, expiry date
  12. Update source_document — Extracted text, extraction metadata
  13. Content history — INSERT version 1
  14. EmbedgenerateEmbedding() via OpenAI
  15. Dedup checkcheckForDuplicates() (informational warning only)
  16. ClassifyclassifyContent() (entities, relationships, temporal refs via AI)
  17. SummarisegenerateSummary()
  18. Quality scorecalculateAndRoundQualityScore()
  19. Layer inferenceinferLayer() + direct column .update()
  20. Topic suggestionsuggestTopic() + merge_item_metadata RPC
  21. Guide section suggestionsuggestGuideSections() (returned in response, not stored)
  22. Mark source_document processed — Status update
  23. Diff computation — For re-uploads: computeDocumentDiff() + analyseDocumentImpact() + notifications
  24. Complete pipeline_run — Final status update
  • All processing steps are present — this is the most complete entry point

Maximum file size: 50 MB. No explicit text truncation after extraction.


NameURL Ingest API
TriggerPOST /api/ingest/url (JSON body with url)
Source fileapp/api/ingest/url/route.tsPOST() (line 12)
LanguageTypeScript
Auth requirementAuthenticated user with admin or editor role
ColumnValue / Source
titleExtracted from page (or "Imported from {domain}")
contentExtracted page content via extractFromUrl()
content_typeRequest body content_type or detectContentType(url)
platform'web'
source_urlInput URL
source_domainParsed hostname
author_nameExtracted from page
thumbnail_urlOG image from page
captured_datenew Date().toISOString()
created_byAuthenticated user ID
user_tagsFrom request body user_tags
embeddingOpenAI text-embedding-3-large (1024d)
metadata{ingestion_source: 'url_import', extraction_method, page_count, og_description}

Post-insert via classifyContent() + generateSummary():

ColumnValue / Source
primary_domainVia classifyContent()
primary_subtopicVia classifyContent()
secondary_domainVia classifyContent()
secondary_subtopicVia classifyContent()
classification_confidenceVia classifyContent()
classified_atVia classifyContent()
suggested_titleVia classifyContent()
summaryVia classifyContent() + generateSummary()
ai_keywordsVia classifyContent()
classification_reasoningVia classifyContent()
summary_dataVia generateSummary()
layerVia inferLayer() (direct column .update())
updated_byVia classifyContent()
  1. Auth + role checkgetAuthorisedClient(['admin', 'editor'])
  2. Rate limit — 10 requests/minute per user
  3. Validate body — Zod schema IngestUrlBodySchema
  4. SSRF validationvalidateUrl() blocks internal/private IPs
  5. Existing URL check — Queries content_items for matching source_url (returns early if found)
  6. Extract contentextractFromUrl() (Jina Reader or Readability fallback)
  7. Quality check — Reject if < 100 chars; warn if < 500 chars
  8. Detect content type — From request body or URL pattern
  9. EmbedgenerateEmbedding() via OpenAI
  10. Dedup checkcheckForDuplicates() (informational warning only)
  11. INSERT content_item — Single insert with embedding
  12. Content history — INSERT version 1
  13. ClassifyclassifyContent() (entities, relationships, temporal refs via AI)
  14. SummarisegenerateSummary()
  15. Layer inferenceinferLayer() + direct column .update()
  16. Topic suggestionsuggestTopic() + merge_item_metadata RPC
  17. Guide section suggestionsuggestGuideSections() (returned in response, not stored)
  18. Fetch final state — Re-query for response
  • No source document tracking

Rejects pages with < 100 characters of extracted text (HTTP 422).


NameManual Item Creation API
TriggerPOST /api/items (JSON body)
Source fileapp/api/items/route.tsPOST() (line 18)
LanguageTypeScript
Auth requirementAuthenticated user with admin or editor role
ColumnValue / Source
titleRequest body
contentRequest body (may contain HTML)
content_typeRequest body
suggested_titleSame as title
platform'manual'
captured_datenew Date().toISOString()
created_byAuthenticated user ID
metadata{ingestion_source: ingestion_source ?? 'manual'}
primary_domainRequest body (optional)
primary_subtopicRequest body (optional)
secondary_domainRequest body (optional)
secondary_subtopicRequest body (optional)
priorityRequest body (optional)
user_tagsRequest body (optional)
ai_keywordsRequest body (optional)
author_nameRequest body (optional)
source_urlRequest body (optional)
briefRequest body (optional)
detailRequest body (optional)
referenceRequest body (optional)
embeddingOpenAI (if auto_embed=true)
governance_review_statusRequest body (optional)
source_document_idRequest body (optional)

Post-insert AI processing (conditional on auto_classify / auto_summarise):

ColumnValue / Source
primary_domainVia classifyContent() (if auto_classify)
primary_subtopicVia classifyContent() (if auto_classify)
secondary_domainVia classifyContent() (if auto_classify)
secondary_subtopicVia classifyContent() (if auto_classify)
classification_confidenceVia classifyContent() (if auto_classify)
classified_atVia classifyContent() (if auto_classify)
suggested_titleVia classifyContent() (if auto_classify)
summaryVia classifyContent() / generateSummary()
ai_keywordsVia classifyContent() (if auto_classify)
classification_reasoningVia classifyContent() (if auto_classify)
summary_dataVia generateSummary() (if auto_summarise)
layerVia inferLayer() (always)
quality_scoreVia calculateAndRoundQualityScore()
quality_score_updated_atTimestamp
updated_byVia classifyContent()
  1. Auth + role checkgetAuthorisedClient(['admin', 'editor'])
  2. Rate limit — 20 requests/minute per user
  3. Validate body — Zod schema ItemCreateBodySchema
  4. EmbedgenerateEmbedding() (if auto_embed=true)
  5. Dedup checkcheckForDuplicates() (informational warning only)
  6. INSERT content_item — Single insert with all provided fields + embedding
  7. Content history — INSERT version 1 (includes brief, detail, reference)
  8. ClassifyclassifyContent() (if auto_classify=true) + pipeline_runs logging
  9. SummarisegenerateSummary() (if auto_summarise=true) + pipeline_runs logging
  10. Layer inferenceinferLayer() (always runs)
  11. Quality scorecalculateAndRoundQualityScore()
  12. Topic suggestionsuggestTopic() + merge_item_metadata RPC
  13. Guide section suggestionsuggestGuideSections() (returned in response, not stored)
  • No source document tracking (though source_document_id can be passed)
  • No date extraction (regex-based)
  • Classification, summary, and embedding are all optional (controlled by auto_classify, auto_summarise, auto_embed flags)

Content field max: 500,000 characters (Zod validation). Title max: 500 characters.


NameBatch Item Creation API
TriggerPOST /api/items/batch (JSON body with items array)
Source fileapp/api/items/batch/route.tsPOST() (line 69)
LanguageTypeScript
Auth requirementAuthenticated user with admin or editor role
ColumnValue / Source
titleFrom item in array (truncated at word boundary to 120 chars by caller)
contentFrom item in array (Q: {question}\n\nA: {answer} format)
content_type'q_a_pair'
platform'extraction'
suggested_titleSame as title
captured_datenew Date().toISOString()
created_byAuthenticated user ID
metadata{ingestion_source: 'upload_autosplit', autosplit_batch_id, section_name, detection_source, detection_confidence}
source_document_idFrom request body (optional, shared across all items)
answer_advancedFrom item in array (optional)

Post-insert AI processing (per item):

ColumnValue / Source
embeddingOpenAI text-embedding-3-large (1024d)
primary_domainVia classifyContent()
primary_subtopicVia classifyContent()
secondary_domainVia classifyContent()
secondary_subtopicVia classifyContent()
classification_confidenceVia classifyContent()
classified_atVia classifyContent()
suggested_titleVia classifyContent()
summaryVia classifyContent() + generateSummary()
ai_keywordsVia classifyContent()
classification_reasoningVia classifyContent()
summary_dataVia generateSummary()
layerVia inferLayer() (direct column .update())
quality_scoreVia calculateAndRoundQualityScore()
quality_score_updated_atTimestamp

Processing Pipeline (per item, sequential)

Section titled “Processing Pipeline (per item, sequential)”
  1. Auth + role checkgetAuthorisedClient(['admin', 'editor'])
  2. Validate body — Zod schema (max 100 items per batch)
  3. Batch token enforcement — SHA-256 hash checked against pipeline_runs to prevent re-submission
  4. Create pipeline_runqa_autosplit progress tracking
  5. INSERT content_item — Via service client (bypasses RLS)
  6. Content history — INSERT version 1
  7. EmbedgenerateEmbedding() via OpenAI
  8. ClassifyclassifyContent() (entities, relationships, temporal refs via AI)
  9. SummarisegenerateSummary()
  10. Layer inferenceinferLayer() + direct column .update()
  11. Topic suggestionsuggestTopic() + merge_item_metadata RPC
  12. Quality scorecalculateAndRoundQualityScore()
  13. Update pipeline_run progress — After each item
  • No dedup check (items created sequentially to avoid race conditions, but no explicit dedup)
  • No guide section suggestion
  • No date extraction (regex-based)
  • No source document creation (only links via source_document_id if provided)
  • No markdown chunking (GAP). app/api/items/batch/route.ts does not invoke regenerateChunks(). Items created via this path (Q&A autosplit, S116 workflow) have content_items rows but no content_chunks rows. S167 Plan C wired chunking into single-item routes but batch was missed — flag for a follow-up WP. Backfill via scripts/backfill-chunks.ts.

Content field max: 500,000 characters per item (Zod validation). Title max: 500 characters. Max 100 items per batch.


NameBatch Reclassification + Entity Extraction
TriggerCLI: bun run scripts/batch-reclassify.ts --execute
Source filescripts/batch-reclassify.ts (line 411, main function)
LanguageTypeScript (Bun)
Auth requirementNone (uses SUPABASE_SECRET_KEY directly)

Fields Updated (not INSERT — UPDATE only)

Section titled “Fields Updated (not INSERT — UPDATE only)”
ColumnValue / Source
primary_domainFrom Claude Sonnet classification
primary_subtopicFrom Claude Sonnet classification
secondary_domainFrom Claude Sonnet classification
secondary_subtopicFrom Claude Sonnet classification
ai_keywordsFrom Claude Sonnet classification
summaryFrom Claude Sonnet classification
suggested_titleFrom Claude Sonnet classification
classification_confidenceFrom Claude Sonnet classification
classification_reasoningFrom Claude Sonnet classification
classified_atnew Date().toISOString()
embeddingRegenerated via OpenAI (using updated suggested_title + content)
  1. Load taxonomy — Fetches taxonomy_domains + taxonomy_subtopics from DB
  2. Fetch items — Queries content_items with optional domain filter, ordered by content_type priority
  3. Data quality report — Identifies garbled keywords, editorial notes, duplicate titles (dry-run report)
  4. For each item: a. Classify — Direct Anthropic API call with return_classification_with_entities tool use b. Canonicalise entitiescanonicalise() from lib/entities/entity-dedup c. UPDATE content_item — Classification fields + regenerated embedding (skip if --entities-only) d. Delete + re-insert entity_mentions — Clean slate per item e. Delete + re-insert entity_relationships — Clean slate per item
  • Does not create new items — only updates existing ones
  • No content history versioning
  • No quality score calculation
  • No topic suggestion
  • No guide section suggestion
  • No summary generation (only sets summary from classification output, not structured summary_data)
  • No source document tracking
  • No date extraction (regex-based)

Content is truncated for the classification prompt (first ~8000 tokens passed to Claude). The summary field from classification is a 1-2 sentence summary (20-50 words).


NameQ&A Import (Bid Library)
TriggerCLI: python3 scripts/import_bid_library.py <dir>
Source filescripts/import_bid_library.pybuild_content_record() (line 209) + main() (line 281)
LanguagePython
Auth requirementNone (uses SUPABASE_SECRET_KEY directly)
ColumnValue / Source
titleQuestion text truncated at word boundary to 120 chars
content"Q: {question}\n\n{answer_standard}\n{answer_advanced}"
answer_standardFrom DOCX extraction
answer_advancedFrom DOCX extraction
content_type'q_a_pair'
platform'extraction'
source_url'' (empty string)
source_domain'' (empty string)
primary_domainFrom keyword classifier
primary_subtopicFrom keyword classifier
secondary_domainFrom keyword classifier
secondary_subtopicFrom keyword classifier
classification_confidenceFrom keyword classifier
summaryAnswer text truncated at word boundary to 200 chars
ai_keywordsDeterministic keyword extraction (domain + subtopic + frequent words, 3-5 items)
source_fileFrom DOCX extraction (source_file field)
user_tagsFrom --batch-tag CLI flag
metadata{section_name, table_index, row_index, has_standard, has_advanced, import_batch, has_tracked_changes}
embeddingOpenAI text-embedding-3-large (1024d) (unless --skip-embed)
  1. Find .docx files — Non-recursive directory scan
  2. Detect Track Changeshas_tracked_changes() on each file
  3. Extract Q&A pairsextract_qa_from_docx() (tables, lists, headings)
  4. Exact dedup — MD5 of normalised question text
  5. Near-duplicate detection — Similarity scoring (flagged, not removed)
  6. Classify — Keyword-based classifier (keyword_classifier.py) — no AI
  7. Quality validation — Empty content and fragment detection
  8. Idempotency check — Queries existing content_items by title match (unless --force)
  9. Embedbuild_embedding_text() + generate_embedding() via OpenAI (unless --skip-embed)
  10. Storeinsert_content_item(record) — single INSERT per pair
  • No AI classification (uses keyword classifier only)
  • No entity extraction (unless --entities flag is used)
  • No relationship extraction (unless --entities flag is used)
  • No temporal reference storage
  • No AI summary generation (only truncated answer text)
  • No content history versioning
  • No quality score calculation
  • No source document tracking
  • No created_by / updated_by
  • No topic suggestion
  • No guide section suggestion
  • No date extraction (regex-based)

Title: truncated at word boundary to 120 characters. summary: truncated at word boundary to 200 characters.


NameMCP create_content_item Tool
TriggerMCP tool call create_content_item via Claude Desktop / Claude.ai
Source filelib/mcp/tools/content.ts — tool registration at line 113
LanguageTypeScript
Auth requirementMCP OAuth bearer token with admin or editor role
ColumnValue / Source
titleTool input title
suggested_titleSame as title
contentTool input content
content_typeTool input content_type (enum validated)
platform'manual'
captured_datenew Date().toISOString()
created_byMCP user ID
primary_domainTool input (optional)
primary_subtopicTool input (optional)
priorityTool input (optional)
embeddingOpenAI (skip for drafts) — uses title + ' ' + content.slice(0, 5000)
governance_review_status'draft' if specified
source_urlTool input (optional, S205 WP-A1) — typed column, was metadata blob
source_fileTool input (optional, S205 WP-A1) — typed column, was metadata blob
source_document_idTool input (optional, S205 WP-A1) — FK to source_documents.id
metadata{batch_tag} (if provided); legacy source_document removed S205 WP-A1

Post-insert AI processing (skip for drafts):

ColumnValue / Source
layerVia inferLayer()
primary_domainVia classifyContent()
primary_subtopicVia classifyContent()
secondary_domainVia classifyContent()
secondary_subtopicVia classifyContent()
classification_confidenceVia classifyContent()
classified_atVia classifyContent()
suggested_titleVia classifyContent()
summaryVia classifyContent() + generateSummary()
ai_keywordsVia classifyContent()
classification_reasoningVia classifyContent()
summary_dataVia generateSummary()
  1. Auth + role checkcheckMcpRole(extra.authInfo, ['admin', 'editor'])
  2. EmbedgenerateEmbedding() (skip for drafts — saves API cost)
  3. Build metadatabatch_tag and source_document if provided
  4. INSERT content_item — Single insert
  5. Layer inferenceinferLayer() + UPDATE layer column (skip for drafts)
  6. ClassifyclassifyContent() (skip for drafts)
  7. SummarisegenerateSummary() (skip for drafts)
  8. Guide section suggestionsuggestGuideSections() (returned in response, not stored; skip for drafts)
  • No content history versioning
  • No dedup check
  • No quality score calculation
  • No topic suggestion
  • No date extraction (regex-based)
  • All AI processing skipped for draft items

recordPipelineRun({ pipelineName: 'mcp_create_content_item' }) is emitted once per invocation (S205 WP-A2 / spec §3.2). Status mapping:

  • success → completed
  • partial AI-step failure (any warnings) → completed_with_errors
  • content_items insert failure → failed
  • draft branch → completed with result.skipped_reason='draft'

Embedding input truncated: content.slice(0, 5000). Content field max: 500,000 characters. Title max: 500 characters.


NameBid Outcome KB Integration
TriggerPOST /api/bids/:id/outcome/integrate (JSON body)
Source fileapp/api/procurement/[id]/outcome/integrate/route.tsPOST() (line 22)
LanguageTypeScript
Auth requirementAuthenticated user with admin or editor role
ColumnValue / Source
titleIntegration title or question text (first 200 chars)
suggested_titleSame as title
contentBid response text (HTML)
content_typeIntegration content_type or 'q_a_pair'
platform'extraction'
source_urlnull
embeddingOpenAI text-embedding-3-large (1024d)
primary_domainFrom bid workspace domain_metadata.domain
summary"Response to bid question: {question_text}" (static template, max 200 chars)
captured_datenew Date().toISOString()
created_byAuthenticated user ID
metadata{source_bid_id, source_bid_name, source_question_id, source_question_text, integrated_at}
ColumnValue / Source
contentBid response text
summary"Updated from winning bid response: {question_text}" (static template)
updated_byAuthenticated user ID
updated_atnew Date().toISOString()
embeddingRegenerated (best-effort)
  1. Auth + role checkgetAuthorisedClient(['admin', 'editor'])
  2. Rate limit — 10 requests/minute per user
  3. Validate bid — Must exist and be in won state
  4. Fetch questions + responses — From bid_questions and bid_responses tables
  5. For each integration: a. Skip — If action is skip or no response text b. New entry — Generate embedding, INSERT content_item c. Update existing — UPDATE content + summary, regenerate embedding (best-effort)
  • No AI classification (only sets primary_domain from bid metadata)
  • No entity extraction
  • No relationship extraction
  • No temporal reference storage
  • No AI summary generation (uses static template string)
  • No content history versioning
  • No layer inference
  • No quality score calculation
  • No source document tracking
  • No dedup check
  • No topic suggestion
  • No guide section suggestion
  • No date extraction (regex-based)
  • No classified_at, classification_confidence, classification_reasoning, ai_keywords

Title derived from question text: first 200 characters. summary template: question text truncated to 200 chars (new) or 150 chars (update).

  • No markdown chunking (GAP). app/api/procurement/[id]/outcome/integrate/route.ts does not invoke regenerateChunks(). Content items created by this path have no content_chunks rows. Backfill via scripts/backfill-chunks.ts if these items need to be discoverable via search_content_chunks.

NameRSS / Atom Feed Promotion (Sector Intelligence pipeline)
TriggerCron: app/api/intelligence/run/route.tsrunPipeline()processSource()
Source filelib/intelligence/pipeline.tsstoreAsContentItem() (line 521)
LanguageTypeScript
Auth requirementService-role client (runs under pipeline service user PIPELINE_SYSTEM_USER_ID)

feed_articles rows are inserted for every polled RSS/Atom item and scored by an LLM filter (lib/intelligence/relevance-scorer.ts). Only articles where the filter returns passed = true are promoted to content_items via storeAsContentItem(). The feed_articles.content_item_id column is updated to link the source feed article to the promoted content item. feed_articles.ai_summary (intentionally keeps the ai_ prefix) stores the filter-generated LLM summary of the RSS article — separate column from the content_items.summary written by generateSummary() after promotion.

Fields Written (initial INSERT at line 534)

Section titled “Fields Written (initial INSERT at line 534)”
ColumnValue / Source
titleextraction.title or raw feed item.title
contentextraction.content (full extracted article text via reader / fallback)
content_type'article' initially; may be re-inferred post-classify (see pipeline step 6 below)
source_urlitem.url (resolved redirect URL)
metadata{source: 'intelligence_pipeline', feed_source_id, feed_source_name, published_at, thumbnail_url}

Fields Written (post-classify via classifyContent() + inferLayer() downstream)

Section titled “Fields Written (post-classify via classifyContent() + inferLayer() downstream)”
ColumnValue / Source
primary_domainVia classifyContent()
primary_subtopicVia classifyContent()
secondary_domainVia classifyContent()
secondary_subtopicVia classifyContent()
classification_confidenceVia classifyContent()
classified_atVia classifyContent()
suggested_titleVia classifyContent()
summaryVia classifyContent() + generateSummary() (if called)
ai_keywordsVia classifyContent()
classification_reasoningVia classifyContent()
embeddingVia classifyContent() (OpenAI text-embedding-3-large 1024d)
content_typeRe-inferred via inferContentType() from classification result (if not 'article')

Fields Written (side-table INSERT at line 616)

Section titled “Fields Written (side-table INSERT at line 616)”
TableColumns set
content_item_workspacesworkspace_id = feed source’s workspace, content_item_id
feed_articlescontent_item_id UPDATE (line 557)
  1. Cron poll + dedup — upstream in runPipeline() / processSource() (lib/intelligence/pipeline.ts); upserts feed_articles with normalised URL deduplication (code 23505 no-op).
  2. LLM filterlib/intelligence/relevance-scorer.ts scores each new article; only passed = true rows progress to storeAsContentItem().
  3. INSERT content_item — single INSERT without classified_at / primary_domain etc. (line 534).
  4. Link feed_article — UPDATE feed_articles.content_item_id (line 557, by workspace_id + normalised external_url).
  5. ClassifyclassifyContent({ force: true, userId: PIPELINE_SYSTEM_USER_ID }) (line 564). Generates entities, relationships, temporal refs, embedding.
  6. Content-type inference — re-reads classified fields; if still 'article', calls inferContentType() and UPDATEs content_type if the inferred type is more specific (line 585).
  7. Workspace assignment — INSERT into content_item_workspaces (line 616).
  • No suggestedTitle / UI-visible fallback title logic; relies on extraction.title -> feed item.title.
  • No quality score, layer inference, topic suggestion, guide section suggestion, source document tracking, or content history versioning at this entry point.
  • No markdown chunking (GAP). storeAsContentItem() does not invoke regenerateChunks(). Content items promoted from RSS feeds have no content_chunks rows; they are discoverable via item-level semantic search but not via search_content_chunks. Backfill via scripts/backfill-chunks.ts.
  • No date extraction (regex-based); relies on AI temporal extraction only.
  • No created_by / updated_by (service-role run; PIPELINE_SYSTEM_USER_ID is used as the userId argument to classifyContent but not written to the content_items row).

No explicit truncation. extraction.content is full body text from the feed’s enclosed article. LLM filter (step 2) and classification (step 5) each apply their own 5,000-char input truncation downstream.


NameMarkdown Batch Ingest UI (EP2 §1.11; admin/editor drag-and-drop)
TriggerUI: /item/new “Upload file” tab → multi-file .md drop → phase=analyse then phase=import POST
Source fileapp/api/ingest/markdown/route.ts + lib/ingest/markdown-orchestrator.ts
LanguageTypeScript
Auth requirementgetAuthorisedClient(['admin', 'editor']) (spec §5.3 D-1)

The route is a dedicated EP2 surface (Liam D-D9, S199): EP3 /api/upload/route.ts (maxDuration=60) is untouched; this route runs at maxDuration=300 to accommodate batch imports of up to 10 markdown files. Two-phase POST on the same endpoint via the multipart phase field:

  • phase=analyse — read-only pre-flight. Returns { analysis: MarkdownIngestAnalysis[] } per file (front-matter parse, title extraction, diff-marker scan, dedupVerdict.{isDuplicate, existingId, existingTitle} from checkExactDuplicate, sourceFileMatch from filename collision query). NO DB writes.
  • phase=import — full pipeline. Orchestrator opens a pipeline_runs row via startPipelineRun() (Pattern E Step 1), per-file loop with mid-flight updatePipelineProgress() UPDATEs at each file boundary (Pattern E Step 2), terminal UPDATE via finaliseRun() using createServiceClient() internally (chokepoint per S213 fix — admin-only RLS on pipeline_runs required service-role client; closed S214 by adding admin UPDATE/DELETE policies, but service-role pattern retained for cron / orchestrator parity).

The UI fires GET /api/pipeline-runs/:id every 1–2 s in parallel during the import POST so users see progress (per spec §7.2 Pattern E framing).

ColumnValue / Source
titleextractMarkdownTitle() priority chain: frontMatter.title > body H1 > bold-after-Article-N > filename
contentCleaned body via cleanMdxTags() (PascalCase tag stripping; Python parity)
content_type'article' initially; may be re-inferred post-classify
source_fileOriginal filename
ingest_source'upload' (per S205-S207 schema widening; spec §3.4)
content_owner_idResolved via resolveContentOwnerId({ explicit, role, userId })
publication_statusPer D-A: draft_or_final='draft' | 'unknown' → 'draft'; 'final' → 'in_review'
governance_review_statusNULL on insert (publication-management event, not governance event; spec §9.1)
dedup_status'clean' for unique; 'suspected_duplicate' for soft-block matches
metadataIncludes front_matter_fields, title_provenance, diff_markers, original_filename
primary_domain, primary_subtopic, secondary_domain, secondary_subtopic, classification_confidence, classified_at, suggested_title, summary, ai_keywords, classification_reasoning, embeddingVia classifyContent() post-insert
  • content_text_hashGENERATED ALWAYS (Postgres auto-computes; CLAUDE.md gotcha).
  • source_url — N/A for markdown file ingest.
  • source_document_id — markdown files don’t go through source_documents (no PDF/DOCX storage path).
  • quality_score, lifecycle_type, expiry_date — set by downstream cron (cron/quality-score, cron/freshness-transitions).
  1. Auth + multipart parse + per-file validation (app/api/ingest/markdown/route.ts:90-194) — file count ≤10, per-file size ≤1 MB (early-return 413 on first violation), total batch ≤5 MB, .md extension only (mixed batch → 400, all-non-.md → 415), UTF-8 only via TextDecoder('utf-8', { fatal: true }) (415 on decode failure BEFORE orchestrator runs).
  2. Phase routingphase='analyse' → orchestrator returns MarkdownIngestAnalysis[] with no DB writes; phase='import' → full pipeline.
  3. pipeline_runs row UPSERT (producer-side, Pattern 2 caller-allocated UUID) with pipeline_name='upload_markdown_batch', status='running', progress.detail='Queued N file(s); awaiting worker claim…'. The route .upsert(..., { onConflict: 'id', ignoreDuplicates: true }) against the service-role client (app/api/ingest/markdown/route.ts:295-319) — compiles to PostgreSQL ON CONFLICT (id) DO NOTHING. Mirrors the worker-side Path B UPSERT in lib/pipeline/start-run.ts:142-145 for symmetry: when AC-3 (same-day re-enqueue with stable pipeline_run_id) or AC-4 (next-day re-enqueue) POSTs the same UUID twice, neither side clobbers the other — the worker’s terminal UPDATE handles status. Pattern E preservation per §5.4.4 D-10 + D-11 ratified.
  4. Per-file loop (with mid-flight updatePipelineProgress after each file) — extract title + clean MDX tags + dedup pre-check + INSERT content_items (with dedup_status stamped from pre-check) → classifyContent()regenerateChunks() → record outcome to stored[] and conditionally to dedup_flagged[] (subset relationship — see spec §5.4 dedup_flagged note).
  5. pipeline_runs terminal UPDATE via finaliseRun() (service-role client) — sets status='completed' \| 'completed_with_errors' \| 'failed', result=results_summary, completed_at.
FieldDescription
skip_dedupPer-file boolean in per_file_overrides[]. Admin-only (editor request silently ignored). When true on admin request, dedup soft-block bypassed; dedup_status='clean'.
auto_supersedeAdmin-only batch-wide flag (forwards as no-op until orchestrator wires setSupersession post-EP2; reserved for §1.18 Python parity follow-on).
excludedPer-file boolean to skip a file from import (any role). Listed in metadata.results_summary.skipped_excluded.
draft_or_final overridePer-file override for the filename heuristic; values 'draft' | 'final' | 'unknown'.
  • No source_documents linkage. Markdown files don’t carry binary storage; no row inserted into source_documents. (Contrast with EP3 PDF/DOCX upload which always creates a source_documents row + FK.)
  • No re-upload / version detection. detect_reupload RPC is not invoked because there’s no canonical filename versioning convention for markdown (auto-supersede heuristic via filename DRAFT/final pairs is the rough equivalent and is opt-in).
  • No parent_id chain. Each markdown file is a leaf; supersession is the explicit relationship.

No batch-level content truncation. MAX_EMBEDDING_CHARS = 24_000 applies inside classifyContent() for the embedding step (TS+Python parity per S168). Classification input is similarly truncated at ~8,000 tokens inside the LLM call.

pipeline_runs.pipeline_name='upload_markdown_batch'. Lifecycle uses the Pattern E primitives (startPipelineRun() + updatePipelineProgress() + finaliseRun()); contrast with EP3 single-file which uses recordPipelineRun() as a one-shot terminal write — no opId is passed, so the helper takes the plain-insert path (see Appendix G for the opId-carrying terminal-transition path the cocoindex webhook route uses to keep one pipeline_runs row per invocation, Inv-16).


Appendix A: “Complete” Content Item Definition

Section titled “Appendix A: “Complete” Content Item Definition”

Every field a fully-processed content item should have. “Fully-processed” means the item has been through the most complete pipeline (File Upload API, entry point 3).

FieldExpected typeHow populatedRequired/OptionalDefault
iduuidAuto-generated (PK)Requiredgen_random_uuid()
titletextSet on creationRequired
contenttextSet on creation / extractedRequired
source_urltextFrom source / null for uploadsOptionalnull
source_domainvarcharParsed from URLOptionalnull
thumbnail_urltextOG image extractionOptionalnull
content_typevarcharSet on creation (CHECK constraint)Required
platformvarcharSet on creation (CHECK constraint)Optionalnull
parent_iduuidManual linkageOptionalnull
author_namevarcharExtracted or user-providedOptionalnull
metadatajsonbSet on creation, extended during processingOptionalnull
primary_domainvarcharAI classificationOptionalnull
primary_subtopicvarcharAI classificationOptionalnull
secondary_domainvarcharAI classificationOptionalnull
secondary_subtopicvarcharAI classificationOptionalnull
classification_confidencenumericAI classification (0.0-1.0)Optionalnull
classified_attimestamptzSet during classificationOptionalnull
suggested_titletextAI classificationOptionalnull
classification_reasoningtextAI classificationOptionalnull
summarytextAI classification or summary generationOptionalnull
ai_keywordstext[]AI classificationOptionalnull
embeddingvector(1024)OpenAI text-embedding-3-largeOptionalnull
summary_datajsonbAI summary generation (executive, detailed, takeaways)Optionalnull
user_tagstext[]User-providedOptionalnull
priorityvarcharUser-provided (high/medium/low)Optionalnull
file_pathtextUpload pipeline (Supabase Storage path)Optionalnull
captured_datetimestamptzExtraction or creation timeOptionalnull
created_attimestamptzAuto-generatedRequirednow()
updated_attimestamptzAuto-updated on changesOptionalnull
created_byuuidAuthenticated user IDOptionalnull
updated_byuuidLast modifierOptionalnull
brieftextManual (progressive depth layer)Optionalnull
detailtextManual (progressive depth layer)Optionalnull
referencetextManual (progressive depth layer)Optionalnull
source_documenttextManual (originating document name)Optionalnull
source_biduuidBid workspace linkageOptionalnull
freshnessvarcharComputed by governance cronOptional'fresh'
previous_freshnessvarcharSet on freshness transitionOptionalnull
freshness_checked_attimestamptzGovernance cronOptionalnull
lifecycle_typevarcharDate extraction or manualOptional'evergreen'
expiry_datetimestamptzRegex date extraction or manualOptionalnull
verified_attimestamptzSME verificationOptionalnull
verified_byuuidSME who verifiedOptionalnull
governance_review_statustextGovernance workflowOptionalnull
governance_review_duetimestamptzGovernance workflowOptionalnull
governance_reviewer_iduuidGovernance workflowOptionalnull
answer_standardtextQ&A pair standard answerOptionalnull
answer_advancedtextQ&A pair advanced answerOptionalnull
archived_attimestamptzSoft-deleteOptionalnull
archived_byuuidArchiverOptionalnull
archive_reasontextReason for archivingOptionalnull
content_owner_iduuidOwnership assignmentOptionalnull
source_document_iduuidFK to source_documentsOptionalnull
notestextFree-form notesOptionalnull
quality_scoreintcalculateAndRoundQualityScore() (0-100)Optionalnull
quality_score_updated_attimestamptzSet during quality calculationOptionalnull
previous_quality_scoreintStored before quality updateOptionalnull
citation_countintTrigger-maintainedRequired0
source_filetextPython ingestion (promoted from metadata)Optionalnull
layervarcharinferLayer() or manualOptionalnull
starredbooleanUser toggle via toggle_star() RPCRequiredfalse
content_text_hashtextDedup detectionOptionalnull

Added S136 per the pipeline-parity spec, Phase 2.

This section defines the canonical set of processing steps that constitute a “full pipeline” for content ingestion, and records which steps each entry point implements. It serves as the reference for future pipeline changes and the basis for the automated drift detection test (Phase 3).

Every entry point that performs AI classification should implement all 19 required steps. Entry points that skip AI classification entirely (e.g. Q&A Import with keyword classifier, Bid Outcome KB Integration) are exempt from classification-dependent steps.

#Step nameDescription
1content_extractionExtract text from source (HTML, URL, .md, .docx)
2content_truncationTruncate to 5,000 chars for classification input
3classificationAI classification via Claude
4taxonomy_validationValidate domain/subtopic against taxonomy
5keyword_normalisationNormalise AI keywords (proper nouns, singular form)
6entity_extractionExtract entities (AI, optionally keyword-based)
7entity_canonicalisationNormalise entity names (12-rule canonicalise)
8entity_alias_resolutionResolve entity aliases from DB
9entity_exclusion_filteringFilter out non-entity identifiers (SIC codes, VAT, etc.)
10entity_context_extractionExtract surrounding text snippet for each entity
11entity_storageUpsert into entity_mentions
12relationship_extractionExtract entity relationships (AI)
13relationship_storageInsert into entity_relationships
14temporal_reference_extractionExtract temporal references (AI)
15temporal_reference_storageStore in content_items.metadata
16temporal_entity_bridgeBridge temporal refs to entity mention metadata
17embedding_generationGenerate vector embedding (OpenAI)
18deduplicationCheck for duplicates (URL and/or embedding similarity)
19layer_inferenceSuggest content layer (7-rule deterministic)

These steps are not required for parity. Some are intentionally limited to one pipeline.

#Step nameImplemented byNotes
20summary_generationBothRenamed S164b from ai_summary_generation; writes content_items.summary + summary_data.
21regex_date_extractionTS onlySupplementary to AI extraction; low priority for Python port
22temporal_reconciliationTS onlyDepends on regex date extraction
23quality_score_calculationTS onlyIntentionally TS-only (depends on web app fields)
24content_history_versioningTS onlyIntentionally TS-only (CLI creates, doesn’t update)
25keyword_entity_extractionPython onlyIntentionally Python-only (compensates for different prompt strategy)
26markdown_chunkingBoth (with gaps)Added S166–168 (Plan A/B/C). Split the canonical markdown body at H2 headings into content_chunks rows. TS: lib/content/chunk-store.ts regenerateChunks(). Python: scripts/kb_pipeline/chunk.py store_chunks(). Currently NOT invoked by Entry Points 2 (Python MD), 6 (Batch Items), 10 (Bid Outcome), 11 (RSS). Items ingested via those paths are not findable via search_content_chunks.
27chunk_embedding_generationBoth (with gaps)Per-chunk OpenAI text-embedding-3-large (1024d) with heading-path prefix. Coupled with step 26 — runs iff markdown_chunking runs.

Each cell shows whether the entry point implements the canonical step. Legend:

  • Y = Yes, implemented
  • N = Not implemented (gap)
  • ~ = Partial or conditional (see notes)
  • n/a = Not applicable (entry point does not perform AI classification)
#StepPython URLPython MDQ&A ImportURL Ingest APIFile Upload APIManual Create APIBatch ReclassMCP Create
1content_extractionYYY (DOCX)Y (URL)Y (PDF/DOCX/MD)n/a (user provides)n/a (existing)n/a (user provides)
2content_truncationY (5,000 + "...")Y (5,000 + "...")n/aY (5,000)Y (5,000)~ (if auto_classify)Y (~8,000 tokens)~ (if not draft)
3classificationY (Opus)Y (Opus)~ (keyword only)Y (Sonnet)Y (Sonnet)~ (if auto_classify)Y (Sonnet)~ (if not draft)
4taxonomy_validation~ (warn only)~ (warn only)n/aY (auto-correct)Y (auto-correct)~ (if auto_classify)Y (auto-correct)~ (if not draft)
5keyword_normalisationYYn/a (deterministic keywords)YY~ (if auto_classify)Y~ (if not draft)
6entity_extractionY (AI + keyword)Y (AI + keyword)~ (--entities flag)Y (AI)Y (AI)~ (if auto_classify)Y (AI)~ (if not draft)
7entity_canonicalisationYY~ (via --entities)YY~ (if auto_classify)Y~ (if not draft)
8entity_alias_resolutionYY~ (via --entities)YY~ (if auto_classify)Y~ (if not draft)
9entity_exclusion_filteringYY~ (via --entities)YY~ (if auto_classify)Y~ (if not draft)
10entity_context_extractionNNNYY~ (if auto_classify)Y~ (if not draft)
11entity_storageYY~ (--entities flag)YY~ (if auto_classify)Y~ (if not draft)
12relationship_extractionYY~ (--entities flag)YY~ (if auto_classify)Y~ (if not draft)
13relationship_storageYY~ (--entities flag)YY~ (if auto_classify)Y~ (if not draft)
14temporal_reference_extractionYYNYY~ (if auto_classify)Y~ (if not draft)
15temporal_reference_storageYYNYY~ (if auto_classify)Y~ (if not draft)
16temporal_entity_bridgeYYNYY~ (if auto_classify)Y~ (if not draft)
17embedding_generationYY~ (--skip-embed)YY~ (if auto_embed)Y~ (skip drafts)
18deduplicationY (URL + embed)Y (embed only)Y (MD5 + idempotency)Y (URL + embed)Y (informational)~ (if auto_embed)NN
19layer_inferenceYYYYYYY~ (skip drafts)
26markdown_chunkingY (S167 Plan C)N (GAP)N (intentional — atomic)Y (S167 Plan C)Y (S167 Plan C)Y (S167 Plan C)N (update-only; chunks stale after reclassify — use backfill-chunks.ts)Y (S167 Plan C, skip drafts)
27chunk_embedding_generationYN (step 26 gap)n/aYYYNY (skip drafts)

S174 Additional Entry-Point Chunking Coverage

Section titled “S174 Additional Entry-Point Chunking Coverage”

Per-entry-point chunking coverage for the entry points not in the matrix above:

Entry pointChunking invoked?Note
6. Batch Item Creation APIN (GAP)app/api/items/batch/route.ts never calls regenerateChunks(). S167 wired single-item routes but batch was missed. Backfill script available.
10. Bid Outcome KB IntegrationN (GAP)app/api/procurement/[id]/outcome/integrate/route.ts never calls regenerateChunks(). Lower priority (won-bid responses are surfaced via bid workspace UI, not chunk search).
11. RSS Feed PromotionN (GAP)lib/intelligence/pipeline.ts storeAsContentItem() never calls regenerateChunks(). All SI-ingested articles rely on item-level search only.
12. Markdown Batch Ingest UIY (S211)lib/ingest/markdown-orchestrator.ts:674 calls regenerateChunks(supabase, inserted.id, cleanedBody) after every successful insert. Closes the markdown_chunking gap for the UI markdown path (the Python EP2 path remains gapped — see §1.18 roadmap entry for the parity follow-on).
GapAffected entry pointsSeverityPhase
entity_context_extraction missing in PythonPython URL, Python MD, Q&A ImportMediumNot yet planned
taxonomy_validation warn-only in PythonPython URL, Python MDLowNot yet planned
content_truncation ellipsis divergencePython URL, Python MD (append "...")Very lowCosmetic
deduplication missing in batch reclassifyBatch ReclassNone (intentional — operates on existing items)n/a
deduplication missing in MCP createMCP CreateLowNot yet planned
Q&A Import skips most stepsQ&A ImportNone (intentional — keyword classifier)See Appendix C
markdown_chunking not invokedPython MD (EP 2), Batch Items (EP 6), Bid Outcome (EP 10), RSS (EP 11)Medium — items not findable via search_content_chunksNot yet planned
markdown_chunking stale after reclassifyBatch Reclass (EP 7)Medium — chunks and embedding diverge from new classificationFix via backfill-chunks.ts post-reclassify

The following constants must match between TS and Python pipelines. These are verified by the drift detection test (Phase 3).

ConstantTS locationPython locationCurrent value
Classification truncation limitlib/ai/classify.ts (inline 5000)kb_pipeline/classify.py build_user_prompt() (inline 5000)5,000 chars
Entity types (12)lib/ai/classify.ts tool schemakb_pipeline/classify.py VALID_ENTITY_TYPESorganisation, certification, regulation, framework, capability, person, technology, project, sector, product, standard, methodology
Temporal entity typeslib/entities/entity-metadata-bridge.ts TEMPORAL_ENTITY_TYPESkb_pipeline/temporal_bridge.pycertification, framework, regulation
Excluded entity patterns (5)lib/ai/classify.ts EXCLUDED_PATTERNSkb_pipeline/classify.py _EXCLUDED_PATTERNSSIC Code, VAT, DUNS, numeric, VAT format
Layer inference rules (7)lib/layer-inference.tskb_pipeline/layer_inference.py7 rules, same priority order
Layer keys (4)lib/client-config.tskb_pipeline/layer_inference.pysales_brief, bid_detail, company_reference, research
Canonicalisation rules (12)lib/entities/entity-dedup.tskb_pipeline/classify.py12 rules, same order
Proper noun allowlist (19)lib/validation/schemas.ts TAG_PROPER_NOUN_ALLOWLISTkb_pipeline/classify.py PROPER_NOUN_ALLOWLIST19 entries, same set
Abbreviations lookup (39)lib/entities/entity-dedup.ts ABBREVIATIONSkb_pipeline/classify.py _ABBREVIATIONS39 entries, matching

Appendix C: Processing Pipeline Comparison

Section titled “Appendix C: Processing Pipeline Comparison”

Rows represent processing steps. Columns represent entry points. Marks indicate whether the step is performed.

Processing step1. Python URL2. Python MD3. File Upload4. URL Ingest5. Manual Create6. Batch Create7. Batch Reclass8. Q&A Import9. MCP Create10. Bid Outcome
Text extractionYesYes (file read)Yes (PDF/DOCX/MD)Yes (URL fetch)No (user provides)No (user provides)No (existing)Yes (DOCX tables)No (user provides)No (existing response)
SSRF validationNoNoNoYesNoNoNoNoNoNo
URL dedup checkYesNoNoYes (existing URL)NoNoNoNoNoNo
Embedding dedup checkYesYesYes (informational)Yes (informational)Yes (informational)NoNoNoNoNo
Exact dedup (MD5)NoNoNoNoNoNoNoYesNoNo
File upload to storageNoNoYesNoNoNoNoNoNoNo
Source document trackingNoNoYesNoNoOptionalNoNoNoNo
Re-upload detectionNoNoYesNoNoNoNoNoNoNo
Diff computationNoNoYes (re-uploads)NoNoNoNoNoNoNo
AI classificationYes (Python)Yes (Python)Yes (TS shared)Yes (TS shared)OptionalYes (TS shared)Yes (Anthropic direct)No (keyword only)Yes (TS shared)No
Entity extractionYesYesYes (via classify)Yes (via classify)OptionalYes (via classify)YesOptional (--entities)Yes (via classify)No
Relationship extractionYesYesYes (via classify)Yes (via classify)OptionalYes (via classify)YesOptional (--entities)Yes (via classify)No
Temporal references (AI)YesYesYes (via classify)Yes (via classify)OptionalYes (via classify)YesNoYes (via classify)No
Temporal-to-entity bridgeYesYesYes (via classify)Yes (via classify)OptionalYes (via classify)YesNoYes (via classify)No
Date extraction (regex)NoNoYesYesNoNoNoNoNoNo
Embedding generationYesYesYesYesOptionalYesYes (regenerated)OptionalYes (skip drafts)Yes
AI summary generationYesYesYesYesOptionalYesNo (classification only)NoYes (skip drafts)No
Layer inferenceYes (S134)Yes (S134)YesYesYesYesYesYes (S134)Yes (skip drafts)No
Quality scoreNoNoYesYesYesYesNoNoNoNo
Topic suggestionNoNoYesYesYesYesNoNoNoNo
Guide section suggestionNoNoYes (response only)Yes (response only)Yes (response only)NoNoNoYes (response only)No
Content history v1Yes (S153)Yes (S153)YesYesYesYesNoNoNoNo
Quality loggingYesYesNoNoNoNoNoNoNoNo
Pipeline run trackingNoNoYesNoYes (classify/summarise)YesNoNoNoNo
Batch token enforcementNoNoNoNoNoYesNoNoNoNo
Rate limitingNoNoNoYes (10/min)Yes (20/min)NoNoNoNoYes (10/min)
created_by setNoNoYesYesYesYesNo (update only)NoYesYes
updated_by setNoNoYesYes (via classify)Yes (via classify)NoNoNoNoYes (update action)
Markdown chunkingYes (S167)No (GAP)Yes (S167)Yes (S167)Yes (S167)No (GAP)No (update-only)n/a (atomic Q&A)Yes (skip drafts)No (GAP)
Chunk embedding generationYesNoYesYesYesNoNon/aYes (skip drafts)No

Note (S174): Entry Point 11 (RSS Feed Promotion) is not represented as a column in this comparison table. Its profile mirrors Entry Point 4 (URL Ingest API) for AI classification, embedding, and entity extraction (all “Yes via classifyContent()”), but No for date extraction (regex), AI summary generation (generateSummary() not called from storeAsContentItem()), layer inference, quality score, topic suggestion, content history, content chunking, and created_by / updated_by. See §11 above for the authoritative entry-point detail and Appendix B “S174 Additional Entry-Point Chunking Coverage” for chunking gap status.


Appendix D: Canonical content_history.change_reason Values

Section titled “Appendix D: Canonical content_history.change_reason Values”

Added in S152B WP3 (migration 20260407220000_add_content_history_change_reason.sql), wired across all write paths in S153. The column is free-text (no CHECK constraint) but every production write path uses one of the values below. See supabase/migrations/20260407220000_add_content_history_change_reason.sql COMMENT for background on the WHY/WHAT/HOW-categorised distinction between change_reason, change_summary, and change_type.

ValueWritten byMeaning
initial_ingesttrg_content_items_ensure_v1_history trigger (S207 WP-A4) — fires when content_items.ingest_source IS NOT NULL (Option D §4.4). Pre-WP-A4: written app-side from ingest/url, upload, items/batch, items POST, MCP create_content_item, Python URL + Markdown pipelines (S153).First version created by any ingest path. S207 WP-A4 (28/04/2026): v1 history is now trigger-derived from content_items.ingest_source (typed column). Explicit app-level v1 writes were deleted in plan Task 3.4 — the trigger is the SOLE authority. Granular per-path observability lives on content_history.metadata->>'ingest_source' (one of the 11 values in Appendix F).
auto_v1_on_inserttrg_content_items_ensure_v1_history trigger fallback branch — fires when content_items.ingest_source IS NULL (legacy/back-compat).Fallback for rows where ingest_source was never populated (pre-WP-A4 legacy rows post-backfill, or future writes that omit the typed column).
reclassify/api/items/[id]/classify, classification_quality cron (future wiring)Classification pipeline re-run
entity_enrichmentEntity-mention / relationship backfill scripts (future wiring)Entity or relationship data updated
template_coverage_refreshTemplate coverage job (future wiring)New version produced by template coverage refresh
source_document_acceptedSource-document workflow (future wiring)Accepted a diff from an uploaded source document
owner_changeapp/api/items/[id]/owner PATCHContent owner reassigned
rollback_to_v<N>app/api/items/[id]/rollback POSTVersion rollback from item detail UI
archivelib/mcp/tools/governance.ts (soft-delete branch)Item archived via MCP governance tool
hard_deletelib/mcp/tools/governance.ts (hard-delete branch)Item hard-deleted via MCP governance tool
status_change_publishlib/mcp/tools/governance.ts (status-change branch)Draft → live promotion via MCP governance tool
status_change_draftlib/mcp/tools/governance.ts (status-change branch)Live → draft demotion via MCP governance tool
bulk_approveapp/api/review/publication-bulk-action/route.ts (action=‘approve’)§5.3 publication approval gate — bulk 'in_review' → 'published' transition. Distinct from per-row PATCH 'Transition from in_review to published' so audit queries can filter bulk-vs-singleton approvals. S220 W2.
bulk_return_to_draftapp/api/review/publication-bulk-action/route.ts (action=‘return_to_draft’)§5.3 publication approval gate — bulk 'in_review' → 'draft' transition. Audit-trail counterpart to bulk_approve. S220 W2.
rollback_legacyMigration backfill only (historical change_type='rollback' rows where we lack the target version)Not written by any live path — backfill-only value
backfill_owner_assign_wp_a3Migration 20260428145733_backfill_content_owner_id_from_created_by.sql (S206)One-time backfill: content_owner_id auto-assigned from created_by for human-authored content_items (WP-A3)
NULLapp/api/items/[id] PATCH when admin UI “Why change?” field is emptyAcceptable when a caller cannot or does not supply a reason

S207 WP-A4 contract (28/04/2026): change_reason for v1 rows is trigger-derived from content_items.ingest_source (not an app-level literal); explicit app-level v1 writes are no longer accepted (the inverted guard at __tests__/validation/content-items-v1-history-guard.test.ts enforces absence across lib/, app/, scripts/). Per spec §3.4 AC4.9 + plan Task 4.2.

Adding a new value: update this table, the canonical list in the migration COMMENT, the TS call site comment, and __tests__/api/items.test.ts regression tests. No DB constraint change is required — the column is intentionally free-text.


Appendix E: Operational data writes (one-shot SQL, not driven by an entry point)

Section titled “Appendix E: Operational data writes (one-shot SQL, not driven by an entry point)”

This appendix records mass DML writes that happen outside the canonical entry-point pipeline — typically migrations or backfill scripts that touch production data once. Each entry must record date, target table+column, row count, and rationale so the write is navigable from the audit trail.

DateOperationTable.columnRowsRationale
27/04/2026S200 WP5 §5.5 Phase 1 review-cadence backfillcontent_items.review_cadence_days + content_items.next_review_date440Three cohorts per spec §18.1 OQ-1.2: regulatory/compliance (24 @ 365d), Q&A (395 @ 180d), methodology/case-study (21 @ 365d). Articles/blogs/research not backfilled. WHERE review_cadence_days IS NULL predicate preserved any owner-set values (pre-snapshot was 0 owner-set rows; post-snapshot match expected).
28/04/2026S205 WP-A1 Phase 1 metadata→typed source-column backfillcontent_items.source_url + content_items.source_file (from metadata->>'source_document')23 (prod) / 0 (staging)Migration 20260428131822_backfill_metadata_source_document_to_typed_columns.sql. Disambiguation rule: values matching ^https?:// (case-insensitive) copy to source_url; non-URL (file paths, opaque tokens) copy to source_file. Idempotent (WHERE … IS NULL clauses). Pre-flight on prod rovrymhhffssilaftdwd: 23 rows had metadata.source_document set, all 23 were filenames → source_file, 0 URLs. Legacy JSONB key preserved per AC1.4 for rollback. Pairs with EP9 typed-column writes.
28/04/2026S207 WP-A4 Phase 3 ingest_source backfillcontent_items.ingest_source23 (prod) / 0 (staging)Migration 20260428180945_backfill_ingest_source.sql. Maps pre-WP-A4 rows from metadata->>'ingestion_source' (legacy JSONB key) onto the typed column using the canonical 11-value vocabulary in Appendix F. Idempotent (WHERE ingest_source IS NULL). Legacy JSONB key preserved for rollback. Required so the rewritten ensure_v1_history_at_commit() trigger emits change_reason='initial_ingest' (vs the legacy 'auto_v1_on_insert' fallback) on subsequent re-versions of pre-existing rows.

Appendix F: Canonical content_items.ingest_source Values

Section titled “Appendix F: Canonical content_items.ingest_source Values”

Added in S207 WP-A4 (migration 20260428174512_add_ingest_source_to_content_items.sql). The column is typed text with no DB CHECK constraint applied in S207 (CHECK deferred to a future phase per spec §10.4 — NOT VALID + VALIDATE CONSTRAINT to avoid table-rewriting locks on prod). Every production INSERT-time write site maps to one of the 10 canonical values below; the 11th value ('batch_reclassify') is reserved for an EP7 UPDATE path and is intentionally NOT written by any INSERT-time wire today.

The DB trigger trg_content_items_ensure_v1_history reads NEW.ingest_source and emits content_history.change_reason='initial_ingest' when non-NULL, falling back to 'auto_v1_on_insert' when NULL (legacy/back-compat). Granular per-path observability lives on content_history.metadata->>'ingest_source'. See docs/specs/ingest-path-consistency-spec.md §3.4 + §4.4.

ValueINSERT-time write siteMaps to entry point
manualapp/api/items/route.ts:163 (web-form POST default; ingestion_source body field overrides)EP3
url_importapp/api/ingest/url/route.ts:179EP4
uploadapp/api/upload/route.ts:344EP5
upload_autosplitapp/api/items/batch/route.ts:256EP6 (autosplit branch)
mcp_createlib/mcp/tools/content.ts:491 (MCP create_content_item tool)EP9
rss_feedlib/intelligence/pipeline.ts:653 (RSS feed promotion)EP11
bid_outcome_integrationapp/api/procurement/[id]/outcome/integrate/route.ts:220EP10
python_urlscripts/kb_pipeline/pipeline.py (Python URL ingest pipeline)EP1
python_markdownscripts/ingest_markdown.py + scripts/ingest_stage2_markdown.py (Python Markdown ingest pipelines)EP2
qa_importscripts/import_bid_library.py (Q&A pair imports from .docx)EP8
batch_reclassifyReserved — for an EP7 UPDATE path. NOT written by any INSERT-time wire today (out of scope per S207 plan §3.3).EP7 (deferred, UPDATE-only)

Adding a new value: widen any TS literal-union type referencing the canonical set (feedback_db_check_ts_union_paired_widening), update this table, update the COMMENT on the column in the migration that introduced the value, and add a wire-site test under __tests__/api/ or __tests__/mcp/. If a CHECK constraint is later applied, the new value MUST be added to the constraint expression and the migration paired with bunx tsc --noEmit verification.


Appendix G: Canonical pipeline_runs.pipeline_name Values

Section titled “Appendix G: Canonical pipeline_runs.pipeline_name Values”

Single source of truth for the pipeline_name column on pipeline_runs. All callers of recordPipelineRun() (lib/pipeline/record-run.ts) MUST use one of the values below. The column is free-text (no DB CHECK) but every production write path uses one of these labels — keeping the set small is what makes /admin/pipeline-runs and Sentry alert routing tractable.

The Python ingest pipelines do not flow through recordPipelineRun() — they write to pipeline_runs directly via scripts/kb_pipeline/pipeline_log.py (start_run()/finish_run()). Their pipeline_name value ('ingest') is listed below for completeness.

ValueWritten byPurpose
mcp_create_content_itemlib/mcp/tools/content.ts (S205 WP-A2)MCP create_content_item tool invocation audit (success, validation-fail, and error branches all log)
publish_classifyapp/api/items/[id]/route.ts (PATCH publish branch) + lib/mcp/tools/governance.ts (status-change path)Draft → live promotion classification audit
background_classifyapp/api/items/route.ts (POST after item creation)Background classification job kicked off post item insert
background_summariseapp/api/items/route.ts (POST after item creation)Background summarisation job kicked off post item insert
quality_scoreapp/api/cron/quality-score/route.tsDaily KB quality-score recompute cron
freshness_transitionsapp/api/cron/freshness-transitions/route.tsDaily content freshness-state transition cron
content_gapsapp/api/cron/content-gaps/route.tsDaily content-gap detection cron
classification_qualityapp/api/cron/classification-quality/route.tsDaily classification-quality eval cron
coverage_alertapp/api/cron/coverage-alerts/route.tsDaily template-coverage alert cron
review_cadenceapp/api/cron/review-cadence/route.ts (S200 WP5 §5.5 Phase 2)Daily review-cadence scheduling cron (writes one run per phase: schedule, transitions, alerts)
taxonomy_syncapp/api/admin/taxonomy-sync/route.ts (constant PIPELINE_NAME)Admin-triggered taxonomy sync dispatch + GitHub Actions status
provenance_audit_pdfapp/api/admin/provenance/export/verification-history/route.tsProvenance audit PDF export run
ingestscripts/kb_pipeline/pipeline_log.py (start_run('ingest'))Python URL + Markdown ingest pipelines (does not flow through recordPipelineRun())

Adding a new value: add a new row above, search for the literal in lib/, app/, and scripts/ to confirm the wire-site, and prefer reusing an existing value when the new caller is a peer of an existing one (e.g. another cron of the same kind). Per feedback_record_pipeline_run_signature, the helper’s terminal status set is 'completed' | 'completed_with_errors' | 'failed' | 'cancelled'; it also accepts 'in_progress' as a lifecycle status for the cocoindex flow-start emission (ID-28.11 FX-1) — a terminal status carrying an opId transitions the invocation’s existing in_progress row to the terminal state (Inv-16: one row per invocation; Inv-12: op_id resolves to exactly one row) rather than inserting a sibling. Non-cocoindex callers (crons, MCP tools, queue telemetry) omit opId and keep plain-insert semantics.

Live-code-verified drift sweep against kh_code_sources. The doc’s own DRIFT WARNING banner at the top already documents the Phase 0 investigation findings; this section adds confirmations against the current main of the public repo. Counts by category:

  • [path] 8 cited source files no longer exist on main: scripts/kb_pipeline/ pipeline.py, scripts/kb_pipeline/classify.py, scripts/kb_pipeline/chunk.py (whole scripts/kb_pipeline/ dir retired — ID-46), scripts/ingest.py, scripts/ingest_markdown.py, scripts/import_bid_library.py, scripts/backfill-chunks.ts, lib/ingest/markdown-orchestrator.ts, and scripts/batch-reclassify.ts (moved to lib/queue/handlers/batch-reclassify.ts). Affects §1, §2, §7, §8, §12 and Quick Comparison Matrix rows 1, 2, 7, 8.
  • [route] app/api/intelligence/run/route.ts (§11 trigger) does not exist; the live cron route is app/api/cron/intelligence-poll/route.ts (banner already flags this).
  • [route] app/api/ingest/markdown/route.ts (§12) does not exist; live ingest routes are app/api/ingest/url/route.ts and app/api/ingest/folder-drop/route.ts.
  • [lib] lib/intelligence/pipeline.ts exports processFeedSource and runPipeline (no processSource or storeAsContentItem symbols); §11 cites storeAsContentItem() at line 521 and processSource() in the trigger semantics — both stale (banner already flags processSource).
  • [route] §10 trigger line still reads POST /api/bids/:id/outcome/integrate; live route is app/api/procurement/[id]/outcome/integrate/route.ts (the Source file column was updated in S248 but the trigger line was not).