- 📖 Knowledge Management, the AI Pro Way
- 1 Phase One — Developer Build
- 1a The Engine
- 1b The Knowledge Store
- 1c Retrieval and Answering
- 1c+ Lessons from the real first run
- 1d Connecting to the Live Site
- 1e The Interface
- 1f Database — MariaDB 11.8
- 1g Credentials — checking and changing them
- 1h Editing Python Code Using FTP
- 2 Phase Two — Knowledge Build
- 2a Content Manager's Hints — writing knowledge Katie can actually use
- 3 Appendix — Build Tracker
- 3a Python Learning — line-by-line for PHP devs
knowledge/ does nothing on its own. The tool must be explicitly told to rebuild its index, and then restarted, before it reflects the change. This is the single most common way this kind of tool appears “broken” when it is actually just running on stale knowledge.Right now, Katie answers wellness questions using whatever a general AI model already happens to know — broad, but not specific to BodySleepMind, and not guaranteed to match what BSM actually says or sells. “Training” Katie's knowledge, in the sense this guide means it, isn't retraining the AI itself — nothing about how Katie thinks gets touched. It's something simpler and more controllable: handing her BSM's own real documents to search before she answers, so her replies are genuinely grounded in what BodySleepMind actually says, not a general guess.
That alone is worth doing, for BSM's own wellness content today. But there's a bigger opportunity sitting one step further out, and it's worth being deliberate about from the start.
That is the actual pitch to a partner: a relatively small amount of document preparation turns into Katie being able to discuss their products credibly, inside a conversation a user already trusts — without the partner needing to build or host any AI of their own at all.
Worth flagging honestly, not glossed over: once more than one partner's content sits inside the same knowledge base, deciding which knowledge should apply to which conversation becomes its own small design question — not solved by today's build, but not blocked by it either. A good problem to have later, not a reason to wait.
None of that is magic — it's documents, turned into numbers, matched by meaning. The definitions below explain exactly how, term by term, starting from zero — including the one piece that has nothing to do with AI at all, but that everything else in Phase One depends on.
Getting to it on this server: inside Plesk, look for SSH Terminal — usually a labelled tool or terminal icon in the left-hand menu or Tools & Settings. Clicking it opens a black command-line window directly in the browser, already connected to the server as root. No separate app or login needed beyond Plesk itself. Every grey code block in Phase One gets typed or pasted into that window, one at a time.
- Press Ctrl+C in the terminal first, just to land on a clean, fresh prompt — harmless even if nothing was actually running
- Copy the code being given to you, and paste it into a plain text editor first (Notepad, or equivalent) — not straight into the terminal
- Select it again from there, and paste into the terminal using Ctrl+Shift+V — not the normal Ctrl+V
Ctrl+Shift+V is the dedicated “paste as plain text” shortcut in most terminal apps, where plain Ctrl+V is often reserved for something else entirely.
One common mix-up worth clearing up directly: cosine similarity (see below) does not get calculated or stored at this point. Nothing about “how good a match” gets baked into a chunk when it's created. Only its embedding is stored. Cosine similarity only ever gets calculated fresh, on the spot, the moment a real question is asked — comparing that question's own embedding against every stored chunk's embedding, to work out which ones are the closest match.
How the numbers actually get generated: the text is fed into an already-trained AI model built specifically for this one job (called an “embedding model”). That model was trained beforehand by reading an enormous amount of real-world text and learning, statistically, which words and phrases tend to show up in similar situations — for example, learning that “cancel my plan” and “stop my subscription” tend to appear in similar conversations, even though they don't share a word. Once trained, it can take any new sentence and work out where it belongs on that learned map of meaning, and writes that location down as a number string. This is why two totally different phrases can land on similar numbers: the model isn't matching letters at all, it's matching learned context — the same family of AI technology (called a “transformer”) behind tools like ChatGPT and Claude itself.
0.12, -0.87, 0.34, ... continuing on for hundreds of numbers). It is not a sequence of separate, unrelated numbers one after another (like 1234, then 1235, then 1236) — it's one single address, just written with hundreds of numbers instead of two or three. Number string = co-ordinates = vector — these three phrases all mean exactly the same thing in this guide. When people say “store the vectors” or “search the vectors,” they mean storing and comparing these meaning-coordinates, not anything more exotic.katie_rag, is created on the same MariaDB server specifically for this — not added as a table inside WordPress's existing database. Only the Python service ever connects to it, with its own narrowly-scoped login. WordPress never gets a password to it at all. Everything WordPress actually needs — the review queue, who submitted what, decline reasons — stays exactly where it already belongs: inside WordPress's own database, the same way every other custom table on this site already works. The two systems coordinate through a plain text file and one web address (see Build Plan Group 5), never through a shared database login.main.py (Build Plan, Group 3). Also confirmed, from its own source code: it builds the vector index with cosine distance automatically, every time — not MariaDB's own default. Nothing left to check on this point.Runs on the same server that already runs BodySleepMind and its database — one new folder, no new vendor, no new account.
venv module by default — install it first, or the next command fails immediately.
apt install -y python3.12-venv mkdir -p /var/www/vhosts/bodysleepmind.com/katie-knowledge cd /var/katie-rag python3 -m venv venv source venv/bin/activate
(venv). Re-run source venv/bin/activate at the start of any new session on this project.
LlamaIndex (the retrieval engine — see Glossary), its official MariaDB connector (so vectors are stored in MariaDB 11.8 from day one, not a temporary file — see Glossary: “the bridge between LlamaIndex and MariaDB”), and FastAPI plus Uvicorn (the web layer). Installed into the private environment from Step 1, the standard approach on this server's operating system.
pip install llama-index "llama-index-vector-stores-mariadb>=0.3.0" openai fastapi uvicorn
Lives at /var/www/vhosts/bodysleepmind.com/katie-knowledge/ — deliberately a sibling of httpdocs, not inside it, so it's never reachable as a public webpage, while still sitting inside the one folder boundary this site's PHP is actually allowed to touch (see the open_basedir note below; this is the corrected final location, not the original one). Every document referenced from here on lives in this one place. Created by root over SSH, so it needed one further step before the knowledge manager (Build Plan Group 5) could actually write to it: handing ownership to the exact user WordPress's PHP actually runs as for this site.
wp-config.php) is not a reliable way to find this — it can show root even when PHP itself runs as someone else entirely. The reliable method: check the operating system's own process list directly, while PHP-FPM is actually running. ps aux | grep php-fpm showed the real worker for this site as chris2, confirmed independently via id chris2 as belonging to the group psacln — a shared group Plesk uses across subscriptions, not one matching the username. These two values are specific to this server and this subscription — a future server will hand you different ones. Re-run this same methodology there; don't copy these names literally.
chown -R chris2:psacln /var/www/vhosts/bodysleepmind.com/katie-knowledge
ls -la /var/www/vhosts/bodysleepmind.com/ shows katie-knowledge owned by chris2 psacln instead of root root.
open_basedir
The folder was originally created at /var/katie-rag/knowledge/ — correct for the Python service, which has no restriction on what it can read. But the very first real attempt to approve a real submission through knowledge-manager.php failed with “Could not write to the knowledge folder.” The cause, confirmed with a tiny standalone test script (echo ini_get('open_basedir')): Plesk restricts each website's PHP to only read or write inside its own domain folder — here, /var/www/vhosts/bodysleepmind.com/:/tmp/. /var/katie-rag/ sat completely outside that boundary, so PHP was flatly blocked from writing to it, regardless of how correct the file ownership and permissions already were.Two fixes existed: widen
open_basedir in Plesk to explicitly allow the extra path, or move the folder to somewhere already inside the boundary. The folder was moved — the cleaner, permanent fix, with nothing to ever re-check on a future server or PHP upgrade. Only the knowledge folder itself needed to move; main.py, the virtual environment, and the secrets file all stayed exactly where they were, since PHP never touches any of those directly — only the Python service does, over its own internal network call, which open_basedir has no say over at all.What actually moved, for a future rebuild: the one line in
main.py setting KNOWLEDGE_DIR (Step 6), the matching KM_KNOWLEDGE_DIR constant in knowledge-manager.php (Build Plan Group 5), and the existing knowledge files themselves, moved across with mv. Ownership was re-applied at the new location using the exact same chris2:psacln values already confirmed above — that part never changed.
Originally planned as two named reference documents typed directly into the folder over SSH. In practice, the knowledge manager (Build Plan Group 5) was built first, and the real intake path is through it instead — type or paste text, or upload a .txt, PDF, Word, or Excel file, reviewed and approved the same way every future document will be. The first genuinely live content will arrive this way, not via direct terminal authoring.
main.py's own code, the same lesson applied across every other file touched today.One extra package, then one file. python-dotenv lets main.py read both secrets from a separate file at startup.
pip install python-dotenv
Then the file itself:
cat > /var/katie-rag/.env OPENAI_API_KEY=<the real masterKey value> <Ctrl+D> cat >> /var/katie-rag/.env DB_PASSWORD=<the real katie_rag database password> <Ctrl+D> chmod 600 /var/katie-rag/.env
cat /var/katie-rag/.env shows both lines present and correct. chmod 600 means only root can read the file at all — appropriate while the service is run manually over SSH as root; revisit if it's ever run as a different system user later.
katie_rag database and its chris3 user (Step 16), and the secrets file (Step 5) — both done.
cat > /var/katie-rag/main.py << 'EOF'
import os
import glob
from dotenv import load_dotenv
from fastapi import FastAPI
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, StorageContext
from llama_index.vector_stores.mariadb import MariaDBVectorStore
# Secrets loaded from .env โ never hardcoded directly in this file
load_dotenv("/var/katie-rag/.env")
os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY", "")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
KNOWLEDGE_DIR = "/var/www/vhosts/bodysleepmind.com/katie-knowledge"
vector_store = MariaDBVectorStore.from_params(
host="127.0.0.1",
port=3306,
user="chris3",
password=DB_PASSWORD,
database="katie_rag",
table_name="katie_knowledge_vectors",
embed_dim=1536, # OpenAI embedding dimension
)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
query_engine = None
def build_index():
global query_engine
files = glob.glob(os.path.join(KNOWLEDGE_DIR, "*.txt"))
if not files:
query_engine = None
return
docs = SimpleDirectoryReader(KNOWLEDGE_DIR).load_data()
index = VectorStoreIndex.from_documents(docs, storage_context=storage_context)
query_engine = index.as_query_engine()
build_index() # build once when the service starts
app = FastAPI()
@app.get("/ask")
def ask(q: str):
if query_engine is None:
return {"answer": "No knowledge has been loaded yet."}
return {"answer": str(query_engine.query(q))}
@app.get("/reload")
def reload():
build_index()
files_found = len(glob.glob(os.path.join(KNOWLEDGE_DIR, "*.txt")))
return {"status": "reloaded", "files_found": files_found}
EOF
DISTANCE=cosine explicitly — not MariaDB's own Euclidean default. The connector's code even comments why: its search queries use VEC_DISTANCE_COSINE, and an index built for a different distance function wouldn't be used. Nothing extra to configure — cosine is what this exact tool does automatically.
knowledge/ has no documents yet, /ask answers honestly rather than crashing. /reload always rebuilds the whole index from scratch rather than adding incrementally — the simpler, correct choice at today's scale; revisit only if the knowledge base grows large enough for a full rebuild to become genuinely slow.
cd /var/katie-rag nohup uvicorn main:app --host 127.0.0.1 --port 8420 > katie.log 2>&1 &
[1] 407815) the instant this runs, whether or not the service actually started successfully a moment later — that number alone is not confirmation. Always follow with cat katie.log. Confirmed working when it shows:
INFO: Started server process INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://127.0.0.1:8420
curl rejects a literal space in a URL outright (URL rejected: Malformed input to a URL function), even inside quotes. Use %20 in place of each space.
curl "http://127.0.0.1:8420/ask?q=what%20is%20the%20secret%20test%20phrase"
knowledge.txt (see Glossary & Section F), and got back the real, grounded answer:
{"answer":"THE SECRET TEST PHRASE IS: BLUE ELEPHANTS DANCE AT MIDNIGHT."}katie_knowledge_vectors, and when a row gets created| Column | What it actually is |
|---|---|
id | MariaDB's own row number — internal bookkeeping only, not used by any AI logic |
node_id | LlamaIndex's own unique ID for one chunk of a document, not the whole file |
text | The literal text of that one chunk — what actually gets matched, and handed to OpenAI when forming an answer |
metadata | A small JSON blob attached automatically — source filename, file path, that kind of thing |
embedding | vector(1536) — the actual numbers representing that chunk's meaning, compared at query time |
- A submission is Approved in the knowledge manager
- The file is written into the knowledge folder, and
/reloadis called automatically build_index()runsSimpleDirectoryReader(KNOWLEDGE_DIR).load_data()— this reads every.txtfile currently in the folder, not just the one just approvedVectorStoreIndex.from_documents(...)does the genuinely clever part, in three distinct steps — worth separating clearly, since they're easy to blur together:- Chunking — each document is sliced into smaller pieces first (see Glossary: Chunking), typically a paragraph or two each. A short file might end up as one single chunk; a long one becomes several. This happens before anything else.
- Embedding — each individual chunk, on its own, is sent to OpenAI and comes back as its own vector (see Glossary: Embedding) — one chunk in, one set of 1536 numbers out.
- Storing — that chunk's text and its vector are written together as one new row.
build_index() never clears the table first. That means every approval likely re-embeds and re-inserts every document already in the knowledge base on top of what's already there, not just the new one — the table can accumulate duplicate rows over time, and every approval re-spends real OpenAI cost on documents that haven't even changed. Checkable directly: Browse katie_knowledge_vectors in phpMyAdmin and look for the same source file appearing more than once. Two real fixes exist if this turns out to matter — clear the table at the start of build_index() before rebuilding, or move to properly incremental updates instead of a full rebuild each time — deliberately not built yet, a genuine open decision rather than an oversight.
main.py was written out as a command, but a detour into updating this guide happened immediately after, before that command was actually run on the server. The result: uvicorn failed with a vague Could not import module "main". The fix was simple once found — ls -la main.py showed the file plainly didn't exist. Worth a habit: after any step that creates a file, confirm it's really there before moving on, especially after a context-switch away from the terminal.
main.py content in one go (roughly 50 lines) produced a file with whole sections missing and fragments from different parts of the script mashed together mid-line — not a shell error, a silently broken file that only revealed itself as confusing Python errors later. The fix: rebuild in small chunks (roughly 10–15 lines each), checking wc -l after every single one, rather than trusting one large paste. Slower, but every chunk verifiable on its own.
^[[200~actual command~, and the shell tried to run that whole garbled string, failing with command not found. This is the terminal's own paste-marker leaking through uncleanly. The fix: for short commands, just type them by hand instead of pasting — it happened consistently with paste, never with typing.
uvicorn's own error message for an import failure (Could not import module "main") is a dead end by itself — it hides the real Python exception underneath it, even running in the foreground with no log redirection. The fix that actually revealed the cause: bypass uvicorn entirely and ask Python directly — python3 -c "import main" — which prints the full, real traceback uvicorn was hiding.
Access denied for user 'chris3'@'localhost' (using password: YES) — a wrong-password error, despite the password looking right at a glance. Opening .env directly in nano and reading it character by character showed why: DB_PASSWORD=...rs; — a trailing semicolon had been typed onto the end of the value, a leftover habit from the many PHP files edited earlier the same session, where every line ends in ;. That semicolon was silently treated as part of the password itself. The fix: reset the password fresh in Plesk, then edit the line in nano deleting back to immediately after the = before typing the new value — confirmed visually before saving, not assumed correct.
/ask with a real multi-word question failed with URL rejected: Malformed input to a URL function, even with the whole address inside quotes. The fix: encode each space as %20 in the query string rather than leaving it literal.
Three more real obstacles were hit later in the build, kept inline at the step where they actually happened rather than duplicated here: a regressed API key at Step 9, a strict-comparison bug at Step 10, and a port conflict at Step 14.
A wp_remote_get() call to 127.0.0.1:8420, internal to the server, never exposed publicly — built directly into triage_backend.php's ?ai=1 handler (Step 10, below).
triage_backend.php's ?ai=1 handler no longer reads knowledge.txt directly. It calls /ask first. Two decisions were made deliberately, not by default:
- No silent fallback to the old approach. If the knowledge service is unreachable or has nothing loaded, Katie returns a clear, honest message — "Sorry. I'm just getting my wellness check up. I won't be able to answer your question right now. Back shortly!" — rather than quietly reverting to stuffing the whole of
knowledge.txtinto a fresh OpenAI call. A silent fallback would hide a real outage completely, and cost real money every time it happened; a visible message means a real failure gets noticed and fixed, not buried. - No blending with GOD JSON, for now. The retrieved answer is shown as-is. Deliberately not built as a default or a guess at what might be wanted — if and when Katie's answers should ever draw on a user's own wellness profile too, that's a separate, considered decision to make case by case, not something to bundle in here.
=== comparison — wp_remote_retrieve_response_code($rag_response) === 200. WordPress's HTTP functions don't always return a plain integer; depending on the server's transport, the code can come back as the text "200" instead, and "200" === 200 is false in PHP even though the call succeeded. The fix: cast explicitly before comparing — (int) wp_remote_retrieve_response_code($rag_response) === 200 — checking the value, not the type.
knowledge-manager.php, a WordPress admin page in the same family as every other admin tool already built for BSM. Built differently from the original plan: rather than a holding folder (e.g. /var/katie-rag/pending/), every submission is saved into a database table, katie_knowledge_queue, that self-installs the first time the page loads. Nothing is written to the live knowledge/ folder until Step 13's approval — the file only needs write access to that one folder, never to a second one, and the table doubles as a genuine audit trail (who submitted what, when, and why anything was declined).
Four ways in, all feeding the same queue: type or paste text directly; upload a .txt file; upload a PDF (text pulled out via pdftotext); upload a Word (.docx) or Excel (.xlsx) file, read directly with no AI step at all, no extra cost, no misread risk. Editing something already live works the same way — the edit goes to the queue, the live file is untouched until it's approved.
A Review Queue tab inside knowledge-manager.php, listing everything currently pending in katie_knowledge_queue — tagged NEW or EDIT-of-X, with who submitted it, when, and the source type. For each submission, the reviewer chooses:
- Approve — the content is written into the live
knowledge/folder (Group 2) for the first time, the row is marked approved, and Step 13's reload is triggered immediately, automatically - Decline — marked declined, with an optional note on why; the file is never written anywhere Katie can see it
A separate Declined tab, added once it became clear a decline with no visible record afterward was a real gap: every declined item stays visible, with who declined it, when, and the reason — a genuine audit trail, not a second queue, and nothing in it can come back to life by itself.
edit_posts (any Contributor+), reviewing and declining requires manage_options (Administrators only) — a sensible starting point, not a final decision. Tracked in Appendix, Section I.
Built on both sides now. knowledge-manager.php's Approve action calls /reload via wp_remote_get() the moment a reviewer clicks Approve. main.py's /reload address (Step 6) rebuilds the whole index from the knowledge folder and reports back how many files it found. Not yet tested end-to-end — that happens once Step 7 actually starts the service.
<?php
/*
=============================================================================================
Filename: knowledge/knowledge-manager.php
Description: Katie's Knowledge Admin Interface โ Phase Two of the RAG build.
VERSION: v1.0
=============================================================================================
DEVELOPER GUIDE: WHAT THIS FILE DOES
This is the admin page that lets a knowledge admin write, upload, review, and publish
knowledge for Katie โ without ever touching SSH. It follows the same "Monolith SPA"
pattern as cheat_master.php: PHP AJAX interceptor on top, scoped CSS, then HTML + vanilla JS.
1. SUBMISSION (4 ways in)
- Type/paste text directly
- Upload a .txt file
- Upload a PDF (text extracted automatically via `pdftotext`)
- Upload a Word (.docx) or Excel (.xlsx) file (read directly, no AI involved)
An image-upload / vision-analysis method was deliberately considered and parked โ
see the note above the (removed) handler further down. It earns its value for
real-time field capture (a label, no file available); knowledge curation is
desk-based, where a real file almost always exists already.
Every submission โ new or an edit of something already live โ is saved into a
database queue table, NOT written to the live knowledge folder. Nothing goes live
until approved.
2. REVIEW QUEUE
Shows every pending item. Approve writes it into the live knowledge folder and
triggers the Python service's /reload address. Decline marks it declined (kept,
not deleted, for an honest audit trail) and it never reaches Katie.
3. WHY A DATABASE TABLE INSTEAD OF A SECOND FOLDER
The original plan used a `/var/katie-rag/pending/` folder. A DB table does the same
job with one real advantage: this page only needs write access to the live
`knowledge/` folder, and only at the moment of approval โ not a second folder needing
its own permissions setup. It also gives a genuine audit trail (who submitted what,
who reviewed it, when) for free.
4. ACCESS LEVELS โ PLACEHOLDER, NOT YET A FINAL DECISION
Submitting requires 'edit_posts' (any Contributor+). Reviewing/approving requires
'manage_options' (Administrators only). This is a sensible default split, not a
final answer โ change the current_user_can() checks below to match whatever BSM
actually wants. Tracked as an open decision in the build guide, Appendix Section I.
5. THE OPENAI KEY
Pulled from getenv('OPENAI_KEY') โ the same convention already used in
triage_backend.php. Deliberately NOT a third hardcoded copy of the key.
=============================================================================================
*/
// =========================================================================
// CONFIGURATION
// =========================================================================
define('KM_KNOWLEDGE_DIR', '/var/www/vhosts/bodysleepmind.com/katie-knowledge/');
define('KM_RELOAD_URL', 'http://127.0.0.1:8420/reload'); // Must match whatever Step 12 actually builds
define('KM_TABLE', 'katie_knowledge_queue'); // No wp_ prefix, matching user_cheats / assessment convention
$km_can_submit = current_user_can('edit_posts');
$km_can_review = current_user_can('manage_options');
if (!$km_can_submit) {
echo '<p>You do not have permission to view this page.</p>';
return;
}
// =========================================================================
// SELF-INSTALLING TABLE (runs once, harmless if it already exists)
// =========================================================================
global $wpdb;
$wpdb->query("CREATE TABLE IF NOT EXISTS " . KM_TABLE . " (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action_type VARCHAR(20) NOT NULL,
target_filename VARCHAR(255) NULL,
suggested_filename VARCHAR(255) NULL,
content_text LONGTEXT NOT NULL,
source_type VARCHAR(20) NOT NULL,
ai_meta TEXT NULL,
submitted_by BIGINT UNSIGNED NOT NULL,
submitted_at DATETIME NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
reviewed_by BIGINT UNSIGNED NULL,
reviewed_at DATETIME NULL,
decline_note TEXT NULL
) " . $wpdb->get_charset_collate());
// =========================================================================
// PHP BACKEND: AJAX INTERCEPTOR
// =========================================================================
if (isset($_POST['km_action'])) {
ob_clean();
header('Content-Type: application/json');
$user_id = get_current_user_id();
if (!$user_id) { echo json_encode(['error' => 'You must be logged in.']); exit; }
$action = sanitize_text_field($_POST['km_action']);
// --- Helper: safely resolve a filename inside the knowledge dir, no path traversal ---
function km_safe_path($filename) {
$clean = basename($filename); // strips any ../ or directory parts
return KM_KNOWLEDGE_DIR . $clean;
}
// --- Helper: extract plain text from a .docx file (it's a zip with XML inside) ---
function km_extract_docx_text($path) {
if (!class_exists('ZipArchive')) return ['error' => 'PHP\'s zip extension is not enabled on this server.'];
$zip = new ZipArchive();
if ($zip->open($path) !== true) return ['error' => 'Could not open this file as a .docx (is it actually a Word document?).'];
$xml = $zip->getFromName('word/document.xml');
$zip->close();
if ($xml === false) return ['error' => 'This doesn\'t look like a valid .docx file.'];
// Turn paragraph breaks into newlines before stripping the XML, so the
// result reads as actual paragraphs, not one giant run-on block of text.
$xml = str_replace('</w:p>', "\n", $xml);
$xml = str_replace('</w:tab>', "\t", $xml);
$text = html_entity_decode(strip_tags($xml));
$text = trim(preg_replace('/\n{3,}/', "\n\n", $text));
return ['text' => $text];
}
// --- Helper: extract a readable table from a .xlsx file's first sheet ---
function km_extract_xlsx_text($path) {
if (!class_exists('ZipArchive')) return ['error' => 'PHP\'s zip extension is not enabled on this server.'];
$zip = new ZipArchive();
if ($zip->open($path) !== true) return ['error' => 'Could not open this file as an .xlsx (is it actually an Excel file?).'];
// Excel stores repeated text once in a shared lookup table, and cells just
// reference it by number โ so we build that lookup first.
$shared = [];
$shared_xml = $zip->getFromName('xl/sharedStrings.xml');
if ($shared_xml !== false) {
$dom = new DOMDocument();
@$dom->loadXML($shared_xml);
foreach ($dom->getElementsByTagName('si') as $si) { $shared[] = trim($si->textContent); }
}
$sheet_xml = $zip->getFromName('xl/worksheets/sheet1.xml'); // first sheet only, v1 limitation
$zip->close();
if ($sheet_xml === false) return ['error' => 'No readable sheet found in this file.'];
$dom = new DOMDocument();
@$dom->loadXML($sheet_xml);
$lines = [];
foreach ($dom->getElementsByTagName('row') as $row) {
$cells = [];
foreach ($row->getElementsByTagName('c') as $cell) {
$type = $cell->getAttribute('t');
$v_nodes = $cell->getElementsByTagName('v');
$raw = $v_nodes->length ? $v_nodes->item(0)->textContent : '';
$cells[] = ($type === 's' && $raw !== '' && isset($shared[(int)$raw])) ? $shared[(int)$raw] : $raw;
}
if (count(array_filter($cells, fn($c) => trim($c) !== ''))) {
$lines[] = implode(' | ', $cells);
}
}
if (empty($lines)) return ['error' => 'No data found on the first sheet.'];
return ['text' => trim(implode("\n", $lines))];
}
// --- LIST LIVE KNOWLEDGE FILES ---
if ($action === 'list_live') {
$files = [];
foreach (glob(KM_KNOWLEDGE_DIR . '*.txt') as $path) {
$content = file_get_contents($path);
$files[] = [
'filename' => basename($path),
'preview' => mb_substr(trim($content), 0, 140),
'size' => filesize($path),
'modified' => date('Y-m-d H:i', filemtime($path)),
];
}
echo json_encode(['success' => true, 'data' => $files]);
exit;
}
// --- GET ONE LIVE FILE'S FULL CONTENT (for editing) ---
if ($action === 'get_live_file') {
$path = km_safe_path($_POST['filename'] ?? '');
if (!file_exists($path)) { echo json_encode(['error' => 'File not found.']); exit; }
echo json_encode(['success' => true, 'content' => file_get_contents($path)]);
exit;
}
// --- SUBMIT: NEW TEXT (typed/pasted) ---
if ($action === 'submit_text') {
$text = sanitize_textarea_field($_POST['content'] ?? '');
$suggested = sanitize_file_name($_POST['suggested_filename'] ?? '');
if (empty($text)) { echo json_encode(['error' => 'No content provided.']); exit; }
if (empty($suggested)) { $suggested = 'submission-' . time() . '.txt'; }
if (!str_ends_with($suggested, '.txt')) { $suggested .= '.txt'; }
$wpdb->insert(KM_TABLE, [
'action_type' => 'new',
'suggested_filename' => $suggested,
'content_text' => $text,
'source_type' => 'text',
'submitted_by' => $user_id,
'submitted_at' => current_time('mysql'),
'status' => 'pending',
]);
echo json_encode(['success' => true]);
exit;
}
// --- SUBMIT: EDIT OF AN EXISTING LIVE FILE ---
if ($action === 'submit_edit') {
$target = sanitize_file_name($_POST['target_filename'] ?? '');
$text = sanitize_textarea_field($_POST['content'] ?? '');
if (empty($target) || !file_exists(km_safe_path($target))) {
echo json_encode(['error' => 'Original file not found.']); exit;
}
if (empty($text)) { echo json_encode(['error' => 'No content provided.']); exit; }
$wpdb->insert(KM_TABLE, [
'action_type' => 'edit',
'target_filename' => $target,
'content_text' => $text,
'source_type' => 'text',
'submitted_by' => $user_id,
'submitted_at' => current_time('mysql'),
'status' => 'pending',
]);
echo json_encode(['success' => true]);
exit;
}
// --- SUBMIT: UPLOAD A .TXT FILE ---
if ($action === 'upload_txt') {
if (empty($_FILES['file']['tmp_name'])) { echo json_encode(['error' => 'No file received.']); exit; }
$text = file_get_contents($_FILES['file']['tmp_name']);
$suggested = sanitize_file_name($_FILES['file']['name']);
$wpdb->insert(KM_TABLE, [
'action_type' => 'new',
'suggested_filename' => $suggested,
'content_text' => $text,
'source_type' => 'txt_upload',
'submitted_by' => $user_id,
'submitted_at' => current_time('mysql'),
'status' => 'pending',
]);
echo json_encode(['success' => true]);
exit;
}
// --- SUBMIT: UPLOAD A PDF (text extracted via pdftotext) ---
if ($action === 'upload_pdf') {
if (empty($_FILES['file']['tmp_name'])) { echo json_encode(['error' => 'No file received.']); exit; }
// Confirm pdftotext is actually available before relying on it
$which = shell_exec('which pdftotext');
if (empty(trim($which ?? ''))) {
echo json_encode(['error' => 'pdftotext is not installed on this server. Install poppler-utils first.']);
exit;
}
$tmp_path = $_FILES['file']['tmp_name'];
$text = shell_exec('pdftotext ' . escapeshellarg($tmp_path) . ' -');
$text = trim($text ?? '');
if (empty($text)) {
echo json_encode(['error' => 'No text could be extracted โ this may be a scanned/image-only PDF.']);
exit;
}
$suggested = sanitize_file_name(pathinfo($_FILES['file']['name'], PATHINFO_FILENAME)) . '.txt';
$wpdb->insert(KM_TABLE, [
'action_type' => 'new',
'suggested_filename' => $suggested,
'content_text' => $text,
'source_type' => 'pdf_upload',
'submitted_by' => $user_id,
'submitted_at' => current_time('mysql'),
'status' => 'pending',
]);
echo json_encode(['success' => true]);
exit;
}
// --- IMAGE UPLOAD: DELIBERATELY NOT BUILT ---
// Considered and parked. The food/cheat analyser's vision capability earns its value
// from real-time field capture (a label in a supermarket, no file available at all).
// Knowledge curation is desk-based and deliberate โ a real file almost always exists,
// so .docx/.xlsx/.pdf direct reading covers this need more reliably and at no AI cost.
// Revisit only if a genuine "photo is the only option" case actually shows up.
// --- SUBMIT: UPLOAD A WORD DOCUMENT (.docx, read directly, no AI involved) ---
if ($action === 'upload_docx') {
if (empty($_FILES['file']['tmp_name'])) { echo json_encode(['error' => 'No file received.']); exit; }
$result = km_extract_docx_text($_FILES['file']['tmp_name']);
if (isset($result['error'])) { echo json_encode(['error' => $result['error']]); exit; }
if (empty(trim($result['text']))) { echo json_encode(['error' => 'No text found in this document.']); exit; }
$suggested = sanitize_file_name(pathinfo($_FILES['file']['name'], PATHINFO_FILENAME)) . '.txt';
$wpdb->insert(KM_TABLE, [
'action_type' => 'new',
'suggested_filename' => $suggested,
'content_text' => $result['text'],
'source_type' => 'docx_upload',
'submitted_by' => $user_id,
'submitted_at' => current_time('mysql'),
'status' => 'pending',
]);
echo json_encode(['success' => true]);
exit;
}
// --- SUBMIT: UPLOAD AN EXCEL FILE (.xlsx, read directly, no AI involved) ---
if ($action === 'upload_xlsx') {
if (empty($_FILES['file']['tmp_name'])) { echo json_encode(['error' => 'No file received.']); exit; }
$result = km_extract_xlsx_text($_FILES['file']['tmp_name']);
if (isset($result['error'])) { echo json_encode(['error' => $result['error']]); exit; }
$suggested = sanitize_file_name(pathinfo($_FILES['file']['name'], PATHINFO_FILENAME)) . '.txt';
$wpdb->insert(KM_TABLE, [
'action_type' => 'new',
'suggested_filename' => $suggested,
'content_text' => $result['text'],
'source_type' => 'xlsx_upload',
'submitted_by' => $user_id,
'submitted_at' => current_time('mysql'),
'status' => 'pending',
]);
echo json_encode(['success' => true]);
exit;
}
// --- LIST PENDING QUEUE (review screen) ---
if ($action === 'list_pending') {
if (!$km_can_review) { echo json_encode(['error' => 'Not permitted.']); exit; }
$rows = $wpdb->get_results("SELECT * FROM " . KM_TABLE . " WHERE status = 'pending' ORDER BY submitted_at ASC", ARRAY_A);
foreach ($rows as &$row) {
$user = get_userdata($row['submitted_by']);
$row['submitted_by_name'] = $user ? $user->display_name : 'Unknown';
$row['preview'] = mb_substr(trim($row['content_text']), 0, 200);
}
echo json_encode(['success' => true, 'data' => $rows]);
exit;
}
// --- LIST DECLINED ITEMS (audit trail โ nothing here can go live) ---
if ($action === 'list_declined') {
if (!$km_can_review) { echo json_encode(['error' => 'Not permitted.']); exit; }
$rows = $wpdb->get_results("SELECT * FROM " . KM_TABLE . " WHERE status = 'declined' ORDER BY reviewed_at DESC", ARRAY_A);
foreach ($rows as &$row) {
$submitter = get_userdata($row['submitted_by']);
$reviewer = get_userdata($row['reviewed_by']);
$row['submitted_by_name'] = $submitter ? $submitter->display_name : 'Unknown';
$row['reviewed_by_name'] = $reviewer ? $reviewer->display_name : 'Unknown';
$row['preview'] = mb_substr(trim($row['content_text']), 0, 200);
}
echo json_encode(['success' => true, 'data' => $rows]);
exit;
}
// --- APPROVE A PENDING ITEM ---
if ($action === 'approve') {
if (!$km_can_review) { echo json_encode(['error' => 'Not permitted.']); exit; }
$id = intval($_POST['id']);
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . KM_TABLE . " WHERE id = %d AND status = 'pending'", $id), ARRAY_A);
if (!$row) { echo json_encode(['error' => 'Item not found or already reviewed.']); exit; }
$filename = ($row['action_type'] === 'edit') ? $row['target_filename'] : $row['suggested_filename'];
$filename = sanitize_file_name($filename);
if (!str_ends_with($filename, '.txt')) { $filename .= '.txt'; }
$write_path = KM_KNOWLEDGE_DIR . $filename;
$written = file_put_contents($write_path, $row['content_text']);
if ($written === false) {
echo json_encode(['error' => 'Could not write to the knowledge folder โ check folder permissions.']);
exit;
}
$wpdb->update(KM_TABLE, [
'status' => 'approved',
'reviewed_by' => $user_id,
'reviewed_at' => current_time('mysql'),
], ['id' => $id]);
// Trigger the live reload โ see Build Plan Group 5, Step 12
$reload_result = wp_remote_get(KM_RELOAD_URL, ['timeout' => 15]);
$reload_ok = !is_wp_error($reload_result);
echo json_encode(['success' => true, 'reload_ok' => $reload_ok]);
exit;
}
// --- DECLINE A PENDING ITEM ---
if ($action === 'decline') {
if (!$km_can_review) { echo json_encode(['error' => 'Not permitted.']); exit; }
$id = intval($_POST['id']);
$note = sanitize_textarea_field($_POST['note'] ?? '');
$wpdb->update(KM_TABLE, [
'status' => 'declined',
'reviewed_by' => $user_id,
'reviewed_at' => current_time('mysql'),
'decline_note' => $note,
], ['id' => $id, 'status' => 'pending']);
echo json_encode(['success' => true]);
exit;
}
echo json_encode(['error' => 'Unknown action.']);
exit;
}
?>
<!-- =========================================================================
FRONTEND CSS โ scoped to .km- to protect the theme
========================================================================= -->
<style>
.km-wrapper { max-width: 1000px; margin: 30px auto; padding: 0 20px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #2F3E46; }
.km-header { text-align: center; margin-bottom: 25px; }
.km-header h1 { font-size: 26px; font-weight: 900; margin: 0; }
.km-tabs { display: flex; gap: 10px; justify-content: center; margin-bottom: 25px; }
.km-tab { padding: 10px 20px; border-radius: 20px; background: #f1f5f9; color: #5a6b72; font-weight: 700; font-size: 14px; cursor: pointer; border: none; }
.km-tab.active { background: #2F3E46; color: #fff; }
.km-panel { display: none; }
.km-panel.active { display: block; }
.km-card { background: #fff; border: 1px solid #e5e9ea; border-radius: 12px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); }
.km-card-title { font-weight: 800; font-size: 15px; margin-bottom: 8px; }
.km-preview { font-size: 13px; color: #5a6b72; margin-bottom: 10px; }
.km-meta { font-size: 12px; color: #a1b0b5; margin-bottom: 10px; }
.km-input-methods { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
.km-method-btn { flex: 1; min-width: 140px; padding: 12px; border-radius: 8px; border: 2px dashed #c7d2d4; background: #faf9f5; text-align: center; font-weight: 700; font-size: 13px; cursor: pointer; color: #5a6b72; }
.km-method-btn.active { border-color: #819b81; background: #f0f5f0; color: #2F3E46; }
.km-hint { font-size: 13px; color: #5a6b72; background: #f8fafc; border-left: 3px solid #819b81; padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; }
.km-textarea { width: 100%; height: 220px; padding: 14px; border: 1px solid #e5e9ea; border-radius: 8px; font-size: 14px; box-sizing: border-box; resize: vertical; }
.km-input { width: 100%; padding: 10px; border: 1px solid #e5e9ea; border-radius: 8px; font-size: 14px; box-sizing: border-box; margin-bottom: 10px; }
.km-btn { background: #819b81; color: #fff; border: none; padding: 12px 20px; border-radius: 8px; font-weight: 700; cursor: pointer; font-size: 14px; }
.km-btn:hover { background: #6d8a6d; }
.km-btn.km-btn-decline { background: #d63638; }
.km-btn-small { font-size: 12px; padding: 6px 14px; border-radius: 6px; border: none; font-weight: 700; cursor: pointer; }
.km-tag { display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 12px; margin-right: 6px; }
.km-tag-new { background: #e0f2fe; color: #0369a1; }
.km-tag-edit { background: #fef3c7; color: #92400e; }
.km-tag-source { background: #f1f5f9; color: #5a6b72; }
.km-status { text-align: center; font-size: 13px; font-weight: 700; height: 20px; margin: 10px 0; }
.km-empty { text-align: center; color: #a1b0b5; padding: 30px; }
</style>
<!-- =========================================================================
FRONTEND HTML
========================================================================= -->
<div class="km-wrapper">
<div class="km-header">
<h1>๐ Katie's Knowledge Manager</h1>
</div>
<div class="km-tabs">
<button class="km-tab active" data-panel="km-panel-existing">Existing Knowledge</button>
<button class="km-tab" data-panel="km-panel-new">Add New</button>
<?php if ($km_can_review): ?>
<button class="km-tab" data-panel="km-panel-review">Review Queue</button>
<button class="km-tab" data-panel="km-panel-declined">Declined</button>
<?php endif; ?>
</div>
<div id="km-status" class="km-status"></div>
<!-- EXISTING KNOWLEDGE -->
<div id="km-panel-existing" class="km-panel active">
<div id="km-existing-list"><p class="km-empty">Loading...</p></div>
</div>
<!-- ADD NEW -->
<div id="km-panel-new" class="km-panel">
<div class="km-input-methods">
<div class="km-method-btn active" data-method="text">โ๏ธ Type / Paste</div>
<div class="km-method-btn" data-method="txt">๐ Upload .txt</div>
<div class="km-method-btn" data-method="pdf">๐ Upload PDF</div>
<div class="km-method-btn" data-method="docx">๐ Upload Word Doc</div>
<div class="km-method-btn" data-method="xlsx">๐ Upload Excel</div>
</div>
<div id="km-method-text" class="km-method-panel">
<input type="text" id="km-new-filename" class="km-input" placeholder="Filename (e.g. apnea-analytics.txt) โ optional, we'll suggest one if left blank">
<textarea id="km-new-text" class="km-textarea" placeholder="Write or paste the knowledge here..."></textarea>
<button class="km-btn" onclick="kmSubmitText()">Submit for Review</button>
</div>
<div id="km-method-txt" class="km-method-panel" style="display:none;">
<input type="file" id="km-file-txt" accept=".txt" class="km-input">
<button class="km-btn" onclick="kmUploadFile('txt')">Upload & Submit for Review</button>
</div>
<div id="km-method-pdf" class="km-method-panel" style="display:none;">
<p class="km-hint">Pulls the text straight out of the PDF. Best for PDFs that contain real text, not a scanned image of a page.</p>
<input type="file" id="km-file-pdf" accept=".pdf" class="km-input">
<button class="km-btn" onclick="kmUploadFile('pdf')">Extract Text & Submit for Review</button>
</div>
<div id="km-method-docx" class="km-method-panel" style="display:none;">
<p class="km-hint">For an actual Word document (.docx). Pulls out all the written text, paragraph by paragraph. Tables and formatting are simplified to plain text.</p>
<input type="file" id="km-file-docx" accept=".docx" class="km-input">
<button class="km-btn" onclick="kmUploadFile('docx')">Extract Text & Submit for Review</button>
</div>
<div id="km-method-xlsx" class="km-method-panel" style="display:none;">
<p class="km-hint">For an actual Excel file (.xlsx). Reads the <strong>first sheet only</strong> and turns each row into one line, with columns separated by " | ". Best for simple tables โ a product list, an ingredient sheet โ not complex multi-sheet workbooks.</p>
<input type="file" id="km-file-xlsx" accept=".xlsx" class="km-input">
<button class="km-btn" onclick="kmUploadFile('xlsx')">Extract Data & Submit for Review</button>
</div>
</div>
<!-- REVIEW QUEUE -->
<?php if ($km_can_review): ?>
<div id="km-panel-review" class="km-panel">
<div id="km-review-list"><p class="km-empty">Loading...</p></div>
</div>
<!-- DECLINED (audit trail only โ nothing here can go live) -->
<div id="km-panel-declined" class="km-panel">
<div id="km-declined-list"><p class="km-empty">Loading...</p></div>
</div>
<?php endif; ?>
</div>
<script>
const kmCanReview = <?php echo $km_can_review ? 'true' : 'false'; ?>;
// --- TAB SWITCHING ---
document.querySelectorAll('.km-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.km-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.km-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.panel).classList.add('active');
if (tab.dataset.panel === 'km-panel-existing') loadExisting();
if (tab.dataset.panel === 'km-panel-review') loadReview();
if (tab.dataset.panel === 'km-panel-declined') loadDeclined();
});
});
// --- ADD NEW: METHOD SWITCHING ---
document.querySelectorAll('.km-method-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.km-method-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.km-method-panel').forEach(p => p.style.display = 'none');
btn.classList.add('active');
document.getElementById('km-method-' + btn.dataset.method).style.display = 'block';
});
});
function showStatus(msg, isError) {
const el = document.getElementById('km-status');
el.innerText = msg;
el.style.color = isError ? '#d63638' : '#46b450';
setTimeout(() => { el.innerText = ''; }, 4000);
}
async function postAction(action, extraData, isFormData) {
let body;
if (isFormData) {
body = extraData;
body.append('km_action', action);
} else {
body = new URLSearchParams({ km_action: action, ...extraData });
}
const res = await fetch(window.location.href, { method: 'POST', body: body });
return res.json();
}
// --- EXISTING KNOWLEDGE ---
async function loadExisting() {
const data = await postAction('list_live', {});
const container = document.getElementById('km-existing-list');
if (data.error) { container.innerHTML = `<p class="km-empty">${data.error}</p>`; return; }
if (!data.data.length) { container.innerHTML = '<p class="km-empty">No knowledge files yet.</p>'; return; }
container.innerHTML = data.data.map(f => `
<div class="km-card">
<div class="km-card-title">${f.filename}</div>
<div class="km-meta">${f.size} bytes ยท last modified ${f.modified}</div>
<div class="km-preview">${f.preview}...</div>
<button class="km-btn-small km-btn" onclick="editExisting('${f.filename}')">Edit (sends to review)</button>
</div>
`).join('');
}
async function editExisting(filename) {
const data = await postAction('get_live_file', { filename });
if (data.error) { showStatus(data.error, true); return; }
const newText = prompt('Edit content for ' + filename + ' (your edit goes to review, the live version is untouched until approved):', data.content);
if (newText === null) return;
const result = await postAction('submit_edit', { target_filename: filename, content: newText });
if (result.error) { showStatus(result.error, true); } else { showStatus('Edit submitted for review.'); }
}
// --- ADD NEW: TEXT ---
async function kmSubmitText() {
const content = document.getElementById('km-new-text').value.trim();
const filename = document.getElementById('km-new-filename').value.trim();
if (!content) { showStatus('Write something first.', true); return; }
const result = await postAction('submit_text', { content, suggested_filename: filename });
if (result.error) { showStatus(result.error, true); }
else {
showStatus('Submitted for review.');
document.getElementById('km-new-text').value = '';
document.getElementById('km-new-filename').value = '';
}
}
// --- ADD NEW: FILE UPLOADS (txt / pdf / image) ---
async function kmUploadFile(type) {
const input = document.getElementById('km-file-' + type);
if (!input.files.length) { showStatus('Choose a file first.', true); return; }
const fd = new FormData();
fd.append('file', input.files[0]);
showStatus('Processing...');
const result = await postAction('upload_' + type, fd, true);
if (result.error) { showStatus(result.error, true); }
else { showStatus('Submitted for review.'); input.value = ''; }
}
// --- REVIEW QUEUE ---
async function loadReview() {
if (!kmCanReview) return;
const data = await postAction('list_pending', {});
const container = document.getElementById('km-review-list');
if (data.error) { container.innerHTML = `<p class="km-empty">${data.error}</p>`; return; }
if (!data.data.length) { container.innerHTML = '<p class="km-empty">Nothing pending review.</p>'; return; }
container.innerHTML = data.data.map(row => {
const typeTag = row.action_type === 'edit'
? `<span class="km-tag km-tag-edit">EDIT of ${row.target_filename}</span>`
: `<span class="km-tag km-tag-new">NEW</span>`;
let aiMetaHtml = '';
if (row.ai_meta) {
const meta = JSON.parse(row.ai_meta);
aiMetaHtml = `<div class="km-meta">[AI] Tokens: ${meta.tokens} | Est. Cost: ${meta.cost}</div>`;
}
return `
<div class="km-card">
${typeTag} <span class="km-tag km-tag-source">${row.source_type}</span>
<div class="km-meta">Submitted by ${row.submitted_by_name} on ${row.submitted_at}</div>
${aiMetaHtml}
<div class="km-preview">${row.preview}...</div>
<button class="km-btn-small km-btn" onclick="approveItem(${row.id})">Approve</button>
<button class="km-btn-small km-btn km-btn-decline" onclick="declineItem(${row.id})">Decline</button>
</div>
`;
}).join('');
}
async function approveItem(id) {
const result = await postAction('approve', { id });
if (result.error) { showStatus(result.error, true); }
else {
showStatus(result.reload_ok ? 'Approved and live.' : 'Approved, but the reload call failed โ check the service is running.');
loadReview();
}
}
async function declineItem(id) {
const note = prompt('Optional note on why this was declined:') || '';
const result = await postAction('decline', { id, note });
if (result.error) { showStatus(result.error, true); } else { showStatus('Declined.'); loadReview(); }
}
// --- DECLINED (read-only audit trail) ---
async function loadDeclined() {
if (!kmCanReview) return;
const data = await postAction('list_declined', {});
const container = document.getElementById('km-declined-list');
if (data.error) { container.innerHTML = `<p class="km-empty">${data.error}</p>`; return; }
if (!data.data.length) { container.innerHTML = '<p class="km-empty">Nothing declined yet.</p>'; return; }
container.innerHTML = data.data.map(row => {
const typeTag = row.action_type === 'edit'
? `<span class="km-tag km-tag-edit">EDIT of ${row.target_filename}</span>`
: `<span class="km-tag km-tag-new">NEW</span>`;
const noteHtml = row.decline_note
? `<div class="km-meta"><strong>Decline note:</strong> ${row.decline_note}</div>`
: '<div class="km-meta"><em>No note given.</em></div>';
return `
<div class="km-card">
${typeTag} <span class="km-tag km-tag-source">${row.source_type}</span>
<div class="km-meta">Submitted by ${row.submitted_by_name} on ${row.submitted_at}</div>
<div class="km-meta">Declined by ${row.reviewed_by_name} on ${row.reviewed_at}</div>
${noteHtml}
<div class="km-preview">${row.preview}...</div>
</div>
`;
}).join('');
}
// --- INITIAL LOAD ---
document.addEventListener('DOMContentLoaded', loadExisting);
</script>
ss -tlnp | grep 8420
kill <PID>. Confirm the port is genuinely clear (the command above returns nothing) before continuing.
cat > /etc/systemd/system/katie-rag.service << 'EOF' [Unit] Description=Katie RAG Knowledge Service After=network.target mariadb.service [Service] Type=simple User=root WorkingDirectory=/var/katie-rag ExecStart=/var/katie-rag/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8420 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable katie-rag systemctl start katie-rag systemctl status katie-rag --no-pager
Restart=always is the whole point
This single line is what makes it resilient: if the process ever exits for any reason, systemd starts a fresh one automatically, waiting RestartSec=5 between attempts. enable means it also starts itself the moment the server boots, with no manual command needed at all.
systemctl status or journalctl can drop into a full-screen viewer that's easy to get stuck inside, especially over a remote terminal. Adding --no-pager to either command prints straight to the screen and returns immediately to a normal prompt — safer by default, every time.
OLDPID=$(systemctl show -p MainPID katie-rag --value) kill $OLDPID sleep 6 systemctl status katie-rag --no-pager curl "http://127.0.0.1:8420/ask?q=what%20is%20the%20secret%20test%20phrase"
Main PID number, active (running), and a real grounded answer straight afterward — the actual planted test phrase, correct. The whole chain survived a deliberate crash with nobody touching anything by hand.
/ask fails immediately after a restart, that's not necessarily a real failure — building the index involves loading several Python libraries, connecting to the database, and calling OpenAI to generate embeddings, which genuinely takes a few real seconds. Wait a little and try again before assuming something's actually broken.
systemctl status showed activating (auto-restart) with the restart counter climbing into the dozens. The actual cause, found in journalctl -u katie-rag -n 30 --no-pager: [Errno 98] address already in use. An old manual process from earlier in the day was still genuinely running and still holding port 8420 — every attempt to stop it had silently failed due to mistyping its PID (one digit short, a five-digit number instead of the real six-digit one), so kill kept reporting "no such process" against a PID that had simply never existed, while the real process kept running undisturbed. The fix: ask the kernel directly which process actually holds the port, rather than trust a remembered PID number:
ss -tlnp | grep 8420
pid=...), removing any chance of acting on a wrong or mistyped number.
Server confirmed on Plesk Obsidian 18.0.78.4, Ubuntu 24.04, running MariaDB 11.8.8, verified directly in Plesk's Database Servers list. A genuine daily automatic full-server backup ran throughout the upgrade. The official LlamaIndex–MariaDB connector is confirmed to exist, and is confirmed — from its own source code — to build its vector index with cosine distance automatically. Nothing outstanding on this point.
katie_rag databaseCreated via Plesk's Database Servers tool: database name katie_rag, on localhost:3306 (MariaDB 11.8.8), related to the bodysleepmind.com subscription. A dedicated user, chris3, was created alongside it — specifically without the "access to all databases within the subscription" option, so this login cannot reach WordPress's own database even by mistake. Access control set to local connections only, since nothing outside this server ever needs to reach it — main.py always connects as 127.0.0.1. This is the database referenced in main.py's connection settings (Step 6). The database name, katie_rag, is worth keeping consistent — but the username chris3 is just whatever was typed in on the day; pick anything on a future server, and match it in main.py's code.
chris3 on katie_ragTo check it: the database name and username aren't secret — both are visible directly in main.py's own code (Step 6), and in Plesk's Database Servers list. The password is the only real secret, and Plesk doesn't let you view a password again after it's set — so the actual current value in use is whatever's sitting in the secrets file:
cat /var/katie-rag/.env
To change it: two places need to agree, the database itself and the file main.py reads from.
- In Plesk: Databases → the
chris3user → set a new password (the same Generate-and-copy pattern as creating it the first time, Step 16) - Update the secrets file to match, editing the one line in place rather than recreating the whole file:
Find the
nano /var/katie-rag/.env
DB_PASSWORD=line, replace the value after the=, then save (Ctrl+O, Enter) and exit (Ctrl+X). - Restart the service so it actually picks up the new value — it only reads
.envonce, at startup (Step 7's command, run again)
/ask (Step 8) still answers correctly after the restart — if the password and the file disagree, the service fails to connect to the database at startup.
Editing Python files directly in an SSH terminal (using tools like nano) introduces risks of silent copy-paste corruption. For any future Python edits, using an admin-level SFTP connection (or the visual Plesk File Manager) is the safest and most reliable method.
- Connect: Log into your server using an IONOS Root FTP client (like FileZilla) with your root/admin credentials. This bypasses the normal "website jail" and lets you navigate directly to the
/var/katie-rag/folder. - Create an Instant Backup: Right-click the file you are about to change (e.g.
main.py) and rename it (e.g.main_old.py). If your new code breaks, you just delete the broken file and rename this backup back to its original name to restore the working version instantly. - Edit and Upload: Write or paste your new Python code in a normal text editor (like Notepad) on your own computer, save it, and drag-and-drop it into the FTP window.
- Restart Python for Changes: Python loads code into memory once at startup. Simply uploading a file does nothing on its own. You must force the server to dump the old memory and load your newly uploaded file. (Auto-restart on file changes is deliberately disabled so a half-uploaded FTP file doesn't crash the live agent).
The easiest SSH terminal to use is located at: Plesk > Tools and Settings > Tools & Resources > SSH Terminal.
The exact restart code to type into SSH is:
systemctl restart katie-rag
To check it: the live key value itself can't be retrieved from OpenAI's own dashboard once created — only the last few characters are ever shown there again. To see what's actually configured on this server:
- WordPress side — check the
OPENAI_API_KEYconstant inwp-config.php(every PHP file that calls OpenAI reads from this one constant, not its own copy) - Python side —
cat /var/katie-rag/.env, same command as the database password above - Compare the last few characters of each against platform.openai.com/api-keys to confirm which named key is actually in use
To change it: the same "create new, switch over, confirm, then revoke the old one" sequence used when masterKey itself replaced the previous exposed key.
- Generate a new secret key at platform.openai.com/api-keys
- Update
wp-config.php'sOPENAI_API_KEYconstant to the new value — every PHP file referencing it updates automatically, nothing else to touch on the WordPress side - Update the Python side the same way as the database password:
nano /var/katie-rag/.env, replace theOPENAI_API_KEY=line, save and exit - Restart the service so
main.pypicks up the new value - Test everything that depends on it — Katie's chat, the food analysers,
/ask— before revoking the old key in the OpenAI dashboard. Never revoke first and check second.
wp-config.php only.
Anyone with real knowledge of a subject — a wellness specialist, a partner brand, a member of the BSM team — can extend what Katie knows. Writing that knowledge down well is the actual skill involved; nothing about getting it in front of Katie requires writing or running code.
For example: BSM wants Katie to properly explain its apnea analytics. A specialist writes up what the score measures and what it means for a user. It's submitted, reviewed, approved — and tested with a direct question before it's trusted. Katie can now discuss apnea analytics accurately, in a conversation a user already trusts.
The same path extends outward, by design: a partner brand's own product knowledge follows this exact process to reach Katie too — their own people, writing what they know, reviewed before it goes live. Nothing about that path is different from BSM's own content going through it.
What a knowledge admin actually has in front of them. Two tools cover Phase Two on day one; the list grows from there without touching the engine underneath, because the format knowledge arrives in and the engine that searches it are kept deliberately separate (Architecture Rule 2).
- Build Plan Group 1, environment (Steps 1–2) — done
- Build Plan Group 2, knowledge folder and permissions (Step 3) — done; initial content (Step 4) arrives via the knowledge manager, ongoing
- Build Plan Group 3, secrets file and
main.py(Steps 5–6) — done - Build Plan Group 3, actually running and testing the service (Steps 7–8) — done, proven end to end with a real grounded answer; real friction along the way documented in full (anchor: Lessons from the real first run)
- Build Plan Group 4, WordPress bridge and Katie's conversation (Steps 9–10) — done, live, proven through the real chat widget (Sections E–F)
- Build Plan Group 5, the knowledge manager (Steps 11–13) — done, built and live as
knowledge-manager.php - Build Plan Group 5, resilience (Step 14) — done, a real systemd service, tested by deliberately killing the running process and confirming it came back on its own (Section G)
- Build Plan Group 6, the MariaDB 11.8 upgrade (Step 15) — done, confirmed 11.8.8 (Section H)
- Build Plan Group 6, the dedicated
katie_ragdatabase (Step 16) — done (Section H) - Genuinely open by choice, not by gap: final confirmation of access-level defaults (Section I), and whether Katie's answers should ever blend with GOD JSON (Section F) — both deliberately deferred, not forgotten
- Confirmed: Python 3.12.3 already present on the server
- Confirmed: root terminal access built directly into the existing Plesk panel
- Standard practice on this server's OS: install into a virtual environment, fully isolated from anything else the server runs
- Two source documents identified: the Product Knowledge Reference and the BSM User Management Guide
- Next action: convert both into plain text for the knowledge folder
- Design note: the feature/tier comparison table reads better in this format as prose — a table's structure doesn't carry over to plain text
- Done:
main.pywritten in full (Build Plan Group 3, Step 6), storing vectors directly in MariaDB 11.8 via the official connector, with both/askand/reloadbuilt in from the start, secrets loaded from.envrather than hardcoded - Done: the OpenAI key (masterKey, via the same
.envfile) and the dedicatedkatie_ragdatabase/user (chris3) both exist and are referenced correctly in the code - Done: the service started successfully and answered a real question correctly — the full chain proven end to end for the first time (Steps 7–8)
- The first real run surfaced genuine friction, none of it architectural — a skipped file-creation step, a corrupted large paste, a stray semicolon in the database password, curl rejecting spaces in a URL. Every one diagnosed and fixed; full detail kept in "Lessons from the real first run," right after Step 8, specifically so it doesn't need re-discovering on a future server rebuild
- Rebuilds its full index on every restart and on every
/reloadcall — efficient at today's scale; revisit once the knowledge base is large enough for that to matter - Package version pinned to
llama-index-vector-stores-mariadb>=0.3.0— earlier versions only support the old 11.6 preview syntax
- Pass criteria: a sensible answer, genuinely drawn from the saved documents
- A wrong or vague answer points to the source document, not the code
- Done: confirmed for real, using the planted test phrase in
knowledge.txt— asked "what is the secret test phrase," got back the exact real answer, "BLUE ELEPHANTS DANCE AT MIDNIGHT," not a general-knowledge guess (Step 8)
- Done: built directly into
triage_backend.php's?ai=1handler — awp_remote_get()call to127.0.0.1:8420, internal only (Architecture Rule 4) - Decided: access scope is unchanged from before — whoever could already reach the
?ai=1handler still can; no new restriction added or needed - Decided: fallback behaviour is a clear, honest message, never a silent reversion to the old approach (Section F)
- Real bug found and fixed: a strict
===comparison on the HTTP response code silently failed because WordPress can return it as a string, not an integer; fixed by casting to(int)before comparing - Real mistake found and fixed: a second edit of this file was accidentally built from the original uploaded copy instead of the already-corrected one, briefly reintroducing the old hardcoded key; caught before upload, not after
- Done: Katie's chat now genuinely calls
/askfirst, replacing the oldknowledge.txtcontext-stuffing approach entirely in that one handler - Decided: no blending with GOD JSON (Architecture Rule 3) for now — the retrieved answer is shown as-is. Not a default or a guess; a deliberate choice to revisit case by case if it's ever actually wanted
- Decided: no silent fallback to the old context-stuffing approach if the knowledge service is unreachable — that would hide a real outage and cost money every time it happened. Katie returns a clear, honest message instead, with no further OpenAI call at all
- Verification idea, reused successfully: the planted test phrase in
knowledge.txt("BLUE ELEPHANTS DANCE AT MIDNIGHT"), asked through the real live chat widget, not just over curl — confirmed working end to end
- Done: registered as a real systemd service (
/etc/systemd/system/katie-rag.service),enabledso it starts on boot,Restart=alwaysso it restarts itself if it ever crashes - Done: actually tested, not just configured — the running process was deliberately killed, and confirmed a brand new process took its place on its own within seconds, with
/askanswering correctly straight afterward - Real obstacle found and fixed: a port conflict from an old manual process that was never actually stopped, caused by repeatedly mistyping its PID by one digit; every
killattempt silently targeted a PID that never existed, while the real process kept running undisturbed. Fixed by asking the kernel directly (ss -tlnp | grep 8420) for the real PID rather than relying on a remembered number
Run ahead of the rest of this build, ahead of need, so the database was ready well before Katie depends on it (Architecture Rule 6).
- Done: server confirmed on MariaDB 11.8.8, verified directly in Plesk's Database Servers list, data checked and sound post-upgrade
- Done: a genuine daily automatic full-server backup remained active throughout, independent of Plesk's own backup-before-upgrade step
- Done: the official LlamaIndex–MariaDB connector confirmed to exist (
llama-index-vector-stores-mariadb, version 0.3.0+), maintained by the LlamaIndex project, recognised by MariaDB's own foundation - Done: confirmed directly from the connector's own source code that it builds its vector index with
DISTANCE=cosineautomatically, every time it sets up the table — not MariaDB's own Euclidean default. Nothing further to configure or check on this point. - Done: the dedicated
katie_ragdatabase created (Step 16) — its own database, its own narrowly-scoped login (chris3), local connections only, used only by the Python service. Not WordPress's database, and WordPress gets no login to it at all (see Glossary: "Why the vectors get their own dedicated database").
The submission page, review queue, and reload address are built in Build Plan Group 5, Steps 11–13. A default access split already exists in the code, deliberately flagged there as a placeholder rather than a final decision:
- Current default: submitting requires
edit_posts(any Contributor+) — open, not yet confirmed as the right scope (Step 11) - Current default: reviewing and approving requires
manage_options(Administrators only) — intentionally a smaller, more trusted group than who can submit (Step 12) - Next decision: confirm both defaults are actually what BSM wants, or adjust the
current_user_can()checks inknowledge-manager.phpaccordingly
Python uses spaces (indentation) instead of curly brackets {}. When the spaces stop, Python knows the function is over. Here is the main.py script broken down with direct PHP/JS analogies.
In PHP, you use
require_once or Composer's use. In Python, this is import.
import os, glob: Loads Python's built-in OS and file-searching tools (identical to PHP'sglob()).from [package] import [tool]: Loads only a specific tool from a package to save memory.FastAPI: The tool that creates the web server and URLs (like Laravel's router).
load_dotenv(...): Opens the hidden.envfile and loads it into memory.os.environ["OPENAI_API_KEY"] = ...: Python's version of$_ENV. Locks the key into the system environment so AI tools find it automatically.DB_PASSWORD = ...: Saves the password to a variable (Like PHP's$DB_PASSWORD = getenv("DB_PASSWORD");).
Settings.llm = OpenAI(...): Instantiating the OpenAI class globally (In PHP:$settings->llm = new OpenAI(...);).temperature=0.0: Kills creativity to stop random guessing.
vector_store = MariaDBVectorStore...: Creates a database connection object (Like PHP's$db = new PDO(...)).embed_dim=1536: Tells MariaDB the exact array length of an OpenAI vector.
query_engine = None: Like$query_engine = null;. Declared outside the function to share it globally.def build_index():: Python's way of writingfunction build_index() {.global query_engine: Exactly like PHP'sglobal $query_engine;.if not files: return: In PHP,if (empty($files)) { return; }.query_engine = index.as_query_engine(node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.70)]): The 0.70 mathematical guardrail. Filters out chunks that score lower than 70%.
app = FastAPI(): Starts the web server router.@app.get("/ask"): A decorator. If someone visits/askvia GET, run the function below.def ask(q: str):: Automatically grabs?q=from the URL as a string (Like PHP's$q = $_GET['q'];).return {"answer": ...}: FastAPI automatically converts Python dictionaries into JSON strings (Like PHP'secho json_encode(...); exit;).valid_nodes = [n for n in nodes if n.score >= 0.70]: A "List Comprehension". Python's ultra-clean 1-line version of PHP'sarray_filter().
len(...): Counts the files. Identical to PHP'scount().