mock: apply flight anchors directly + give the model conversation memory

The real-email case exposed two gaps:

1. Confirmation friction: when the booked dates didn't match the plan,
   the model asked to confirm instead of anchoring. The guideline now
   says to apply directly (the user's ticket is the truth, and the edit
   is undo-able) and report the date shift — verified: a dropped booking
   now re-anchors in one round, and a redundant "yes" afterwards is
   handled gracefully ("already done").

2. Amnesia on the follow-up: the model has no conversation memory, so a
   confirming "yes" round had to re-derive the dates from the document
   in context — and that copy was clipped at 20k chars. Now the recent
   turns ride in the model's context (chatLog, ~12k char budget, upload
   messages excluded since docSection carries the document), and the doc
   is no longer clipped in-context at all (60k server cap ≪ 131k-token
   window). A new guideline tells the model to apply a confirmed change
   from its own earlier message rather than claiming it can't see info
   it already quoted.

Extraction hardening: broader HTML detection (20k-char scan + tag
density), and a brute-force base64-block fallback in decodeMime for
mail clients with quirky MIME structure. The server now logs an
extraction preview and saves the exact model-facing text to
/tmp/parse-doc-last.txt for debugging real files.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Greg Pomerantz 2026-09-09 14:48:59 -04:00
parent f03e7b5dbc
commit 91e93ec265
2 changed files with 50 additions and 11 deletions

View File

@ -428,7 +428,8 @@ function llmSystemPrompt() {
'- 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' +
'- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. If the document is ambiguous or its dates dont match the plans, ask me to confirm before applying.\n\n' +
'- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. Apply it directly even if the booked dates differ from the current plan — the users ticket is the truth and the edit is undo-able — then report what changed, including the date shift. Only ask a clarifying question if the document is genuinely ambiguous (e.g. two candidate departure flights).\n' +
'- Stay consistent with your earlier turns: if I am confirming a change you already proposed, apply it using the details from your own earlier message — never claim you cant see information you quoted yourself.\n\n' +
'Current plan:\n' + llmContext() + docSection());
}
@ -448,9 +449,10 @@ function toolLine(name, args) {
async function askLLM(v) {
if (scriptBusy) return msg('ai', 'Hold on — Im still working on the last request.');
scriptBusy = true;
pushChatLog('user', v);
let t = typing();
const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }];
const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; };
const messages = [{ role: 'system', content: llmSystemPrompt() }, ...recentHistory(), { role: 'user', content: v }];
const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) pushChatLog('assistant', plain); scriptBusy = false; };
try {
let finalText = null;
for (let i = 0; i < LLM_TOOL_MAX; i++) {
@ -472,7 +474,7 @@ async function askLLM(v) {
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 if (finalText) finish(esc(finalText).replace(/\n+/g, '<br>'), finalText);
else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
} catch (e) {
t.remove();
@ -1530,6 +1532,27 @@ let version = 0;
let history = []; // [{v, label, at, days, stays}]
let hIdx = -1; // pointer into history
let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents, persisted per trip
let chatLog = []; // [{role:'user'|'assistant', text}] — recent turns, so a follow-up
// ("yes, do it") can see what the model itself said earlier
function pushChatLog(role, text) {
if (!text || !String(text).trim()) return;
chatLog.push({ role, text: String(text) });
if (chatLog.length > 24) chatLog.splice(0, chatLog.length - 24);
}
function recentHistory() {
// most recent turns that fit the context budget; upload messages carry the
// whole document — skip them, docSection() already has it
const out = [];
let budget = 12000;
for (let i = chatLog.length - 1; i >= 0 && budget > 0; i--) {
const e = chatLog[i];
if (e.role === 'user' && e.text.includes('<document>')) continue;
const t = e.text.length > 3000 ? e.text.slice(0, 3000) + ' […]' : e.text;
out.unshift({ role: e.role === 'assistant' ? 'assistant' : 'user', content: t });
budget -= t.length;
}
return out;
}
const commit = (label) => {
history = history.slice(0, hIdx + 1); // drop any redo branch
history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays), bookings: deep(M.trip.bookings), title: M.trip.title });
@ -1825,12 +1848,13 @@ async function ingestDoc(file) {
// attached documents stay in the model's context on EVERY turn, so follow-up
// questions ("whats my confirmation code?", "whens the flight out?") work
// without a re-upload
const DOC_CTX_CAP = 20000; // per-document budget inside the system prompt
function docSection() {
// the full extracted text rides in the context every turn (it's already
// capped at 60k chars server-side — well within the 131k-token window)
const ds = Object.values(tripDocs);
if (!ds.length) return '';
return '\n\nAttached documents (the users own files, kept for reference on any question). For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plans placeholder bookings:\n' +
ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '').slice(0, DOC_CTX_CAP)).join('\n\n');
ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n');
}
function renderDocChips() {
const el = $('#doc-chips'); if (!el) return;
@ -1853,8 +1877,8 @@ function reconfigureFromDoc(j) {
'<document>\n' + (j.text || '') + '\n</document>\n\n' +
'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' +
'1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' +
'2. Call apply_flight_anchors exactly once with those two anchors.\n' +
'3. Confirm what changed — the new first and last day with their start/end times, and any stops that had to go, in a couple of sentences.\n' +
'2. Call apply_flight_anchors exactly once with those two anchors — apply it now, do not ask first (the edit is undo-able), even if the dates differ from the current plan.\n' +
'3. Confirm what changed — the new first and last day with their start/end times, any stops that had to go, and the date shift — in a couple of sentences.\n' +
'If it is NOT a flight booking, do not change the plan — just confirm its attached and summarize what it contains in 12 sentences.';
askLLM(prompt);
}
@ -1955,7 +1979,7 @@ function enterTrip(id) {
tripDocs = saved.docs ? deep(saved.docs) : {};
} else tripDocs = {};
// reset per-trip UI state
compareItems = [];
chatLog = []; compareItems = [];
closeDiscover(); closeL3();
scope = 'day';
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));

