RFP Builder Developer Guide - edmozley/freeitsm GitHub Wiki
User page: Contracts - RFP Builder · pages contracts/rfp-builder/ · endpoints api/rfp-builder/ · helpers includes/rfp_ai.php, includes/rfp_scope.php, includes/rfp_docx_parser.php
An RFP starts as a pile of Word documents, one per department, each saying what that department wants from a new system. The RFP Builder reads the text out of each document, asks an AI to list every requirement in it, then asks the AI once more to merge the lists from every department into one set: sorted into categories, given a priority, with contradictions flagged. The analyst tidies that set by hand and locks it. Only then does the AI write the document - an introduction, scope and response instructions, plus one section per category - which the analyst edits and prints. Invited suppliers are then scored 0-5 against every requirement, and a compare page ranks them.
Everything below an RFP belongs to that RFP and follows its company. The AI settings (provider, model, key) are the RFP Builder's own, not the shared AI Providers panel.
| File | Job |
|---|---|
contracts/rfp-builder/index.php |
The RFP list, with New / Edit / Delete (get_rfps, get_rfp, save_rfp, delete_rfp) |
contracts/rfp-builder/view.php |
One RFP's overview: phase tiles linking to the pages below, and the AI activity panel (get_processing_log) |
contracts/rfp-builder/documents.php |
Upload, view text, re-extract, run AI extraction on, and delete the source .docx files |
contracts/rfp-builder/extracted.php |
Every extracted requirement across the RFP's documents, filter by department and type, edit and delete |
contracts/rfp-builder/consolidate.php |
The consolidation workbench (1,712 lines) - see consolidate.php |
contracts/rfp-builder/coverage.php |
Category x department heatmap (get_coverage) |
contracts/rfp-builder/document.php |
The generated document: framing sections, per-category sections, Generate all, TinyMCE edit, version history, restyle |
contracts/rfp-builder/preview.php |
Print-friendly render of the whole document, built server-side with no API call. PDF is the browser's print |
contracts/rfp-builder/suppliers.php |
Invite existing suppliers, create a prospective one inline, demo dates and notes |
contracts/rfp-builder/scoring.php |
Score one supplier at a time, 0-5 per requirement, autosave, radar chart |
contracts/rfp-builder/compare.php |
Read-only cross-supplier compare: ranked cards, radar overlay, category winners (get_compare_data) |
contracts/rfp-builder/audit.php |
The full, filterable rfp_processing_log for one RFP |
contracts/rfp-builder/help.php |
Static in-app help |
contracts/rfp-builder/uploads/ |
Where uploaded .docx files are stored - see Uploads |
includes/rfp_ai.php |
Every AI call: settings, the HTTP calls, streaming, the prompts, the log |
includes/rfp_scope.php |
Company scoping: rfpGate(), rfpCanView(), rfpListSql() and friends |
includes/rfp_docx_parser.php |
rfpExtractDocxText() - plain text out of a .docx |
contracts/settings/index.php |
The two settings tabs, RFP departments and RFP AI |
tests/rfp-companies.php |
Fails if an endpoint is added without the company gate - see Company scoping |
Every page calls requireModuleAccess('contracts'). Pages fetch from api/rfp-builder/ through a API_BASE constant set to '../../api/rfp-builder/'.
The code's comments number the AI calls as passes; the user page numbers the phases the analyst sees. Both are below.
-
Create the RFP -
save_rfp.php. Status starts atdraft. The allowed statuses aredraft,collecting,consolidating,generating,scoring,closed,abandoned. -
Upload (
documents.php->upload_document.php) -.docxonly, 20 MB max (RFP_UPLOAD_MAX_BYTES). Each document can carry anrfp_departmentsid. The file is saved, arfp_documentsrow is written with statusuploaded, and text extraction runs straight away. -
Text extraction -
rfpExtractDocxText()turns the .docx into flat text intorfp_documents.raw_text, statusextracted. A failure leaves the upload in place with statuserror;extract_document.phpre-runs extraction on the stored file. -
Pass 1 - AI extraction, per document (
extract_requirements.php->rfpAiExtractRequirements()). It deletes that document's earlier extracted rows first, then inserts onerfp_extracted_requirementsrow per item: text, type (requirement,pain_pointorchallenge- anything else becomesrequirement), source quote, confidence clamped to 0-1. Document status becomesprocessed. This is a plain request, not a stream. -
Pass 2 - consolidation, one call for the whole RFP (
consolidate.php->run_consolidation.php->rfpAiConsolidateStreaming()). The AI returns one JSON object withcategories(asked for 8 to 20),consolidated_requirements(each with a priority,critical/high/medium/low, a rationale and thesource_extracted_idsit came from) andconflicts. Only after the stream finishes does one transaction delete the RFP's consolidated requirements and categories and write the new ones, therfp_consolidated_sourceslinks (source ids not in the RFP are ignored) andrfp_conflictsrows with resolutionopen. Status moves fromdraft/collectingtoconsolidating. -
Hand editing on
consolidate.php- edit, add, split, merge, delete, resolve conflicts. -
Lock -
lock_consolidation.phpsetsis_lockedon every consolidated row of the RFP (or clears it) and moves the status togenerating(or back toconsolidating). -
Pass 3 - section per category (
document.php->generate_section.php->rfpAiGenerateSectionStreaming()), HTML intorfp_output_sections. -
Pass 5 - framing (
generate_framing.php->rfpAiGenerateFramingStreaming()):introduction,scopeandresponse_instructions(RFP_AI_FRAMING_KEYS) intorfp_document_sections. It sends the RFP name, the analyst's context note (rfps.framing_context_text, saved byupdate_framing_context.php) and the category list - not the requirements. -
Pass 4 - restyle (
restyle_section.php,restyle_framing.php->rfpAiRestyleStreaming()): re-applies the style guide to existing HTML without changing its meaning. -
Output -
preview.phprenders framing then category sections for printing. There is no .docx or PDF writer; "export" is the browser's print. -
Suppliers and scoring -
invite_supplier.php/create_prospective_supplier.phpintorfp_invited_suppliers;save_score.phpupsertsrfp_scores;get_scores.phpandget_compare_data.phproll them up.
Steps 8 to 10 refuse while any consolidated requirement of the RFP is unlocked - the check is in each of the four endpoints, answered as a stream error. The two restyle endpoints also refuse when there are no consolidated rows at all; the two generate endpoints do not count that case as unlocked.
rfpAiGetSettings() reads four system_settings rows: rfp_ai_provider, rfp_ai_api_key (encrypted, listed in includes/encryption.php), rfp_ai_model and rfp_ai_verify_ssl. The provider must be one of RFP_AI_VALID_PROVIDERS = ['anthropic', 'openai']; a missing provider, key or model throws a RuntimeException telling the analyst to set it under Contracts -> Settings -> RFP AI.
This is separate from AiProvider in includes/ai_provider.php. That file's header says its request shapes mirror rfp_ai.php and that it is "intentionally independent so the RFP builder isn't affected". Azure, OpenRouter and OpenAI-compatible servers are not available here. AI Providers says the same from the other side.
Settings are written by save_ai_settings.php, read (key masked) by get_ai_settings.php and tried by test_ai_connection.php, which sends a tiny prompt with its own rfpTestAnthropic() / rfpTestOpenAI(). The same tab saves rfp_default_style_guide.
| Function | What it does |
|---|---|
rfpAiCall() |
One non-streaming request, Anthropic or OpenAI by setting. Defaults max_tokens 4096, temperature 0.0 |
rfpAiCallAnthropic() / rfpAiCallOpenAI()
|
The two request shapes. Anthropic marks the system prompt for caching |
rfpAiHttpPostWithRetry() |
cURL POST, retries 429, 5xx and network errors up to RFP_AI_RETRY_MAX (3) with backoff from RFP_AI_RETRY_BACKOFF_MS (2000 ms, doubling); timeout RFP_AI_HTTP_TIMEOUT (120 s) |
rfpAiCallAnthropicStreaming() |
Streaming request to Anthropic. Calls $onEvent('text', {delta}) and $onEvent('usage', {...}) as data arrives. One attempt, no retry - once text has gone to the browser it cannot be replayed. No overall timeout; it gives up after 60 s with no bytes |
rfpAiExtractRequirements() |
Pass 1, via rfpAiCall(), max_tokens 8192 |
rfpAiConsolidate() / rfpAiConsolidateStreaming()
|
Pass 2, max_tokens 16000. Only the streaming one has a caller (run_consolidation.php) |
rfpAiGenerateSectionStreaming() |
Pass 3. Loads the category's consolidated requirements (ordered by priority) and the departments behind each one through rfp_consolidated_sources
|
rfpAiGenerateFramingStreaming() |
Pass 5, one framing key at a time |
rfpAiRestyleStreaming() |
Pass 4 |
rfpAiResolveStyleGuide() |
rfps.style_guide, else system_settings.rfp_default_style_guide, else RFP_AI_FALLBACK_STYLE_GUIDE
|
rfpAiLogAction() |
One rfp_processing_log row per call, success or error |
Every helper strips a stray ```json fence before json_decode(), and Pass 2 refuses a reply missing any of its three keys.
rfpAiCallAnthropicStreaming() throws "Streaming is currently only supported with the Anthropic provider." for any other provider. Consolidation, generation, framing and restyle all stream, so with openai saved only Pass 1 extraction (and Test) works - everything from consolidation on stops with that error.
rfpAiCallAnthropicStreaming() takes an optional $settingsOverride so another feature can stream on its own key. api/forms/ai_generate.php, api/knowledge/writeup_stream.php and api/tickets/ai_cleanup_reply.php call it this way, and api/tickets/ai_merge_summary.php requires the file. Changing its signature or event shape changes those features too.
rfp_ai_verify_ssl is still saved and read, but every cURL call goes through sslApplyCurl(), which follows the global switch alone - see SSL Verification Global Setting. The parameter comments on rfpAiHttpPostWithRetry() and the test functions say so.
The five streaming endpoints are run_consolidation.php, generate_section.php, generate_framing.php, restyle_section.php and restyle_framing.php. Each:
- is a GET (the browser's
EventSourcecannot POST), with ids in the query string; - calls
csrfRequireToken()because it writes -assets/js/csrf.jsadds?_csrf=to everyEventSourceURL (CSRF Protection); - opens with
session_start(['read_and_close' => true]), turns off output buffering, sendsX-Accel-Buffering: nofor nginx, andset_time_limit(0); - checks module access with
analystCanAccessModule()and the RFP withrfpCanView(), answering both as anerrorevent, becauserequireModuleAccessJson()orrfpGate()would print JSON into the stream; - sends
phase,text,usage, thencompleteorerrorthrough a localsse()function.generate_section.phpcan also sendskipped.
The pages close the EventSource on complete and on error.
generate_section.php hashes the inputs (category name and description, each requirement's id, text, type, priority and departments, and the style guide) and sends skipped without calling the AI when the hash equals rfp_output_sections.requirements_hash, force is not set and the section is not manually edited. A manually edited section is therefore always regenerated - the old content goes to rfp_section_history first.
rfp_default_style_guide, while the stored hash comes from rfpAiGenerateSectionStreaming(), which uses rfpAiResolveStyleGuide(). When an RFP has no guide of its own but a system default is saved, the two hashes never match and the skip never fires. Keep the two in step if either changes.
generate_framing.php reads inputs_hash but its skip block is empty: framing always calls the AI and saves.
rfp_output_sections.version goes up on every generate, manual edit (update_section.php), restyle and restore (restore_section_version.php), and the previous content is copied into rfp_section_history each time. Manual edits and restores set is_manually_edited = 1; generate and restyle set it to 0. Framing (rfp_document_sections) has no history - a restyle or regenerate overwrites it.
The biggest page in the module. It loads everything from get_consolidated.php (loadAll(), render()), shows requirements grouped by category with their source quotes (toggleSources()), the AI rationale, and a conflicts panel.
| UI | Function | Endpoint |
|---|---|---|
| Run consolidation (stream modal) | runConsolidation() |
run_consolidation.php |
| Edit / add custom |
openEditModal(), openAddModal(), saveEdit()
|
update_consolidated.php, add_consolidated.php
|
| Delete | deleteRow() |
delete_consolidated.php |
| Split one into several |
openSplitModal(), saveSplit()
|
split_consolidated.php |
| Merge selected rows |
onSelectRow(), openMergeModal(), saveMerge()
|
merge_consolidated.php (rfpGate('consolidated', 'ids') checks every id) |
| Resolve / reopen a conflict |
openResolveModal(), saveResolve(), reopenConflict()
|
resolve_conflict.php |
| Merge from a conflict | mergeFromConflict() |
merge_consolidated.php - the conflict row goes by cascade |
| Lock / unlock |
toggleLock(), applyLockState()
|
lock_consolidation.php |
While the stream runs, updateProgressTracker() counts "name":, "requirement_text": and "consolidated_a_index": in the partial JSON to show live category, requirement and conflict counts.
Split drops any source id not given to a new row, and deletes the original row's conflicts with it. Add custom sets ai_rationale to "Manually added".
applyLockState() hides the Run button while locked, but run_consolidation.php and the edit, split, merge and delete endpoints do not check is_locked. Only the four generate and restyle endpoints refuse on it.
All created by Database Verification (includes/db_verify_schema.php) and in database/freeitsm.sql.
| Table | Holds |
|---|---|
rfps |
The RFP: tenant_id, name, status, contract_id, chosen_supplier_id, style_guide, framing_context_text, created_by_analyst_id
|
rfp_departments |
Shared department list (name unique, colour, sort_order, is_active) - Contracts -> Settings |
rfp_documents |
One upload: department_id, stored filename, original_filename, file_path, raw_text, status
|
rfp_extracted_requirements |
Pass 1 output: document_id, requirement_text, requirement_type, source_quote, ai_confidence, is_consolidated
|
rfp_categories |
Pass 2 categories, sort_order
|
rfp_consolidated_requirements |
Pass 2 output: category_id, text, type, priority, ai_rationale, is_locked
|
rfp_consolidated_sources |
Which extracted rows fed which consolidated row (unique pair) |
rfp_conflicts |
Two consolidated rows, ai_explanation, resolution, resolution_notes, who and when |
rfp_output_sections |
One per category: section_content HTML, version, is_manually_edited, requirements_hash
|
rfp_section_history |
Earlier versions of an output section |
rfp_document_sections |
Framing: section_key (unique per RFP), content, inputs_hash, is_manually_edited
|
rfp_invited_suppliers |
Supplier on an RFP (unique pair), demo_date, notes
|
rfp_scores |
One per RFP, supplier, analyst and consolidated requirement (unique): score (NULL or 0-5), notes
|
rfp_processing_log |
Every AI call - see Audit |
Every child table cascades from rfps, so delete_rfp.php deletes one row and then unlinks the files it listed first. Cascades to know about:
-
Re-running consolidation deletes the categories and consolidated rows, which cascades to
rfp_output_sections(and their history),rfp_conflictsandrfp_scores. Generated sections and supplier scores do not survive a re-run. -
Re-running Pass 1 on a document deletes its extracted rows, which removes their
rfp_consolidated_sourceslinks. -
rfp_scoresdoes not cascade fromrfp_invited_suppliers, soremove_invited_supplier.phpdeletes the scores itself. - Deleting a supplier (
ContractsService::deleteSupplier()) clearsrfps.chosen_supplier_idand deletes its invitations and scores; deleting a contract clearsrfps.contract_id.
is_consolidated on extracted rows is returned by get_extracted.php but nothing in the RFP Builder sets it.
Covered on the user page. In short, from includes/rfp_scope.php:
-
rfps.tenant_idis scoped data; NULL is the Default company. Children have no company column - they follow their RFP. -
rfpGate($kind, $key)runs straight afterrequireModuleAccessJson('contracts')in every JSON endpoint about an RFP. It reads$keyfrom the query, form or JSON body, finds the RFP withrfpIdOf()(kindsrfp,document,extracted,consolidated,conflict,section,framing,invited) and answers{"success":false,"error":"RFP not found"}for one out of reach - never "forbidden", so ids cannot be probed. A list of ids must all pass. On a single-company install, or beforetenant_idexists (rfpTenantReady()), it does nothing. - The streams use
rfpCanView();get_rfps.phpusesrfpListSql()(the active company, or every reachable one under All companies);preview.phpanswers 404. -
save_rfp.phprefuses a company the analyst cannot reach and a contract in another company. The Default company is stored as NULL. -
rfp_departmentsand suppliers are shared by every company.get_rfp_departments.php, the department endpoints and the AI settings endpoints take no RFP and have no gate. -
ContractsService::assertMovable()refuses to move a contract to another company while an RFP of the old company links to it.
A new endpoint must call the gate - php tests/rfp-companies.php fails otherwise. See Multi-Tenancy Developer Guide.
The RFP Builder has no capability of its own for using it: Contracts module access is the whole gate for RFPs, documents, requirements, sections, suppliers and scores. Two capabilities in includes/capabilities.php cover the settings tabs only:
| Capability | Key | Checked by |
|---|---|---|
Cap::CONTRACTS_RFP_DEPARTMENTS |
contracts.rfp_departments |
save_rfp_department.php, delete_rfp_department.php, the RFP departments tab |
Cap::CONTRACTS_RFP_AI |
contracts.rfp_ai |
get_ai_settings.php, save_ai_settings.php, test_ai_connection.php, the RFP AI tab |
Endpoints check them with requireCapabilityJson(). See Roles Developer Guide.
Scoring is single-blind: get_scores.php returns the analyst's own scores plus other analysts' count and average, never their names or individual scores.
Files go to contracts/rfp-builder/uploads/ as rfp<id>_<time>_<random>_<safe name>.docx; rfp_documents.file_path holds the full path on disk. The folder's .htaccess and web.config deny all web access, deploy/nginx/freeitsm.conf returns 404 for it (Running on nginx), and includes/uploads.php lists it in UPLOAD_HTACCESS_ROOTS. Nothing serves the files back - the text is read from raw_text (get_document_text.php).
rfpExtractDocxText() opens the file with ZipArchive, reads word/document.xml with DOMDocument, and returns paragraphs outside tables as lines, then each table row as cell | cell | cell. Headings, lists, images, comments, tracked changes, headers and footers are dropped.
Without the zip extension new ZipArchive() is a PHP Error, which catch (Exception) does not catch, so the function checks class_exists('ZipArchive') first and throws a plain RuntimeException saying the extension is needed. upload_document.php and extract_document.php catch it and set the document to error. Read the text from a .docx anywhere else through this function, not ZipArchive directly. Background: Docker Image Missing PHP Extensions.
rfp_processing_log is the RFP Builder's audit trail, and it records AI calls only: action (extract, consolidate, generate_section, generate_framing, restyle_section), status (success/error), document_id or section_id, tokens_in, tokens_out, and details - JSON with counts, duration, cache figures and model for a success, the error message for a failure. get_processing_log.php feeds the overview's AI activity panel and audit.php.
Hand edits (editing, splitting, merging, scoring, locking) are not logged anywhere; the record of section content is rfp_section_history.
There is one TRAP: comment in these files:
-
upload_document.php- upload error messages are an array lookup, notmatch(). A file the web server runs directly must parse on old PHP, orincludes/php_version_guard.phpnever gets to say "this needs PHP 8.1" - PHP reads the whole file before running any of it.
Not marked TRAP: but worth knowing, all described above: streaming is Anthropic only; rfpAiCallAnthropicStreaming() is shared with Forms, Knowledge and Tickets; re-running consolidation deletes sections and scores; the lock is only enforced on generation; and the section skip hash differs from the stored one when only a system style guide is set.
- Contracts - the user page
- AI Providers - the shared AI settings the RFP Builder does not use
- CSRF Protection - GET streams that write
- Multi-Tenancy Developer Guide
- Code Traps