repository = new AnswerRails_Knowledge_Repository(); // Vector-store/file plumbing is OpenAI-specific: reach it through the // OpenAI provider (Phase 3's provider-neutral retrieval replaces this). $this->gateway = AnswerRails_Provider_Factory::create('openai')->gateway(); // Link processor will be created separately if (class_exists('AnswerRails_Link_Processor')) { $this->link_processor = new AnswerRails_Link_Processor(); } // Local chunk store: populated at sync time, independent of OpenAI // (RETRIEVAL_RESILIENCE_DESIGN). if (class_exists('AnswerRails_Chunk_Store')) { $this->chunk_store = new AnswerRails_Chunk_Store(); } } /** * Create a new knowledge item * * @param array $data Knowledge item data * @return array|WP_Error Created knowledge item or error */ public function create_knowledge_item(array $data) { // Validate required fields $validation = $this->validate_knowledge_data($data); if (is_wp_error($validation)) { return $validation; } // Check for duplicates if ($this->check_for_duplicate($data)) { return new WP_Error('duplicate_item', 'A knowledge item already exists for this source'); } // #174 E1: the load-bearing registry-membership test. Placed here // so it covers EVERY add route (picker bulk-add, URL form, generic // AJAX, REST, future callers) — UI option lists are presentation // only. $type_registered = $this->source_type_registered($data['knowledge_type'] ?? $data['type'] ?? ''); if (is_wp_error($type_registered)) { return $type_registered; } // Process based on type $processed_data = $this->process_by_type($data); if (is_wp_error($processed_data)) { return $processed_data; } // #73 D7: auto_sync means ONGOING SOURCE TRACKING, which only // source-derived types have. document/json pin their stored copy by // definition — normalise here so the flag can never carry a stale // true on types that ignore it. if (!$this->type_tracks_source($processed_data['knowledge_type'] ?? $processed_data['type'] ?? '')) { $processed_data['auto_sync'] = false; } // Generate link metadata if linkable if (!empty($processed_data['is_linkable'])) { $processed_data = $this->enrich_link_metadata($processed_data); } // Calculate initial checksum $processed_data['checksum'] = $this->calculate_checksum($processed_data['content']); // Calculate file size $processed_data['file_size'] = $this->calculate_content_size($processed_data); // Create in repository $item = $this->repository->create($processed_data); if (is_wp_error($item)) { return $item; } // #73 D7: creation-sync ALWAYS runs — post-#92 chunking is free and // safe in every configuration, and an unsynced item answers nothing, // which is never what "add" means. auto_sync no longer gates this; // it means ongoing tracking only. (A sync failure here is recorded // by the sync path itself — the item still exists, honestly marked.) $this->sync_item($item['id']); // Return the POST-sync state (last_synced/checksum/chunks stamped). return $this->repository->get($item['id']); } /** * Delete a knowledge item * * @param int $id Knowledge item ID * @return bool|WP_Error True on success, error on failure */ public function delete_knowledge_item($id) { // Get item to clean up OpenAI resources $item = $this->repository->get($id); if (!$item) { return new WP_Error('not_found', 'Knowledge item not found'); } // Remove from OpenAI if synced if (!empty($item['openai_file_id'])) { $this->cleanup_openai_resources($item); } // Remove local chunk rows (lexical fallback index) if ($this->chunk_store) { $this->chunk_store->delete_for_item($id); } // Clean up media attachments if (!empty($item['media_attachment_id'])) { wp_delete_attachment($item['media_attachment_id'], true); } // Delete from repository return $this->repository->delete($id); } /** * Get all knowledge items * * @param array $filters Optional filters * @return array Array of knowledge items */ public function get_all_knowledge_items(array $filters = array()) { $defaults = array( 'per_page' => (int) apply_filters('answerrails_knowledge_default_page_size', 50), 'page' => 1, ); AnswerRails_Utils::debug_log('knowledge-service:get-all:start', array( 'filters' => $filters, )); $per_page = $filters['per_page'] ?? ($filters['posts_per_page'] ?? null); if (null === $per_page) { $per_page = $defaults['per_page']; } $per_page = (int) $per_page; if (-1 !== $per_page) { if ($per_page <= 0) { $per_page = $defaults['per_page']; } $max_per_page = (int) apply_filters('answerrails_knowledge_max_page_size', 200); if ($max_per_page > 0) { $per_page = min($per_page, $max_per_page); } $filters['per_page'] = max(1, $per_page); } else { $filters['per_page'] = -1; } unset($filters['posts_per_page']); $page = isset($filters['page']) ? (int) $filters['page'] : null; if (null === $page || $page <= 0) { $filters['page'] = $defaults['page']; } $items = $this->repository->find($filters); AnswerRails_Utils::debug_log('knowledge-service:get-all:complete', array( 'filters' => $filters, 'item_count' => is_array($items) ? count($items) : 0, )); return $items; } /** * Count knowledge items matching filters * * @param array $filters Optional filters * @return int */ public function count_knowledge_items(array $filters = array()) { return $this->repository->count($filters); } /** * Sync a knowledge item (#92: engine-aware — renamed from * sync_with_openai, which became a misnomer once local-only sync * became a supported first-class mode). * * With an OpenAI key + vector store: chunk locally, then upload to the * vector store (unchanged behaviour). Without them: chunking IS the * sync — the item is fully synced for local keyword retrieval, no * gateway call is made, and the result is SUCCESS, not an error. * NOTE: the #92 retrieval OVERRIDE does not change sync behaviour — * sync follows key/store availability only, so the store stays fresh * for switching back. * * #245 D4: every refusal this method can return is RECORDED on the item * (meta `_answerrails_sync_error`) before it reaches the caller, and * every success CLEARS that record in the same write that stamps * last_synced — so "never tried" and "tried and failed" stop looking * identical to a returning operator. The attempt itself is unchanged: * perform_sync() below is the pre-#245 body, verbatim in behaviour. * * @param int $id Knowledge item ID * @return array|WP_Error Updated item or error */ public function sync_item($id) { $result = $this->perform_sync($id); if (is_wp_error($result)) { $this->record_sync_failure($id, $result); } return $result; } /** * Record a failed sync attempt on the item (#245 D4). * * TWO refusals are deliberately NOT recorded, and both are read-time * refusals rather than failed attempts: * - 'not_found' — there is no item to mark. * - 'answerrails_source_type_unsupported' — the #174 downgrade contract * is explicit that freezing is read-time and writes NOTHING to the * repository (pinned: IncentivePartitionTest:: * test_sync_frozen_when_unregistered_touches_nothing). A frozen row * already carries its own "Sync frozen" badge and keeps serving as * last synced; overwriting that with "Sync failed" would replace a * precise state with a vaguer one AND break a merged contract. * * The success side of this record is NOT written here — it is cleared * by the same repository write that stamps last_synced, so a success * can never leave a stale failure behind. * * @param int $id Knowledge item ID * @param WP_Error $error The refusal to record */ private function record_sync_failure($id, $error) { $unrecorded = array('not_found', 'answerrails_source_type_unsupported'); if (in_array($error->get_error_code(), $unrecorded, true)) { return; } $this->repository->update($id, array( 'sync_error' => array( 'code' => $error->get_error_code(), 'message' => $error->get_error_message(), 'at' => current_time('mysql', true), ), )); } /** * The sync attempt itself (#245 D4 split it out of sync_item() so the * failure record has one place to live; behaviour is unchanged). * * @param int $id Knowledge item ID * @return array|WP_Error Updated item or error */ private function perform_sync($id) { // Get item $item = $this->repository->get($id); if (!$item) { return new WP_Error('not_found', 'Knowledge item not found'); } // #174 downgrade contract: rows of a type no longer registered // (pro absent/lapsed, or a third-party registrar deactivated) are // SYNC-FROZEN — no source re-read, no content refresh, typed // refusal. The item keeps serving as last synced (retrieval is // untouched); withdraw/delete stay open; re-registering unfreezes // with no state to migrate (the check is read-time membership). // This single refusal freezes every refresh route: the Sync button, // bulk sync, on_post_updated, and the hourly cron. $type_registered = $this->source_type_registered($item['type'] ?? ''); if (is_wp_error($type_registered)) { return new WP_Error( 'answerrails_source_type_unsupported', __('Sync frozen — this item\'s source type is not registered on this installation. The item continues to be served as last synced.', 'answerrails'), array('status' => 400) ); } // #138: source-derived types re-read their source BEFORE any checksum // is computed — the stored copy is a cache of the source, not the // source. Without this step the checksum comparison below is // tautological (stored content vs stored checksum) and auto-sync // never picks up an edit. document/json skip: their stored copy IS // the source (Manual by design). if ($this->type_tracks_source($item['type'] ?? '')) { $refreshed = $this->refresh_from_source($item); if (is_wp_error($refreshed)) { // #138 D3 stage 1: record the failure (first-failure-wins // clock), then stage 2 once past the grace lag. last_synced // NEVER advances on a failed check — the stamp means // "verified against the source", and we could not verify. $this->record_source_failure($id, $item, $refreshed); $this->maybe_withdraw_item($id); return $refreshed; } $item = $this->apply_source_refresh($id, $item, $refreshed); } // #142 DD5: a declared lookup table whose declaration no longer fits // the content refuses with the typed error — never silently syncs // raw-JSON chunks that the focus-resolution trigger cannot parse. if ('json' === ($item['type'] ?? '') && !empty($item['lookup_table'])) { $declaration_error = $this->lookup_declaration_error( (string) ($item['content'] ?? ''), (string) ($item['lookup_key_field'] ?? ''), (string) ($item['lookup_value_field'] ?? ''), (string) ($item['lookup_path'] ?? '') ); if (is_wp_error($declaration_error)) { return $declaration_error; } } // Check if sync needed $current_checksum = $this->calculate_checksum($item['content']); // Local chunks are (re)built BEFORE and independent of the OpenAI // upload — an OpenAI outage must never leave the lexical index // stale. Self-heals on the unchanged path too (missing/stale rows). $this->ensure_chunks($id, $item, $current_checksum); // #92 local mode: no key or no store -> the chunk rebuild above IS // the sync. Stamp completion and return success. (checksum persisted // so "Synced (local)" is detectable: checksum current + no file id.) if (!$this->gateway->has_api_key() || '' === (string) get_option('answerrails_knowledge_vector_store_id', '')) { return $this->repository->update($id, array( 'last_synced' => current_time('mysql', true), 'checksum' => $current_checksum, 'needs_resync' => false, // #245 D4: success clears the failure record in the same // write that stamps the timestamp (empty array deletes). 'sync_error' => array(), )); } if ($current_checksum === $item['checksum'] && !empty($item['openai_file_id'])) { // Already synced with current content, but update the timestamp // (UTC — #88.4: rendered against a UTC now). $update_data = array( 'last_synced' => current_time('mysql', true), 'sync_error' => array(), // #245 D4: cleared with the stamp. ); $updated_item = $this->repository->update($id, $update_data); return $updated_item; } // Prepare content for upload $file_content = $this->prepare_content_for_openai($item); if (is_wp_error($file_content)) { return $file_content; } // Create temporary file $temp_file = $this->create_temp_file($file_content, $item['title']); if (is_wp_error($temp_file)) { return $temp_file; } // Clean up old file if exists if (!empty($item['openai_file_id'])) { $this->gateway->delete_file($item['openai_file_id']); } // Upload to OpenAI $upload_result = $this->gateway->upload_file($temp_file, 'assistants'); // Clean up temp file wp_delete_file($temp_file); if (is_wp_error($upload_result)) { return $upload_result; } // Add to vector store if configured $vector_store_id = get_option('answerrails_knowledge_vector_store_id'); if ($vector_store_id && $this->gateway->vector_store_exists($vector_store_id)) { $this->gateway->add_file_to_vector_store($vector_store_id, $upload_result['id']); } // Update metadata (last_synced in UTC — #88.4) $update_data = array( 'openai_file_id' => $upload_result['id'], 'last_synced' => current_time('mysql', true), 'checksum' => $current_checksum, 'needs_resync' => false, 'sync_error' => array() // #245 D4: cleared with the stamp. ); return $this->repository->update($id, $update_data); } /** * Re-extract a source-derived item's content from its live source * (#138 D1). Dispatches to the SAME extraction functions the add path * uses — one derivation of "what does this source say" per type. * * @param array $item Knowledge item data. * @return array|WP_Error Refreshed data (add-path shape) or error. */ private function refresh_from_source(array $item) { $type = $item['type'] ?? ''; if ('post' === $type) { return $this->refresh_wordpress_content($item); } // Handler-registered types (product and url are registered by // AnswerRails Pro) own their refresh (#174 E1 companion). $handlers = self::source_handlers(); if (isset($handlers[$type]['refresh']) && is_callable($handlers[$type]['refresh'])) { return call_user_func($handlers[$type]['refresh'], $item); } return $item; // Not source-derived; nothing to refresh. } /** * Re-read a WordPress-post-backed item from its source (#138). Public * API: registered handlers for other post-backed types (pro's product * handler) reuse it and layer their own enrichment on the result. * * @param array $item Stored item data (type, source_post_id, flags). * @return array|WP_Error Freshly extracted data (add-path shape). */ public function refresh_wordpress_content(array $item) { $source_post_id = (int) ($item['source_post_id'] ?? 0); $post = $source_post_id > 0 ? get_post($source_post_id) : null; if (!$post) { return new WP_Error('post_missing', 'Source post no longer exists'); } // Trash/draft/private etc.: the owner has withdrawn the page // from the public site — treat as unavailable (#138 D3), do not // serve refreshed text from a non-public source. if ('publish' !== $post->post_status) { return new WP_Error( 'post_not_public', sprintf('Source post is not published (status: %s)', $post->post_status) ); } return $this->process_wordpress_content(array( 'knowledge_type' => (string) ($item['type'] ?? 'post'), 'source_post_id' => $source_post_id, 'is_linkable' => !empty($item['is_linkable']), 'auto_sync' => !empty($item['auto_sync']), )); } /** * Persist a successful source refresh (#138 D2: full re-derivation — * content, title, source_url, is_external, link metadata, file_size). * Write-free when nothing changed; ALWAYS clears the D3 source-error * flag (a successful check is the recovery signal). * * @param int $id Knowledge item ID. * @param array $item Stored item data. * @param array $refreshed Freshly extracted data (add-path shape). * @return array Item data reflecting the refresh. */ private function apply_source_refresh($id, array $item, array $refreshed) { $updates = array(); // Compare content as it will be STORED (the repository writes // through wp_kses_post) so a kses-normalised source does not read // as "changed" on every sync. if (isset($refreshed['content']) && wp_kses_post((string) $refreshed['content']) !== (string) ($item['content'] ?? '')) { $updates['content'] = $refreshed['content']; $updates['file_size'] = strlen((string) $refreshed['content']); } foreach (array('title', 'source_url') as $field) { if (isset($refreshed[$field]) && '' !== (string) $refreshed[$field] && (string) $refreshed[$field] !== (string) ($item[$field] ?? '')) { $updates[$field] = $refreshed[$field]; } } if (array_key_exists('is_external', $refreshed) && (bool) $refreshed['is_external'] !== (bool) ($item['is_external'] ?? false)) { $updates['is_external'] = (bool) $refreshed['is_external']; } if (isset($updates['title']) && !empty($item['is_linkable'])) { $updates = $this->update_link_metadata($item, $updates); } // Recovery: a successful source read clears the #138 D3 flag // (empty array => the repository deletes the meta). if (!empty($item['source_error'])) { $updates['source_error'] = array(); } if (empty($updates)) { return $item; } $updated = $this->repository->update($id, $updates); return (is_array($updated)) ? $updated : $item; } /** * Record a failed source check (#138 D3 stage 1). The first_failed * clock is FIRST-FAILURE-WINS (the #116 quarantine convention): * subsequent failures update code/detail but never move the clock. * * @param int $id Knowledge item ID. * @param array $item Stored item data (pre-failure state). * @param WP_Error $error The refresh failure. */ private function record_source_failure($id, array $item, WP_Error $error) { $flag = is_array($item['source_error'] ?? null) ? $item['source_error'] : array(); $flag['code'] = (string) $error->get_error_code(); $flag['detail'] = substr((string) $error->get_error_message(), 0, 300); if (empty($flag['first_failed'])) { $flag['first_failed'] = current_time('mysql', true); } $this->repository->update($id, array('source_error' => $flag)); } /** * Withdraw the item from retrieval once a source failure has outlived * the grace lag (#138 D3 stage 2 — timed caller). * * @param int $id Knowledge item ID. */ private function maybe_withdraw_item($id) { $item = $this->repository->get($id); if (!$item) { return; } $flag = $item['source_error'] ?? array(); if (!is_array($flag) || empty($flag['first_failed']) || !empty($flag['withdrawn'])) { return; } $lag = (int) apply_filters('answerrails_source_withdrawal_lag', DAY_IN_SECONDS); $now = strtotime(current_time('mysql', true) . ' UTC'); $first = strtotime($flag['first_failed'] . ' UTC'); if ($first && ($now - $first) > $lag) { $this->withdraw_item($id); } } /** * Withdraw a knowledge item from retrieval (#138 D3 stage 2). PHYSICAL: * the vector engine cannot exclude at query time, so the copies * retrieval reads are removed — chunk rows deleted, OpenAI file removed * from the vector store and deleted. The stored content and all item * configuration are RETAINED (withdrawal is not deletion; the last-good * text stays auditable in View Content). Recovery is automatic: the * item keeps auto_sync, so the next successful source check clears the * flag, rebuilds chunks and re-uploads via the normal sync flow. * * Two callers, one implementation: the lag expiry above and the admin * "Withdraw now" action (deliberate removals skip the lag). * * @param int $id Knowledge item ID. * @return array|WP_Error Updated item or error. */ public function withdraw_item($id) { $item = $this->repository->get($id); if (!$item) { return new WP_Error('not_found', 'Knowledge item not found'); } if (!empty($item['openai_file_id'])) { $this->cleanup_openai_resources($item); } if ($this->chunk_store) { $this->chunk_store->delete_for_item($id); } $flag = is_array($item['source_error'] ?? null) ? $item['source_error'] : array(); if (empty($flag['code'])) { $flag['code'] = 'withdrawn'; } if (empty($flag['first_failed'])) { $flag['first_failed'] = current_time('mysql', true); } $flag['withdrawn'] = true; $flag['withdrawn_time'] = current_time('mysql', true); return $this->repository->update($id, array( 'source_error' => $flag, // Empty string => meta deleted; a recovery sync then takes the // upload branch (no file id) and the "Synced (local)"/early-return // detection stays truthful. 'openai_file_id' => '', )); } /** * Repository accessor (#73: the candidates endpoint batch-checks * in-KB state via source_post_ids_in_kb()). * * @return AnswerRails_Knowledge_Repository */ public function get_repository() { return $this->repository; } /** * The filtered source-type registry (#174 E1, * `answerrails_knowledge_source_types`) — THE membership authority for * every ingestion/refresh decision, the add-form's option list, and * the list table's sync-freeze test. AnswerRails Pro registers * 'product' and 'url'; a third-party plugin may register its own type * (with a handler, below). * * @return string[] */ public static function registered_source_types() { $types = apply_filters('answerrails_knowledge_source_types', self::BUILT_IN_SOURCE_TYPES); return array_values(array_unique(array_filter(array_map('strval', (array) $types)))); } /** * Registered ingestion handlers for non-built-in source types (#174 * E1 companion, `answerrails_knowledge_source_handlers`). Entry shape: * type => {process: callable(array $data), refresh: callable(array * $item), tracks_source: bool}. The flat registry above stays the * membership authority; this map is the dispatch plumbing for types * whose handlers do not ship in the free build. * * @return array */ private static function source_handlers() { $handlers = apply_filters('answerrails_knowledge_source_handlers', array()); return is_array($handlers) ? $handlers : array(); } /** * Whether a knowledge type tracks a live source (#73 D7). The single * eligibility definition for auto_sync semantics and the D6 toggle — * document/json store the source itself, so "tracking" is meaningless. * Handler-registered types declare their own tracking (product/url do). * * @param string $type Knowledge type. * @return bool */ public static function type_tracks_source($type) { if ('post' === (string) $type) { return true; } $handlers = self::source_handlers(); return !empty($handlers[(string) $type]['tracks_source']); } /** * Whether a source type is REGISTERED (#174 E1) — THE single * enforcement authority for source-type ingestion. Every add route * (picker bulk-add, URL form, generic AJAX, REST, future callers) * funnels through create_knowledge_item(), which consults this; the * refresh path consults it too (sync freeze). UI option * lists are presentation only — this method is the gate. * * @param string $type Knowledge type. * @return true|WP_Error True when registered; typed * answerrails_source_type_unsupported otherwise. */ public function source_type_registered($type) { if (in_array((string) $type, self::registered_source_types(), true)) { return true; } return new WP_Error( 'answerrails_source_type_unsupported', __('No handler is registered for this knowledge source type.', 'answerrails'), array('status' => 400) ); } /** * Set an item's sync mode (#73 D6) — THE single mode-mutation * authority; the row action and the bulk action both land here. * * Auto→Manual persists the flag and nothing else: the item keeps its * last-synced content and stops being tracked (this IS the pin). * Manual→Auto persists the flag then syncs IMMEDIATELY — "start * tracking" must mean "verify now", not "wait for the cron". If the * source is gone, the #138 machinery records it (amber flag onward); * the mode change itself still succeeds and the fresh item state is * returned so the caller renders the honest result. * * Rejected with typed errors: non-tracking types (document/json), and * items currently flagged amber/withdrawn — pinning content whose * source is KNOWN gone is not a pin (design D6; the amber row offers * Withdraw now, the red row recovers automatically). * * @param int $id Knowledge item ID. * @param bool $auto True for Auto (track source), false for Manual. * @return array|WP_Error Fresh item state or error. */ public function set_sync_mode($id, $auto) { $item = $this->repository->get($id); if (!$item) { return new WP_Error('not_found', 'Knowledge item not found'); } // #174: a sync-frozen row (type no longer registered) has no // mode to toggle — checked FIRST so a frozen url/product row reads // as frozen, not as a non-tracking type. The UI renders the toggle // frozen for these rows; this is the server-side truth behind it. $type_registered = $this->source_type_registered($item['type'] ?? ''); if (is_wp_error($type_registered)) { return $type_registered; } if (!$this->type_tracks_source($item['type'] ?? '')) { return new WP_Error( 'sync_mode_not_applicable', 'Only source-tracking types (posts, and pro\'s products and URLs) have a sync mode; document and JSON items are always manual.' ); } $flag = $item['source_error'] ?? array(); if (is_array($flag) && !empty($flag)) { return new WP_Error( 'sync_mode_flagged', 'This item\'s source is unavailable or withdrawn — restore the source (or withdraw the item) before changing its sync mode.' ); } $updated = $this->repository->update($id, array('auto_sync' => (bool) $auto)); if (is_wp_error($updated)) { return $updated; } if ($auto) { $this->sync_item($id); // Outcome lands on the item (flag/stamps). return $this->repository->get($id); } return $updated; } /** * Bulk sync multiple items * * @param array $ids Item IDs to sync * @return array Results array with success/error for each ID */ public function bulk_sync(array $ids) { $results = array(); foreach ($ids as $id) { $result = $this->sync_item($id); $results[$id] = array( 'success' => !is_wp_error($result), 'message' => is_wp_error($result) ? $result->get_error_message() : 'Synced successfully' ); } return $results; } /** * Create or get vector store * * @param string $name Vector store name * @return array|WP_Error Vector store data or error */ public function ensure_vector_store($name = 'AnswerRails Knowledge Base') { // Check if we have an existing vector store $existing_id = get_option('answerrails_knowledge_vector_store_id'); if ($existing_id && $this->gateway->vector_store_exists($existing_id)) { return $this->gateway->get_vector_store($existing_id); } // Create new vector store $vector_store = $this->gateway->create_vector_store($name); if (is_wp_error($vector_store)) { return $vector_store; } // Save the ID update_option('answerrails_knowledge_vector_store_id', $vector_store['id']); return $vector_store; } /** * Process knowledge item by type * * @param array $data Raw knowledge data * @return array|WP_Error Processed data or error */ private function process_by_type(array $data) { // FIX: Accept both 'type' and 'knowledge_type' for backward compatibility $type = $data['knowledge_type'] ?? $data['type'] ?? null; if (!$type) { return new WP_Error('missing_type', 'Knowledge type is required'); } // Normalize to knowledge_type $data['knowledge_type'] = $type; unset($data['type']); // Remove 'type' to avoid confusion switch ($type) { case 'document': return $this->process_document_knowledge($data); case 'post': return $this->process_wordpress_content($data); case 'json': return $this->process_json_knowledge($data); } // Handler-registered types (#174 E1 companion): product and url // processing comes with AnswerRails Pro; third-party types plug in // the same way. Membership was validated at create time — a registered // type without a process handler is a registrar defect. $handlers = self::source_handlers(); if (isset($handlers[$type]['process']) && is_callable($handlers[$type]['process'])) { return call_user_func($handlers[$type]['process'], $data); } return new WP_Error( 'answerrails_source_type_unsupported', 'No ingestion handler is registered for knowledge type: ' . $type ); } /** * Process document-based knowledge * * @param array $data Document knowledge data * @return array|WP_Error Processed data or error */ private function process_document_knowledge(array $data) { if (empty($data['media_attachment_id'])) { return new WP_Error('missing_attachment', 'Media attachment ID is required for document type'); } $attachment = get_post($data['media_attachment_id']); if (!$attachment || $attachment->post_type !== 'attachment') { return new WP_Error('invalid_attachment', 'Invalid media attachment ID'); } // Get file path $file_path = get_attached_file($data['media_attachment_id']); if (!file_exists($file_path)) { return new WP_Error('file_not_found', 'Attachment file not found'); } // Extract content based on file type $mime_type = get_post_mime_type($data['media_attachment_id']); if ($mime_type === 'text/plain' || $mime_type === 'text/markdown') { $data['content'] = file_get_contents($file_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin-managed file read; wp_remote_get does not apply (not a remote fetch) and WP_Filesystem offers nothing for read-into-string } else { // For other document types, we'll need the content to be provided if (empty($data['content'])) { $data['content'] = 'Document: ' . $attachment->post_title; } } // Use attachment title if not provided if (empty($data['title'])) { $filename = basename(get_attached_file($data['media_attachment_id'])); $data['title'] = !empty($filename) ? $filename : $attachment->post_title; } // Set default linkability for documents if (!isset($data['is_linkable'])) { $data['is_linkable'] = false; } // FIX: Use 'knowledge_type' not 'type' if (!isset($data['knowledge_type'])) { $data['knowledge_type'] = 'document'; } return $data; } /** * Process WordPress-post-backed content. Public API (#174 E1): the * generic source_post_id ingestion any post-backed type shares — pro's * product handler reuses it and appends its WooCommerce enrichment. * * @param array $data WordPress content data * @return array|WP_Error Processed data or error */ public function process_wordpress_content(array $data) { if (empty($data['source_post_id'])) { return new WP_Error('missing_post_id', 'Source post ID is required'); } $post = get_post($data['source_post_id']); if (!$post) { return new WP_Error('invalid_post', 'Invalid source post ID'); } // Build content from post $content_parts = array(); // Title and content $content_parts[] = '# ' . $post->post_title; $content_parts[] = $post->post_content; // Add excerpt if available if (!empty($post->post_excerpt)) { $content_parts[] = '## Summary'; $content_parts[] = $post->post_excerpt; } // WooCommerce product enrichment moved to the pro product handler // (#174 — source_reach). $data['content'] = implode("\n\n", array_filter($content_parts)); $data['title'] = $post->post_title; $data['source_url'] = get_permalink($post); $data['is_linkable'] = isset($data['is_linkable']) ? $data['is_linkable'] : true; // Set auto_sync to true by default for posts and products if (!isset($data['auto_sync'])) { $data['auto_sync'] = true; } // FIX: knowledge_type should already be set ('post' or 'product') // This is a safety fallback only if (!isset($data['knowledge_type'])) { $data['knowledge_type'] = 'post'; // Default fallback } return $data; } /** * Process JSON knowledge * * @param array $data JSON knowledge data * @return array|WP_Error Processed data or error */ private function process_json_knowledge(array $data) { if (empty($data['content'])) { return new WP_Error('missing_content', 'Content is required for JSON knowledge type'); } // Validate JSON $json = json_decode($data['content']); if (json_last_error() !== JSON_ERROR_NONE) { return new WP_Error('invalid_json', 'Invalid JSON content: ' . json_last_error_msg()); } // Convert to readable format for OpenAI $data['content'] = wp_json_encode($json, JSON_PRETTY_PRINT); // FIX: Use 'knowledge_type' not 'type' if (!isset($data['knowledge_type'])) { $data['knowledge_type'] = 'json'; } // #142 DD5: an owner-declared lookup table must actually FIT the // content at declaration time — same typed-rejection convention as // set_sync_mode(). One validation authority for create AND sync: // lookup_declaration_error(). if (!empty($data['lookup_table'])) { $declaration_error = $this->lookup_declaration_error( $data['content'], (string) ($data['lookup_key_field'] ?? ''), (string) ($data['lookup_value_field'] ?? ''), (string) ($data['lookup_path'] ?? '') ); if (is_wp_error($declaration_error)) { return $declaration_error; } } return $data; } /** * Validate a lookup-table declaration against JSON content (#142 DD5 — * THE single validation authority; create and sync both call it). * * @param string $content Stored JSON content. * @param string $key_field Declared key field. * @param string $value_field Declared value field. * @param string $path Declared mapping location (dot-path; '' = root). * @return true|WP_Error */ public function lookup_declaration_error($content, $key_field, $value_field, $path = '') { if (!class_exists('AnswerRails_Focus_Resolver')) { return true; // resolver not loaded (harness edge) — nothing to validate against } // grounding_text validates EVERY declared cross-reference section // (multi-section amendment) — null iff any section does not fit. $lines = AnswerRails_Focus_Resolver::grounding_text((string) $content, (string) $key_field, (string) $value_field, (string) $path); if (null === $lines) { return new WP_Error( 'lookup_fields_missing', __('This JSON does not fit the cross-reference declaration. Each chosen section must hold simple "term": "mapped term" pairs; a flat object of values works as-is, and for a list of objects the declared key and value fields must exist in the rows.', 'answerrails'), array('status' => 400) ); } return true; } /** * The body text GROUNDING sees for an item (#142 DD5) — canonical * entry lines for a declared lookup item, the stored content otherwise. * ONE derivation consumed by BOTH grounding surfaces (local chunks via * ensure_chunks; the OpenAI upload via prepare_content_for_openai), so * the lexical and vector engines parse the SAME format at trigger time. * The stored content is untouched — it remains the source of truth the * checksum watches. * * @param array $item Knowledge item data. * @return string */ private function grounding_body(array $item) { $content = (string) ($item['content'] ?? ''); $type = (string) ($item['knowledge_type'] ?? $item['type'] ?? ''); if ('json' !== $type || empty($item['lookup_table']) || !class_exists('AnswerRails_Focus_Resolver')) { return $content; } // Path amendment (2026-08-06): grounding_text() = entry lines PLUS // the document remainder — the mapping becomes parseable while the // rest of the owner's document (guidance, notes) keeps grounding // as ordinary chunks. Nothing stored is ignored at retrieval. $text = AnswerRails_Focus_Resolver::grounding_text( $content, (string) ($item['lookup_key_field'] ?? ''), (string) ($item['lookup_value_field'] ?? ''), (string) ($item['lookup_path'] ?? '') ); // A broken declaration never silently degrades to raw-JSON chunks: // sync_item() refuses with the typed error before reaching here; // this fallback only guards direct ensure_chunks callers. return null === $text ? $content : $text; } /** * Enrich knowledge item with link metadata * * @param array $data Knowledge item data * @return array Enriched data */ private function enrich_link_metadata(array $data) { if (!$this->link_processor) { return $data; } // Generate title variations $data['title_variations'] = $this->link_processor->generate_variations($data['title']); return $data; } /** * Calculate content size for knowledge item * * @param array $data Knowledge item data * @return int Size in bytes */ private function calculate_content_size(array $data) { $size = 0; // FIX: Use knowledge_type, with fallback to type $type = $data['knowledge_type'] ?? $data['type'] ?? 'unknown'; if ('document' === $type) { // Get from actual file if media_attachment_id exists if (!empty($data['media_attachment_id'])) { $file_path = get_attached_file($data['media_attachment_id']); if ($file_path && file_exists($file_path)) { $size = filesize($file_path); } } return $size; } // Every other type (json, post, and any handler-registered type — // the arms were identical) sizes its processed content string. if (!empty($data['content'])) { $size = strlen($data['content']); } return $size; } /** * Update link metadata for existing item * * @param array $existing Existing item data * @param array $updates Update data * @return array Updated data with link metadata */ private function update_link_metadata(array $existing, array $updates) { if (!$this->link_processor) { return $updates; } $title = $updates['title'] ?? $existing['title']; $is_linkable = $updates['is_linkable'] ?? $existing['is_linkable']; if ($is_linkable) { $updates['title_variations'] = $this->link_processor->generate_variations($title); } else { // Clear link metadata if not linkable $updates['title_variations'] = array(); } return $updates; } /** * Validate knowledge data for creation * * @param array $data Knowledge data * @return true|WP_Error True if valid, error otherwise */ private function validate_knowledge_data(array $data) { // FIX: Accept both 'type' and 'knowledge_type' $type = $data['knowledge_type'] ?? $data['type'] ?? null; if (empty($type)) { return new WP_Error('missing_type', 'Knowledge type is required'); } // Title is required only where nothing derives one: document, post // and the handler-registered types (product/url in pro) all // auto-title from their source; json is the manual-title type. if (empty($data['title']) && 'json' === $type) { return new WP_Error('missing_title', 'Title is required'); } // Type validation IS registry membership (#174 E1) — the same // authority create_knowledge_item() re-checks after processing // routes; kept here so validation fails fast with the typed error. $registered = $this->source_type_registered($type); if (is_wp_error($registered)) { return $registered; } return true; } /** * Check for duplicate knowledge items * * @param array $data Knowledge data * @return bool True if duplicate exists */ private function check_for_duplicate(array $data) { // FIX: Use knowledge_type with fallback $type = $data['knowledge_type'] ?? $data['type'] ?? null; // Type-blind source-identity test (#174 E1): post-backed types // (post, pro's product, any handler type carrying source_post_id) // dedupe on the source post; url-keyed data dedupes on the url. if (!empty($data['source_post_id'])) { return $this->repository->exists_for_source($type, $data['source_post_id']); } if (!empty($data['url'])) { return $this->repository->exists_for_source('url', $data['url']); } return false; } /** * Calculate content checksum * * @param string $content Content to checksum * @return string MD5 hash */ private function calculate_checksum($content) { return md5($content); } /** * (Re)build an item's local chunk rows when missing or stale * (RETRIEVAL_RESILIENCE_DESIGN). Chunk staleness is detected via * the same content checksum the OpenAI sync uses, stored on the rows. * * @param int $id Knowledge item ID. * @param array $item Knowledge item data. * @param string $checksum Current content checksum. */ private function ensure_chunks($id, array $item, $checksum) { if (!$this->chunk_store || !class_exists('AnswerRails_Knowledge_Chunker')) { return; } if ($this->chunk_store->checksum_for_item($id) === $checksum) { return; // rows present and current } // #142 DD5: chunk the GROUNDING body — canonical entry lines for // declared lookup items, stored content otherwise. $chunks = AnswerRails_Knowledge_Chunker::chunk( (string) ($item['title'] ?? ''), $this->grounding_body($item) ); $this->chunk_store->replace_for_item($id, $chunks, $checksum); } /** * Prepare content for OpenAI upload * * @param array $item Knowledge item * @return string|WP_Error Prepared content or error */ private function prepare_content_for_openai(array $item) { $content = array(); $title = isset($item['title']) && $item['title'] !== '' ? $item['title'] : __('Untitled Knowledge Item', 'answerrails'); $type = $item['knowledge_type'] ?? ($item['type'] ?? 'unknown'); // Add metadata header $content[] = 'Title: ' . $title; $content[] = 'Type: ' . $type; $content[] = 'ID: ' . ($item['id'] ?? ''); if (!empty($item['source_url'])) { $content[] = 'Source: ' . $item['source_url']; } $content[] = '---'; // #142 DD5: upload the GROUNDING body — the vector store's chunks // must carry the same canonical entry-line format the local chunk // table does, or the focus-resolution trigger only works on one // engine. $content[] = $this->grounding_body($item); return implode("\n", $content); } /** * The scratch directory sync writes upload payloads into (#245 D4). * * THE single producer of this path: the literal lived inline in * create_temp_file(), so the admin pre-flight that warns about an * unwritable directory would have been a second copy — and a * pre-flight that checks a different directory from the one sync * writes to is worse than none. * * @return string Absolute path (no trailing slash); not created here. */ public static function temp_dir() { $upload_dir = wp_upload_dir(); return $upload_dir['basedir'] . '/answerrails-temp'; } /** * Create temporary file for upload * * @param string $content File content * @param string $filename Base filename * @return string|WP_Error File path or error */ private function create_temp_file($content, $filename) { $temp_dir = self::temp_dir(); // Create temp directory if needed if (!file_exists($temp_dir)) { if (!wp_mkdir_p($temp_dir)) { return new WP_Error('temp_dir_failed', 'Failed to create temporary directory'); } } // Generate unique filename $safe_filename = sanitize_file_name($filename); $temp_file = $temp_dir . '/' . uniqid('knowledge_') . '_' . $safe_filename . '.txt'; // Write content if (file_put_contents($temp_file, $content) === false) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- local plugin-managed temp scratch write; WP_Filesystem's user-context abstraction does not apply to ephemeral temp files return new WP_Error('file_write_failed', 'Failed to write temporary file'); } return $temp_file; } /** * Clean up OpenAI resources * * @param array $item Knowledge item */ private function cleanup_openai_resources(array $item) { if (empty($item['openai_file_id'])) { return; } // Delete file $this->gateway->delete_file($item['openai_file_id']); // Remove from vector store if needed $vector_store_id = get_option('answerrails_knowledge_vector_store_id'); if ($vector_store_id && $this->gateway->vector_store_exists($vector_store_id)) { $this->gateway->remove_file_from_vector_store($vector_store_id, $item['openai_file_id']); } } /** * Get file content for viewing * * @param int $knowledge_id Knowledge item ID * @param bool $preview_only Whether to limit content for preview * @return array|WP_Error File content data or error */ public function get_file_content($knowledge_id, $preview_only = true) { // Get knowledge item $knowledge_item = $this->repository->get($knowledge_id); if (!$knowledge_item) { return new WP_Error('not_found', 'Knowledge item not found', array('status' => 404)); } // Get the type $type = get_post_meta($knowledge_id, '_answerrails_knowledge_type', true); if ($type !== 'document') { // For non-documents, return the post content return array( 'content' => $knowledge_item['content'] ?? '', 'type' => $type ?: 'text', 'filename' => $knowledge_item['title'] ?? 'Knowledge Item', 'size' => strlen($knowledge_item['content'] ?? ''), 'truncated' => false, 'download_url' => '' ); } // For documents, get the attachment $attachment_id = get_post_meta($knowledge_id, '_answerrails_attachment_id', true); if (!$attachment_id) { $attachment_id = get_post_meta($knowledge_id, '_answerrails_media_attachment_id', true); } if (!$attachment_id) { return new WP_Error('no_attachment', 'No file attached to this item', array('status' => 404)); } // Try to get file content directly first (for local files) $file_path = get_attached_file($attachment_id); $content = ''; $file_url = wp_get_attachment_url($attachment_id); if (!$file_url) { return new WP_Error('file_not_found', 'File not found in media library', array('status' => 404)); } // Check if we can read the file locally if ($file_path && file_exists($file_path) && is_readable($file_path)) { // Read file directly - this works for local installations $content = @file_get_contents($file_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin-managed file read; wp_remote_get does not apply (not a remote fetch) and WP_Filesystem offers nothing for read-into-string if ($content === false) { // If local read fails, check if file is empty $file_size = filesize($file_path); if ($file_size === 0) { return new WP_Error('empty_file', 'File is empty', array('status' => 404)); } return new WP_Error('read_failed', 'Failed to read file content', array('status' => 500)); } } else { // For remote files or when local path doesn't exist (stateless/CDN setup) // Only attempt HTTP fetch for non-localhost URLs $parsed_url = wp_parse_url($file_url); $is_localhost = isset($parsed_url['host']) && (strpos($parsed_url['host'], 'localhost') !== false || strpos($parsed_url['host'], '127.0.0.1') !== false); if ($is_localhost) { // For localhost, try to construct the local path $upload_dir = wp_upload_dir(); $relative_path = str_replace($upload_dir['baseurl'], '', $file_url); $local_path = $upload_dir['basedir'] . $relative_path; if (file_exists($local_path) && is_readable($local_path)) { $content = @file_get_contents($local_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local plugin-managed file read; wp_remote_get does not apply (not a remote fetch) and WP_Filesystem offers nothing for read-into-string } else { return new WP_Error('local_file_not_accessible', 'Cannot access local file', array('status' => 404)); } } else { // For remote files (CDN, S3, etc.) $response = wp_remote_get($file_url, array( 'timeout' => 30, 'sslverify' => true )); if (is_wp_error($response)) { return new WP_Error('fetch_failed', 'Failed to retrieve file content: ' . $response->get_error_message(), array('status' => 500)); } $content = wp_remote_retrieve_body($response); } } // Check if we got any content if (empty($content)) { return new WP_Error('no_content', 'File appears to be empty', array('status' => 404)); } $file_info = pathinfo($file_url); // Detect content type $content_type = $this->detect_content_type($file_info['extension'] ?? '', $content); // Limit content for preview $truncated = false; if ($preview_only && strlen($content) > 50000) { $content = $this->truncate_content($content, $content_type, 50000); $truncated = true; } return array( 'content' => $content, 'type' => $content_type, 'filename' => basename($file_url), 'size' => strlen($content), 'truncated' => $truncated, 'download_url' => $file_url ); } /** * Detect content type from extension and content * * @param string $extension File extension * @param string $content File content * @return string Content type */ private function detect_content_type($extension, $content) { $extension = strtolower($extension); $type_map = array( 'json' => 'json', 'md' => 'markdown', 'markdown' => 'markdown', 'txt' => 'text', 'csv' => 'csv', 'xml' => 'xml', 'html' => 'html', 'log' => 'text', 'yaml' => 'yaml', 'yml' => 'yaml' ); if (isset($type_map[$extension])) { return $type_map[$extension]; } // Try to detect JSON if ($this->is_json($content)) { return 'json'; } return 'text'; } /** * Check if content is valid JSON * * @param string $string Content to check * @return bool True if valid JSON */ private function is_json($string) { if (empty($string)) return false; json_decode($string); return (json_last_error() == JSON_ERROR_NONE); } /** * Truncate content intelligently * * @param string $content Content to truncate * @param string $type Content type * @param int $max_size Maximum size in bytes * @return string Truncated content */ private function truncate_content($content, $type, $max_size) { if (strlen($content) <= $max_size) { return $content; } // For JSON, try to keep it somewhat valid if ($type === 'json') { $truncated = substr($content, 0, $max_size); $truncated .= "\n\n... [Content truncated for preview]"; return $truncated; } // For text/markdown, truncate at line boundary $lines = explode("\n", $content); $result = ''; $size = 0; foreach ($lines as $line) { $line_size = strlen($line) + 1; // +1 for newline if ($size + $line_size > $max_size) { break; } $result .= $line . "\n"; $size += $line_size; } $result .= "\n... [Content truncated for preview - " . count($lines) . " total lines]"; return $result; } }