View File

@ -138,6 +138,18 @@ function decodeMime(raw) {
if (body.trim()) dec.push({ html: p.ct.includes('html'), len: body.length, body });
}
dec.sort((a, b) => (b.html - a.html) || (b.len - a.len));
if (dec.length) return dec[0].body;
// last resort: brute-force the biggest base64 block (mail clients vary in
// boundary quoting, charset headers, etc.)
const blocks = raw.match(/[A-Za-z0-9+\/\n=]{400,}/g) || [];
for (const b of blocks.slice(0, 12)) {
try {
const d = Buffer.from(b.replace(/\s+/g, ''), 'base64').toString('utf8');
if (d.length > 200 && /[a-z]{4,}/i.test(d))
dec.push({ html: /<\s*(html|body|div|table|p)\b/i.test(d.slice(0, 2000)), len: d.length, body: d });
} catch {}
}
dec.sort((a, b) => (b.html - a.html) || (b.len - a.len));
return dec.length ? dec[0].body : null;
}
@ -302,7 +314,8 @@ http.createServer((req, res) => {
out.push(l);
}
text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n');
console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > CAP ? ' (TRUNCATED at ' + CAP + ')' : ''}`);
try { fs.writeFileSync('/tmp/parse-doc-last.txt', 'file: ' + name + ' (' + buf.length + ' bytes) → ' + text.length + ' chars\n' + '=== extracted text (exactly what the model receives) ===\n' + text); } catch {}
console.log(`[parse-doc] ${name} ${buf.length}B → ${text.length} chars${text.length > CAP ? ' (TRUNCATED at ' + CAP + ')' : ''} | preview: ${text.slice(0, 200).replace(/\n/g, ' ⏎ ')}`);
send({ ok: true, kind: ext.replace('.', '') || 'text', name, text: text.slice(0, CAP), chars: text.length, truncated: text.length > CAP });
};
if (ext === '.pdf' || ctype.includes('pdf')) {
@ -321,7 +334,9 @@ http.createServer((req, res) => {
const mimeBody = decodeMime(text);
if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain text
}
if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(text.slice(0, 4000)))
const head = text.slice(0, 20000);
const tagDensity = (head.match(/<[a-z][\s\/a-z>]/gi) || []).length;
if (ext === '.html' || ext === '.htm' || ctype.includes('html') || /<\s*(html|!doctype|head|body)\b/i.test(head) || tagDensity > 30)
text = htmlToText(text);
finish(text);
});