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:
parent
f03e7b5dbc
commit
91e93ec265
42
mock/app.js
42
mock/app.js
|
|
@ -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' +
|
'- 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' +
|
'- 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' +
|
'- 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 don’t match the plan’s, 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 user’s 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 can’t see information you quoted yourself.\n\n' +
|
||||||
'Current plan:\n' + llmContext() + docSection());
|
'Current plan:\n' + llmContext() + docSection());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,9 +449,10 @@ function toolLine(name, args) {
|
||||||
async function askLLM(v) {
|
async function askLLM(v) {
|
||||||
if (scriptBusy) return msg('ai', 'Hold on — I’m still working on the last request.');
|
if (scriptBusy) return msg('ai', 'Hold on — I’m still working on the last request.');
|
||||||
scriptBusy = true;
|
scriptBusy = true;
|
||||||
|
pushChatLog('user', v);
|
||||||
let t = typing();
|
let t = typing();
|
||||||
const messages = [{ role: 'system', content: llmSystemPrompt() }, { role: 'user', content: v }];
|
const messages = [{ role: 'system', content: llmSystemPrompt() }, ...recentHistory(), { role: 'user', content: v }];
|
||||||
const finish = html => { t.remove(); msg('ai', html); scriptBusy = false; };
|
const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) pushChatLog('assistant', plain); scriptBusy = false; };
|
||||||
try {
|
try {
|
||||||
let finalText = null;
|
let finalText = null;
|
||||||
for (let i = 0; i < LLM_TOOL_MAX; i++) {
|
for (let i = 0; i < LLM_TOOL_MAX; i++) {
|
||||||
|
|
@ -472,7 +474,7 @@ async function askLLM(v) {
|
||||||
finalText = content; break;
|
finalText = content; break;
|
||||||
}
|
}
|
||||||
if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.');
|
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.');
|
else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
t.remove();
|
t.remove();
|
||||||
|
|
@ -1530,6 +1532,27 @@ let version = 0;
|
||||||
let history = []; // [{v, label, at, days, stays}]
|
let history = []; // [{v, label, at, days, stays}]
|
||||||
let hIdx = -1; // pointer into history
|
let hIdx = -1; // pointer into history
|
||||||
let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents, persisted per trip
|
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) => {
|
const commit = (label) => {
|
||||||
history = history.slice(0, hIdx + 1); // drop any redo branch
|
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 });
|
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
|
// attached documents stay in the model's context on EVERY turn, so follow-up
|
||||||
// questions ("what’s my confirmation code?", "when’s the flight out?") work
|
// questions ("what’s my confirmation code?", "when’s the flight out?") work
|
||||||
// without a re-upload
|
// without a re-upload
|
||||||
const DOC_CTX_CAP = 20000; // per-document budget inside the system prompt
|
|
||||||
function docSection() {
|
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);
|
const ds = Object.values(tripDocs);
|
||||||
if (!ds.length) return '';
|
if (!ds.length) return '';
|
||||||
return '\n\nAttached documents (the user’s 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 plan’s placeholder bookings:\n' +
|
return '\n\nAttached documents (the user’s 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 plan’s 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() {
|
function renderDocChips() {
|
||||||
const el = $('#doc-chips'); if (!el) return;
|
const el = $('#doc-chips'); if (!el) return;
|
||||||
|
|
@ -1853,8 +1877,8 @@ function reconfigureFromDoc(j) {
|
||||||
'<document>\n' + (j.text || '') + '\n</document>\n\n' +
|
'<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' +
|
'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' +
|
'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' +
|
'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, and any stops that had to go, in a couple of sentences.\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 it’s attached and summarize what it contains in 1–2 sentences.';
|
'If it is NOT a flight booking, do not change the plan — just confirm it’s attached and summarize what it contains in 1–2 sentences.';
|
||||||
askLLM(prompt);
|
askLLM(prompt);
|
||||||
}
|
}
|
||||||
|
|
@ -1955,7 +1979,7 @@ function enterTrip(id) {
|
||||||
tripDocs = saved.docs ? deep(saved.docs) : {};
|
tripDocs = saved.docs ? deep(saved.docs) : {};
|
||||||
} else tripDocs = {};
|
} else tripDocs = {};
|
||||||
// reset per-trip UI state
|
// reset per-trip UI state
|
||||||
compareItems = [];
|
chatLog = []; compareItems = [];
|
||||||
closeDiscover(); closeL3();
|
closeDiscover(); closeL3();
|
||||||
scope = 'day';
|
scope = 'day';
|
||||||
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));
|
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,18 @@ function decodeMime(raw) {
|
||||||
if (body.trim()) dec.push({ html: p.ct.includes('html'), len: body.length, body });
|
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));
|
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;
|
return dec.length ? dec[0].body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,7 +314,8 @@ http.createServer((req, res) => {
|
||||||
out.push(l);
|
out.push(l);
|
||||||
}
|
}
|
||||||
text = out.map(l => l.startsWith('__DUP__') ? ' [… ' + count[l.slice(7)] + '× this line repeats — shown above]' : l).join('\n');
|
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 });
|
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')) {
|
if (ext === '.pdf' || ctype.includes('pdf')) {
|
||||||
|
|
@ -321,7 +334,9 @@ http.createServer((req, res) => {
|
||||||
const mimeBody = decodeMime(text);
|
const mimeBody = decodeMime(text);
|
||||||
if (mimeBody != null) text = mimeBody; // not really MIME → treat as plain 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);
|
text = htmlToText(text);
|
||||||
finish(text);
|
finish(text);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user