memory: distinguish a failed extraction from an empty one
Probing the extraction path with a real model call showed it returning a
zero-candidate outcome — indistinguishable from "the conversation had
nothing worth keeping". It was actually an auth failure delivered as a JSON
error body rather than a thrown error, which coerceText turned into an empty
string, which parseCandidates turned into an empty array.
extract() now reports `error` and a `rawText` preview instead of collapsing
every failure mode into `{candidates: []}`. An auth error arriving as a
return value rather than a throw is the common case, not the exotic one.
memory_inspect prints the error and the raw response, so this class of
failure now shows up instead of looking like a healthy extractor that
rarely fires.
Verified: thrown error, error object, empty string, and null each produce a
specific message; valid JSON and fenced JSON still parse.
This commit is contained in:
@@ -218,6 +218,15 @@ export function redactSecrets(text: string): string {
|
|||||||
export interface ExtractionOutcome {
|
export interface ExtractionOutcome {
|
||||||
candidates: Candidate[]
|
candidates: Candidate[]
|
||||||
rejected: { candidate: Candidate; reason: string }[]
|
rejected: { candidate: Candidate; reason: string }[]
|
||||||
|
/**
|
||||||
|
* Set when the round-trip itself failed. Absent means the model answered.
|
||||||
|
* Without this, a broken call and a conversation with nothing worth keeping
|
||||||
|
* both come back as zero candidates — which is how a permanently broken
|
||||||
|
* extractor can look like a healthy one that simply rarely fires.
|
||||||
|
*/
|
||||||
|
error?: string
|
||||||
|
/** Raw model text, truncated, for diagnosing a surprising empty result. */
|
||||||
|
rawText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,7 +311,13 @@ export interface RunExtractionInput {
|
|||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One model round-trip. Returns screened candidates and why things were dropped. */
|
/**
|
||||||
|
* One model round-trip. Returns screened candidates and why things were dropped.
|
||||||
|
*
|
||||||
|
* A transport failure is reported in `error` rather than collapsed into an
|
||||||
|
* empty result: the caller's only other signal would be "no candidates", which
|
||||||
|
* is also what a correct answer looks like when nothing was worth keeping.
|
||||||
|
*/
|
||||||
export async function extract(input: RunExtractionInput): Promise<ExtractionOutcome> {
|
export async function extract(input: RunExtractionInput): Promise<ExtractionOutcome> {
|
||||||
const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}`
|
const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}`
|
||||||
let result: unknown
|
let result: unknown
|
||||||
@@ -314,11 +329,34 @@ export async function extract(input: RunExtractionInput): Promise<ExtractionOutc
|
|||||||
prompt,
|
prompt,
|
||||||
...(input.signal ? { signal: input.signal } : {}),
|
...(input.signal ? { signal: input.signal } : {}),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error) {
|
||||||
// An extraction failure must never disturb the user's session.
|
// An extraction failure must never disturb the user's session, but it must
|
||||||
return { candidates: [], rejected: [] }
|
// not be indistinguishable from success either.
|
||||||
|
return {
|
||||||
|
candidates: [],
|
||||||
|
rejected: [],
|
||||||
|
error: `模型调用失败:${(error as Error).message.slice(0, 160)}`,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const text = coerceText(result)
|
const text = coerceText(result)
|
||||||
|
// An empty string here means the call "succeeded" but returned nothing usable.
|
||||||
|
// An auth or provider error can arrive as a JSON error body rather than a
|
||||||
|
// thrown error, so surface what actually came back instead of reporting it
|
||||||
|
// as "nothing worth remembering".
|
||||||
|
if (!text.trim()) {
|
||||||
|
const preview =
|
||||||
|
result && typeof result === "object"
|
||||||
|
? JSON.stringify(result).slice(0, 160)
|
||||||
|
: String(result ?? "").slice(0, 160)
|
||||||
|
return {
|
||||||
|
candidates: [],
|
||||||
|
rejected: [],
|
||||||
|
error: `模型返回空内容(响应:${preview || "空"})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = parseCandidates(text)
|
const candidates = parseCandidates(text)
|
||||||
return screenCandidates(candidates, input.existing)
|
const screened = screenCandidates(candidates, input.existing)
|
||||||
|
return { ...screened, rawText: text.slice(0, 400) }
|
||||||
}
|
}
|
||||||
+11
-2
@@ -529,6 +529,7 @@ export default Plugin.define({
|
|||||||
const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ")
|
const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ")
|
||||||
lines.push(` 抽取:${when} → ${String(extraction.outcome)}`)
|
lines.push(` 抽取:${when} → ${String(extraction.outcome)}`)
|
||||||
if (extraction.reason) lines.push(` 原因:${String(extraction.reason)}`)
|
if (extraction.reason) lines.push(` 原因:${String(extraction.reason)}`)
|
||||||
|
if (extraction.reasonDetail) lines.push(` 细节:${String(extraction.reasonDetail)}`)
|
||||||
if (extraction.model) lines.push(` 模型:${String(extraction.model)}`)
|
if (extraction.model) lines.push(` 模型:${String(extraction.model)}`)
|
||||||
} else {
|
} else {
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -537,6 +538,10 @@ export default Plugin.define({
|
|||||||
: " 抽取:已关闭(autoExtract: false)",
|
: " 抽取:已关闭(autoExtract: false)",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (extraction && extraction.error) {
|
||||||
|
lines.push(` ⚠ 模型调用异常:${String(extraction.error)}`)
|
||||||
|
if (extraction.rawText) lines.push(` 原始返回:${String(extraction.rawText).slice(0, 160)}`)
|
||||||
|
}
|
||||||
|
|
||||||
return { content: lines.join("\n") }
|
return { content: lines.join("\n") }
|
||||||
},
|
},
|
||||||
@@ -755,10 +760,14 @@ async function runExtraction(
|
|||||||
}
|
}
|
||||||
await noteDiag(ctx, "lastExtraction", {
|
await noteDiag(ctx, "lastExtraction", {
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
outcome: "no-candidates",
|
outcome: outcome.error ? "failed" : "no-candidates",
|
||||||
reason: outcome.rejected.length ? "候选全部被去重或密钥拦截" : "抽取器认为没有值得记的东西",
|
...(outcome.error ? { reason: outcome.error } : {}),
|
||||||
|
reasonDetail: outcome.rejected.length
|
||||||
|
? "候选全部被去重或密钥拦截"
|
||||||
|
: "抽取器认为没有值得记的东西",
|
||||||
rejected: outcome.rejected.length,
|
rejected: outcome.rejected.length,
|
||||||
model: `${model.providerID}/${model.id}`,
|
model: `${model.providerID}/${model.id}`,
|
||||||
|
...(outcome.rawText ? { rawText: outcome.rawText } : {}),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user