Add three chat profiles: Varaverk Assistant, General Chat, Code Sketcher
Explicit buttons rather than an automatic router. Misclassifying a Varaverk question as chat produces a confident invention about the user's system, which is exactly what retrieval exists to prevent — with buttons there is no hidden heuristic to be wrong and the strict profile is the default you land on. Only Varaverk Assistant retrieves; the other two would be carrying passages that cannot help write a folder-copy script. Memory goes to all three, since that is what lets chat know the setup without claiming authority over it. History depth is per profile and set server-side: retrieval costs ~2500 of 16384, so the profiles that skip it can hold a real conversation. Code Sketcher is told to flag flags it is unsure of, after it invented rsync --no-overwrite.
This commit is contained in:
@@ -82,11 +82,14 @@ if (PHP_SAPI !== 'cli') {
|
||||
|
||||
require_once dirname(__DIR__) . '/include/ai.php';
|
||||
|
||||
[$jobFile, $question, $historyJson, $kind, $think] = array_slice($argv, 1, 5) + array_fill(0, 5, '');
|
||||
[$jobFile, $question, $historyJson, $kind, $think, $profile] =
|
||||
array_slice($argv, 1, 6) + array_fill(0, 6, '');
|
||||
|
||||
if ($jobFile === '' || $question === '') exit(1);
|
||||
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
|
||||
|
||||
$profile = in_array($profile, ['varaverk', 'chat', 'code'], true) ? $profile : 'varaverk';
|
||||
|
||||
function jw(string $f, array $d): void {
|
||||
file_put_contents($f, json_encode($d));
|
||||
}
|
||||
@@ -94,40 +97,47 @@ function jw(string $f, array $d): void {
|
||||
$cfg = vv_ai_config();
|
||||
$t0 = microtime(true);
|
||||
|
||||
jw($jobFile, ['status' => 'retrieving']);
|
||||
// Only the Varaverk profile retrieves. The other two answer from memory and the model's own
|
||||
// knowledge, so passages would be noise competing for context — documentation cannot help write
|
||||
// a folder-copy script, and it has nothing to say about how someone's day is going.
|
||||
$sources = [];
|
||||
$context = '';
|
||||
$tRetrieve = 0.0;
|
||||
|
||||
$r = vv_ai_retrieve($question, $kind);
|
||||
if (!$r['ok']) {
|
||||
jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
|
||||
exit(1);
|
||||
}
|
||||
if (!$r['results']) {
|
||||
jw($jobFile, ['status' => 'error',
|
||||
'error' => 'No relevant documentation found. Try rephrasing, or use the readme filter '
|
||||
. 'for questions about what something is.']);
|
||||
exit(0);
|
||||
}
|
||||
if ($profile === 'varaverk') {
|
||||
jw($jobFile, ['status' => 'retrieving']);
|
||||
|
||||
$tRetrieve = microtime(true) - $t0;
|
||||
$r = vv_ai_retrieve($question, $kind);
|
||||
if (!$r['ok']) {
|
||||
jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
|
||||
exit(1);
|
||||
}
|
||||
if (!$r['results']) {
|
||||
jw($jobFile, ['status' => 'error',
|
||||
'error' => 'No relevant documentation found. Try rephrasing, use the readme filter '
|
||||
. 'for questions about what something is, or switch to General Chat if this '
|
||||
. 'is not a Varaverk question.']);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$sources = array_map(fn($x) => [
|
||||
'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
|
||||
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
|
||||
], $r['results']);
|
||||
$tRetrieve = microtime(true) - $t0;
|
||||
|
||||
jw($jobFile, ['status' => 'generating', 'sources' => $sources]);
|
||||
$sources = array_map(fn($x) => [
|
||||
'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
|
||||
'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
|
||||
], $r['results']);
|
||||
|
||||
$context = '';
|
||||
foreach ($r['results'] as $i => $x) {
|
||||
$label = implode(' › ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
|
||||
$context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
|
||||
foreach ($r['results'] as $i => $x) {
|
||||
$label = implode(' › ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
|
||||
$context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic questions get live state as well as documentation. The docs say what a script is
|
||||
// supposed to do; only the logs and the current config say what it actually did. Attached only
|
||||
// when the question is asking why something failed — otherwise it is a few thousand tokens of
|
||||
// noise competing with the retrieved passages for a context budget that is already tight.
|
||||
$diagnostic = (bool)preg_match(
|
||||
$diagnostic = $profile === 'varaverk' && (bool)preg_match(
|
||||
'/\b(why|fail(ed|ing|ure)?|error|broken?|not work|isn.t work|wrong|stuck|hang|'
|
||||
. 'never runs?|didn.t|won.t|debug|troubleshoot|diagnos)/i',
|
||||
$question
|
||||
@@ -155,12 +165,46 @@ if ($diagnostic) {
|
||||
}
|
||||
}
|
||||
|
||||
$system = "You are Varaverk's documentation assistant. Varaverk is this user's private "
|
||||
. "two-server Unraid media ecosystem; it is not in your training data, so the material "
|
||||
. "below is the only thing you know about it.\n\n"
|
||||
. "Answer only from this material and cite the passages inline as [1], [2]. If it does "
|
||||
. "not contain the answer, say so plainly and name what is missing — do not fill the "
|
||||
. "gap from general knowledge. Prefer the user's own terminology.\n\n";
|
||||
// One prompt per profile. Explicit profiles rather than an automatic router: misclassifying a
|
||||
// Varaverk question as chat produces a confident invention about the user's system, which is
|
||||
// precisely what retrieval exists to prevent. The user always knows which contract is in force,
|
||||
// and the strict profile is the default.
|
||||
if ($profile === 'chat') {
|
||||
$system = "You are the Varaverk assistant, talking with the operator of a private two-server "
|
||||
. "Unraid media ecosystem called Varaverk. This is ordinary conversation — no "
|
||||
. "documentation has been retrieved for it.\n\n"
|
||||
. "Talk like a knowledgeable colleague. Be warm and direct, follow a tangent if one "
|
||||
. "is interesting, and use your general knowledge freely.\n\n"
|
||||
. "The one hard rule: you have NOT been given Varaverk's documentation in this mode. "
|
||||
. "Anything you know about their setup comes from the memory section below and from "
|
||||
. "what they tell you. If they ask something specific about how Varaverk works — a "
|
||||
. "script, a config variable, a safeguard — say you would need the Varaverk Assistant "
|
||||
. "profile for that, and do not guess. Being wrong about their own system is worse "
|
||||
. "than sending them one click away.\n\n";
|
||||
|
||||
} elseif ($profile === 'code') {
|
||||
$system = "You are drafting a short shell script for the operator of an Unraid server, to be "
|
||||
. "saved as a Varaverk Custom Script. Assume bash on Unraid: GNU coreutils, paths "
|
||||
. "under /mnt/user, no systemd, and no packages beyond what Unraid ships.\n\n"
|
||||
. "Write the smallest script that does the job. Include a shebang, quote variables, "
|
||||
. "check that inputs exist, and exit non-zero on failure. Prefer plain coreutils and "
|
||||
. "rsync over anything exotic.\n\n"
|
||||
. "This is the important part: you sometimes invent command-line flags that sound "
|
||||
. "plausible but do not exist. After the script, list every flag or command you are "
|
||||
. "not completely certain about, and say how to check it — 'verify with rsync --help' "
|
||||
. "or similar. A stated assumption the operator can check beats a confident mistake "
|
||||
. "they run at 3am. If you are unsure a flag exists, say so rather than picking the "
|
||||
. "one that sounds right.\n\n"
|
||||
. "Treat this as a first draft to be tested, and say so.\n\n";
|
||||
|
||||
} else {
|
||||
$system = "You are Varaverk's documentation assistant. Varaverk is this user's private "
|
||||
. "two-server Unraid media ecosystem; it is not in your training data, so the material "
|
||||
. "below is the only thing you know about it.\n\n"
|
||||
. "Answer only from this material and cite the passages inline as [1], [2]. If it does "
|
||||
. "not contain the answer, say so plainly and name what is missing — do not fill the "
|
||||
. "gap from general knowledge. Prefer the user's own terminology.\n\n";
|
||||
}
|
||||
|
||||
// Memory first, before the passages. It is the standing context — who the operator is and what
|
||||
// has already been decided — so it should frame everything that follows rather than read as one
|
||||
@@ -190,7 +234,7 @@ if ($diagBlock !== '') {
|
||||
. $diagBlock;
|
||||
}
|
||||
|
||||
$system .= "PASSAGES\n" . $context;
|
||||
if ($context !== '') $system .= "PASSAGES\n" . $context;
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $system]];
|
||||
$hist = json_decode($historyJson ?: '[]', true);
|
||||
|
||||
@@ -89,7 +89,12 @@ header('Content-Type: application/json');
|
||||
header('Cache-Control: no-store, no-cache');
|
||||
require_once dirname(__DIR__) . '/include/ai.php';
|
||||
|
||||
const VV_AI_MAX_TURNS = 3; // user+assistant pairs retained; see OPERATIONAL MODEL
|
||||
// History depth is per profile, and decided here rather than by the page. Varaverk Assistant
|
||||
// spends ~2500 of its 16384 on retrieved passages, so it cannot afford deep history; the other
|
||||
// two retrieve nothing and can carry a real conversation. Reasoning is not stored in history,
|
||||
// so it does not compound.
|
||||
const VV_AI_PROFILES = ['varaverk' => 3, 'chat' => 8, 'code' => 4];
|
||||
const VV_AI_MAX_TURNS = 3; // fallback when a profile is not recognised
|
||||
const VV_AI_MAX_QUESTION = 4000; // characters
|
||||
const VV_AI_MAX_HIST_MSG = 4000; // characters per retained message
|
||||
const VV_AI_JOB_TTL = 3600; // seconds before a job file is reaped
|
||||
@@ -179,7 +184,14 @@ if ($action === 'ask') {
|
||||
echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit;
|
||||
}
|
||||
|
||||
$kind = trim($_POST['kind'] ?? '');
|
||||
$profile = trim($_POST['profile'] ?? 'varaverk');
|
||||
if (!isset(VV_AI_PROFILES[$profile])) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
|
||||
}
|
||||
$maxTurns = VV_AI_PROFILES[$profile] ?? VV_AI_MAX_TURNS;
|
||||
|
||||
// The retrieval filter only means anything to the profile that retrieves.
|
||||
$kind = $profile === 'varaverk' ? trim($_POST['kind'] ?? '') : '';
|
||||
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit;
|
||||
}
|
||||
@@ -196,8 +208,8 @@ if ($action === 'ask') {
|
||||
$clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
|
||||
}
|
||||
}
|
||||
if (count($clean) > VV_AI_MAX_TURNS * 2) {
|
||||
$clean = array_slice($clean, -(VV_AI_MAX_TURNS * 2));
|
||||
if (count($clean) > $maxTurns * 2) {
|
||||
$clean = array_slice($clean, -($maxTurns * 2));
|
||||
}
|
||||
|
||||
$dir = vv_ai_job_dir();
|
||||
@@ -226,12 +238,13 @@ if ($action === 'ask') {
|
||||
. escapeshellarg($question) . ' '
|
||||
. escapeshellarg(json_encode($clean)) . ' '
|
||||
. escapeshellarg($kind) . ' '
|
||||
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0')
|
||||
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' '
|
||||
. escapeshellarg($profile)
|
||||
. ' >/dev/null 2>&1 </dev/null &';
|
||||
$out = []; $rc = 0;
|
||||
exec($cmd, $out, $rc);
|
||||
vv_ai_log(sprintf('ask token=%s rc=%d kind=%s q=%s',
|
||||
substr($token, 0, 12), $rc, $kind ?: '-', mb_substr($question, 0, 80)));
|
||||
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
|
||||
substr($token, 0, 12), $rc, $profile, $kind ?: '-', mb_substr($question, 0, 80)));
|
||||
|
||||
echo json_encode(['ok' => true, 'token' => $token]);
|
||||
exit;
|
||||
|
||||
@@ -83,6 +83,16 @@ if (is_dir('/var/log/varaverk')) {
|
||||
.vv-ai-warn { color:#ffb74d !important; }
|
||||
.vv-ai-bad { color:#e57 !important; }
|
||||
|
||||
/* ── Profiles ───────────────────────────────────────────────────────────── */
|
||||
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
||||
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
|
||||
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
|
||||
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
|
||||
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
|
||||
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
|
||||
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
|
||||
border-top:1px dashed #1e1e1e; padding-top:8px; }
|
||||
|
||||
/* ── Chat ───────────────────────────────────────────────────────────────── */
|
||||
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
|
||||
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
|
||||
@@ -180,6 +190,13 @@ if (is_dir('/var/log/varaverk')) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vv-ai-profiles">
|
||||
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
|
||||
<button class="vv-ai-prof" data-prof="chat" type="button">General Chat</button>
|
||||
<button class="vv-ai-prof" data-prof="code" type="button">Code Sketcher</button>
|
||||
<span class="vv-ai-prof-hint" id="vv-ai-prof-hint"></span>
|
||||
</div>
|
||||
|
||||
<div class="vv-ai-chat" id="vv-ai-chat">
|
||||
<div class="vv-ai-empty">
|
||||
Ask Varaverk about itself.<br>
|
||||
@@ -238,11 +255,23 @@ if (is_dir('/var/log/varaverk')) {
|
||||
<script>
|
||||
(function () {
|
||||
const API = '/plugins/varaverk/api/ai.php';
|
||||
const MAXTURN = 3;
|
||||
const POLL_MS = 1200;
|
||||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||||
|
||||
let history = []; // {role, content} — trimmed to MAXTURN pairs
|
||||
// Server-side is the authority on retrieval and history depth; these are for the UI only.
|
||||
// Always starts on varaverk — the strict profile is the one you land on, so a misuse costs a
|
||||
// "the docs don't cover that" rather than an invented claim about the system.
|
||||
const PROFILES = {
|
||||
varaverk: { turns: 3, kind: true,
|
||||
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
|
||||
chat: { turns: 8, kind: false,
|
||||
hint: 'Ordinary conversation. Knows your memory notes, but not the docs — it\'ll point you back here for specifics.' },
|
||||
code: { turns: 4, kind: false,
|
||||
hint: 'Drafts short scripts for Custom Scripts. First drafts — it flags flags it isn\'t sure of. Test before trusting.' },
|
||||
};
|
||||
|
||||
let profile = 'varaverk';
|
||||
let history = []; // {role, content} — trimmed per profile
|
||||
let busy = false;
|
||||
let lastSources = [];
|
||||
|
||||
@@ -463,9 +492,10 @@ if (is_dir('/var/log/varaverk')) {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
action: 'ask',
|
||||
profile: profile,
|
||||
question: q,
|
||||
history: JSON.stringify(history.slice(-MAXTURN * 2)),
|
||||
kind: $('vv-ai-kind').value,
|
||||
history: JSON.stringify(history.slice(-PROFILES[profile].turns * 2)),
|
||||
kind: PROFILES[profile].kind ? $('vv-ai-kind').value : '',
|
||||
think: $('vv-ai-think').checked ? '1' : '0',
|
||||
});
|
||||
res = fetch(API, {
|
||||
@@ -516,7 +546,8 @@ if (is_dir('/var/log/varaverk')) {
|
||||
if (j.status === 'done') {
|
||||
addAnswer(j);
|
||||
history.push({ role: 'assistant', content: j.answer });
|
||||
if (history.length > MAXTURN * 2) history = history.slice(-MAXTURN * 2);
|
||||
const cap = PROFILES[profile].turns * 2;
|
||||
if (history.length > cap) history = history.slice(-cap);
|
||||
fetch(API, { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||||
@@ -547,6 +578,32 @@ if (is_dir('/var/log/varaverk')) {
|
||||
if (s && s.path) vvAiOpen(s.path);
|
||||
};
|
||||
|
||||
// ── Profiles ────────────────────────────────────────────────────────────
|
||||
// Switching clears the conversation history sent to the model but leaves the transcript on
|
||||
// screen. Carrying turns across a profile change would mean feeding cited, retrieval-grounded
|
||||
// answers into a mode that has no retrieval — the model would keep referring to sources it can
|
||||
// no longer see. The visible marker is so the transcript still reads honestly afterwards.
|
||||
function setProfile(p) {
|
||||
if (!PROFILES[p] || p === profile) return;
|
||||
profile = p;
|
||||
document.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.prof === p));
|
||||
$('vv-ai-prof-hint').textContent = PROFILES[p].hint;
|
||||
$('vv-ai-kind').style.display = PROFILES[p].kind ? '' : 'none';
|
||||
|
||||
if (history.length) {
|
||||
const label = document.querySelector('.vv-ai-prof[data-prof="' + p + '"]').textContent;
|
||||
chat().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(label)
|
||||
+ ' — earlier turns are no longer carried</div>'));
|
||||
scroll();
|
||||
}
|
||||
history = [];
|
||||
$('vv-ai-input').focus();
|
||||
}
|
||||
document.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||||
b.addEventListener('click', () => setProfile(b.dataset.prof)));
|
||||
$('vv-ai-prof-hint').textContent = PROFILES[profile].hint;
|
||||
|
||||
// ── Memory panel ────────────────────────────────────────────────────────
|
||||
// Live character count against the cap, because the budget is the whole point: this text is
|
||||
// prepended to every single turn and competes with retrieval for a 16k context.
|
||||
|
||||
Reference in New Issue
Block a user