mock: give the chat assistant a tool-calling agent loop
The LLM now orchestrates the plan instead of only answering from a
static snapshot. It reads the live plan, calls deterministic client-side
tools, and proposes small fix-ops; the tools validate, compute and
commit, so every edit is undo-able (LLM proposes, tools decide).
This llama.cpp build ignores the native OpenAI `tools` field, so tool
calls use a text protocol: the model emits <tool>{"name","args"}</tool>,
the app executes it and feeds the JSON result back as the next turn; the
loop ends when the model stops calling (capped at 10 turns).
Tools: itinerary_summary, search_places, place_facts, route_between
(real OSRM walk, straight-line fallback), review_plan (day overrun /
meal-hour / duplicate checks), and fix-ops add_stop, remove_stop,
move_stop, set_duration, set_start. Places and stops resolve by id or
name; dayIds are surfaced in the plan snapshot ([D1 · date]) so the model
doesn't guess. A subtle ⚙ activity line shows each tool call in the chat.
Verified end-to-end in headless Chrome: factual routing (route_between),
search+swap (search_places→add_stop, incumbent demoted to backup), and
diagnose+fix (review_plan→move_stop→set_start→re-review), with clean
undo-able commits each time.
This commit is contained in:
parent
af63297297
commit
c7b5ecb8da
260
mock/app.js
260
mock/app.js
|
|
@ -60,30 +60,250 @@ function llmContext() {
|
|||
layout(dd);
|
||||
const stops = dd.stops.filter(s => s.state === 'planned').map(
|
||||
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
|
||||
L.push(`${dd.label} [${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
|
||||
L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
|
||||
}
|
||||
return L.join('\n');
|
||||
}
|
||||
function askLLM(v) {
|
||||
const t = typing();
|
||||
// ---------------- LLM agent: tools + calling loop ----------------
|
||||
// The model is the orchestrator: it reads the plan, calls deterministic
|
||||
// tools (search / route / review), and proposes small fix-ops (add / remove /
|
||||
// move / re-time). The tools decide — they validate, compute, and commit, so
|
||||
// every edit is undo-able. This llama.cpp build does not honor the native
|
||||
// OpenAI `tools` field, so tool calls use a text protocol: the model emits
|
||||
// <tool>{"name":...,"args":{...}}</tool>, we execute it, and feed the JSON
|
||||
// result back as the next turn; the loop ends when the model stops calling.
|
||||
const LLM_TOOL_MAX = 10; // max tool-call turns per user message
|
||||
|
||||
// Live-state lookups (the source of truth is `days`, not the original M.days)
|
||||
function liveStopById(id) {
|
||||
for (const d of Object.values(days)) { const s = d.stops.find(x => x.id === id); if (s) return s; }
|
||||
return null;
|
||||
}
|
||||
function candById(id) {
|
||||
for (const cat of Object.keys(M.candidates)) {
|
||||
const c = M.candidates[cat].find(x => x.id === id);
|
||||
if (c) return { ...c, cat };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Resolve a model-given place ref — "hotel", a stop/candidate id, or a name
|
||||
// (exact then substring) — to {name, at, id}
|
||||
function resolvePlace(ref) {
|
||||
const q = String(ref || '').trim(); const ql = q.toLowerCase();
|
||||
if (!q) return null;
|
||||
if (ql === 'hotel' || ql === 'base' || ql === 'the hotel' || ql === 'my hotel') {
|
||||
const b = dayBase(); return { name: b.name, at: b.at, id: b.id || 'hotel' };
|
||||
}
|
||||
const s = liveStopById(q); if (s) return { name: s.name, at: s.at, id: s.id };
|
||||
const c = candById(q); if (c) return { name: c.name, at: c.at, id: c.id };
|
||||
const all = [];
|
||||
Object.values(days).forEach(d => d.stops.forEach(x => all.push(x)));
|
||||
Object.values(M.candidates).forEach(pool => pool.forEach(x => all.push(x)));
|
||||
const hit = all.find(x => x.name.toLowerCase() === ql) || all.find(x => x.name.toLowerCase().includes(ql));
|
||||
return hit ? { name: hit.name, at: hit.at, id: hit.id } : null;
|
||||
}
|
||||
// Resolve a live stop by id or name (the model often names stops, not ids)
|
||||
function resolveStop(ref) {
|
||||
const s = liveStopById(ref); if (s) return s;
|
||||
const q = String(ref || '').trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
for (const d of Object.values(days)) {
|
||||
const hit = d.stops.find(x => x.name.toLowerCase() === q) || d.stops.find(x => x.name.toLowerCase().includes(q));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const TOOLS = {
|
||||
// ---- queries (read-only) ----
|
||||
itinerary_summary: () => ({ plan: llmContext() }),
|
||||
|
||||
search_places: (a) => {
|
||||
const cat = String(a.category || '').toLowerCase();
|
||||
if (!M.candidates[cat]) return { error: `unknown category "${a.category}"; use ${Object.keys(M.candidates).join(', ')}` };
|
||||
let region = null;
|
||||
if (a.region) {
|
||||
region = REGION_MAP[String(a.region).toLowerCase()];
|
||||
if (!region) return { error: `unknown region "${a.region}"; use ${Object.keys(REGION_MAP).join(', ')} or omit` };
|
||||
}
|
||||
const results = poolFor(cat, region).map(c => ({
|
||||
id: c.id, name: c.name, price_eur: c.price, dur_min: c.dur, tags: c.tags,
|
||||
pitch: c.pitch, walk_from_hotel_min: hotelWalkMin(c.at),
|
||||
}));
|
||||
return { category: cat, region: a.region || null, count: results.length, results };
|
||||
},
|
||||
|
||||
place_facts: (a) => {
|
||||
const p = resolvePlace(a.id || a.name);
|
||||
if (!p) return { error: `no place matching "${a.id || a.name}"` };
|
||||
const c = candById(p.id) || liveStopById(p.id);
|
||||
return { name: p.name, price_eur: c?.price, dur_min: c?.dur, tags: c?.tags, pitch: c?.pitch,
|
||||
walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null };
|
||||
},
|
||||
|
||||
route_between: async (a) => {
|
||||
const A = resolvePlace(a.from), B = resolvePlace(a.to);
|
||||
if (!A) return { error: `cannot resolve "from" = "${a.from}"` };
|
||||
if (!B) return { error: `cannot resolve "to" = "${a.to}"` };
|
||||
if (A.id === B.id || A.name === B.name) return { from: A.name, to: B.name, minutes: 0, km: 0, source: 'same place' };
|
||||
let minutes, km, source;
|
||||
const res = await routePts([A.at, B.at], 'foot');
|
||||
if (res) { minutes = res.durMin; km = Math.round(res.distance / 100) / 10; source = 'local router (OSRM walk)'; }
|
||||
else { minutes = walkMin(A.at, B.at); km = Math.round(haversine(A.at, B.at) / 100) / 10;
|
||||
source = routerOn ? 'straight-line est (outside router coverage)' : 'straight-line est (router offline)'; }
|
||||
return { from: A.name, to: B.name, minutes, km, source };
|
||||
},
|
||||
|
||||
review_plan: (a) => {
|
||||
const ids = a.dayId ? [a.dayId] : Object.keys(days);
|
||||
const issues = [];
|
||||
for (const id of ids) {
|
||||
const d = days[id]; if (!d) { issues.push(`${id}: not a day`); continue; }
|
||||
layout(d);
|
||||
const prim = d.stops.filter(s => s.state === 'planned');
|
||||
const planned = prim.reduce((t, s) => t + s.dur, 0) + d.legs.reduce((t, g) => t + (g?.dur || 0), 0);
|
||||
const endMin = d.startMin + planned;
|
||||
const capMin = d.startMin + (d.wakingHours || 11) * 60;
|
||||
if (endMin > capMin) issues.push(`${d.label}: planned time ends ~${fmt(endMin)} but the waking window closes ~${fmt(capMin)} — ~${Math.round(endMin - capMin)} min over`);
|
||||
prim.forEach(s => {
|
||||
if (s.slot === 'lunch' && (s.start < 11 * 60 || s.start > 15 * 60)) issues.push(`${d.label}: lunch at ${s.name} starts ${fmt(s.start)} — outside a normal 11:00–15:00 window`);
|
||||
if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`);
|
||||
});
|
||||
const seen = {};
|
||||
prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; });
|
||||
if (!prim.length) issues.push(`${d.label}: has no planned stops`);
|
||||
}
|
||||
return { issues: issues.length ? issues : ['no problems found'] };
|
||||
},
|
||||
|
||||
// ---- fix-ops (mutations; each commits → undo-able) ----
|
||||
add_stop: (a) => {
|
||||
const SLOTS = ['lunch', 'dinner', 'visit'];
|
||||
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` };
|
||||
const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` };
|
||||
const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat;
|
||||
if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' };
|
||||
const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned');
|
||||
const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id);
|
||||
if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan
|
||||
d.stops.push(stop);
|
||||
rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]);
|
||||
return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null };
|
||||
},
|
||||
|
||||
remove_stop: (a) => {
|
||||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||||
d.stops.splice(d.stops.indexOf(stop), 1);
|
||||
rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll();
|
||||
return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label };
|
||||
},
|
||||
|
||||
move_stop: (a) => {
|
||||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||
const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` };
|
||||
const fromId = Object.keys(days).find(k => days[k].stops.includes(stop));
|
||||
days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1);
|
||||
to.stops.push(stop);
|
||||
rebuildLegs(days[fromId]); rebuildLegs(to);
|
||||
commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll();
|
||||
return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label };
|
||||
},
|
||||
|
||||
set_duration: (a) => {
|
||||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||||
const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' };
|
||||
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||||
stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' };
|
||||
rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]);
|
||||
return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) };
|
||||
},
|
||||
|
||||
set_start: (a) => {
|
||||
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
|
||||
const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
|
||||
d.startMin = h * 60 + mn;
|
||||
rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
|
||||
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
|
||||
},
|
||||
};
|
||||
|
||||
function llmSystemPrompt() {
|
||||
const cats = Object.keys(M.candidates);
|
||||
const regions = Object.keys(REGION_MAP).map(k => REGION_MAP[k].label);
|
||||
return (
|
||||
'You are the planning assistant inside a trip-planning app. The user is refining a trip and you are its orchestrator. Never invent times, prices, or places — read them from tools. Deterministic tools compute routes and validate the plan; you decide which to call and turn the results into a short, warm answer.\n\n' +
|
||||
'You may call TOOLS. To call one, reply with ONLY this exact format — a single fenced json block, nothing else on that turn:\n' +
|
||||
'<tool>\n{"name":"TOOL_NAME","args":{...}}\n</tool>\n' +
|
||||
'The tool result is then returned to you. From there you either call another tool the same way, or give your final natural-language answer.\n\n' +
|
||||
'TOOLS (name — args — what it does):\n' +
|
||||
' itinerary_summary() — the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' +
|
||||
' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' +
|
||||
' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' +
|
||||
' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' +
|
||||
' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' +
|
||||
' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' +
|
||||
' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' +
|
||||
' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' +
|
||||
' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' +
|
||||
' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n\n' +
|
||||
'Guidelines:\n' +
|
||||
'- dayId is the day id like "D1" or "D2" — shown in brackets in the plan rows, e.g. [D1 · 2026-09-12]. Never use the calendar date as a dayId.\n' +
|
||||
'- Answer concisely (1–4 sentences), plain text, no markdown.\n' +
|
||||
'- For any factual question about times, distances, or prices, CALL the tool — do not guess.\n' +
|
||||
'- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' +
|
||||
'- Before a big rework, consider review_plan() and mention any issues it reports.\n' +
|
||||
'- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n\n' +
|
||||
'Current plan:\n' + llmContext());
|
||||
}
|
||||
|
||||
// Parse a <tool>{...}</tool> block from the model output, if present.
|
||||
function parseToolBlock(text) {
|
||||
const m = /<tool>\s*([\s\S]*?)\s*<\/tool>/.exec(text);
|
||||
if (!m) return null;
|
||||
try { return JSON.parse(m[1]); } catch { return null; }
|
||||
}
|
||||
// A subtle activity line so the user sees the model working its tools.
|
||||
function toolLine(name, args) {
|
||||
const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' <span class="ta">' + esc(JSON.stringify(args)) + '</span>' : ''));
|
||||
$('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
return m;
|
||||
}
|
||||
|
||||
async function askLLM(v) {
|
||||
scriptBusy = true;
|
||||
const sys = 'You are the planning assistant inside a trip-planning app. The user is refining a trip; the current plan state is below. ' +
|
||||
'Answer the user\'s message concisely (2–5 sentences), using the plan as ground truth — never invent times, prices, or stops. ' +
|
||||
'The user drives edits through the app UI; if an edit is implied, say which control does it (e.g. the chat command, the drawer chips on the map, a stop card\'s alternatives). ' +
|
||||
'Plain text only, no markdown.\n\nCurrent plan:\n' + llmContext();
|
||||
fetch('/llm-chat', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: v }] }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
const c = (j.choices?.[0]?.message?.content || '').trim();
|
||||
t.remove();
|
||||
msg('ai', c ? esc(c).replace(/\n+/g, '<br>')
|
||||
: 'The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
|
||||
scriptBusy = false;
|
||||
})
|
||||
.catch(() => { t.remove(); msg('ai', 'LLM unreachable right now — falling back to canned replies.'); scriptBusy = false; });
|
||||
let t = typing();
|
||||
const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }];
|
||||
const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; };
|
||||
try {
|
||||
let finalText = null;
|
||||
for (let i = 0; i < LLM_TOOL_MAX; i++) {
|
||||
const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, max_tokens: 8192 }) });
|
||||
const j = await r.json();
|
||||
const content = (j.choices?.[0]?.message?.content || '').trim();
|
||||
const call = parseToolBlock(content);
|
||||
if (call) {
|
||||
t.remove();
|
||||
if (TOOLS[call.name]) toolLine(call.name, call.args || {});
|
||||
t = typing(); // the assistant keeps "thinking"
|
||||
messages.push({ role: 'assistant', content });
|
||||
let res;
|
||||
if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {}));
|
||||
else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') };
|
||||
messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
|
||||
continue;
|
||||
}
|
||||
finalText = content; break;
|
||||
}
|
||||
if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.');
|
||||
else if (finalText) finish(esc(finalText).replace(/\n+/g, '<br>'));
|
||||
else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
|
||||
} catch (e) {
|
||||
t.remove();
|
||||
msg('ai', 'LLM call failed — falling back to canned replies.');
|
||||
scriptBusy = false;
|
||||
}
|
||||
}
|
||||
// Google encoded-polyline decoder (OSRM `overview=full` geometry)
|
||||
// OSRM encodes with precision 5 (unlike Google’s default 6)
|
||||
|
|
|
|||
|
|
@ -263,6 +263,8 @@ button { font: inherit; }
|
|||
@keyframes pop { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
.msg.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
|
||||
.msg.ai { align-self: flex-start; background: #eef0f4; border-bottom-left-radius: 4px; }
|
||||
.msg.tool { align-self: flex-start; background: #f5f1fb; border: 1px solid #e5dbf2; border-radius: 10px; padding: 6px 10px; font-size: 11.5px; font-family: ui-monospace, Menlo, Consolas, monospace; color: #5d3a8a; max-width: 94%; }
|
||||
.msg.tool .ta { color: #8a6116; }
|
||||
.msg .src { display: block; font-size: 11px; color: var(--ink2); margin-top: 6px; }
|
||||
.msg .src a { color: var(--blue); text-decoration: none; }
|
||||
.typing { display: inline-flex; gap: 4px; padding: 12px 14px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user