// mooncassette/matcher —— 未命中诊断。
//
// 回放失败时只报一句「no match」几乎没有可操作性:用户无法判断是请求
// 写错了、录制没覆盖,还是匹配策略选得不合适。本文件把失败拆成可读的
// 结论,并指出与请求最接近的几条录制记录到底差在哪个字段上。
//
// 这里只报告**路径**,不报告字段值:诊断信息常被写进测试输出或日志,
// 而传进来的请求未必经过脱敏。
///|
/// 每条候选最多报告几个差异路径。
///
/// 差异通常集中在少数几个字段上,把上百条路径全部列出反而会淹没真正的原因。
let max_reported_paths : Int = 5
///|
/// 诊断中的一条候选记录。
pub(all) struct Candidate {
/// 该记录在 cassette 中的下标。
index : Int
/// 记录中的 provider。
provider : String
/// 记录中的模型名。
///
/// 单独列出它,是因为「所有候选的模型都与请求不同」是最常见的一类原因
/// (录的是 `gpt-4o`,请求却写成了 `gpt-4o-mini`),摆出来一望便知。
model : String
/// 与请求不同的字段路径,例如 `$.body.messages[0].content`。
differing_paths : Array[String]
} derive(Eq)
///|
/// 一次未命中的诊断结果。
pub(all) struct Diagnosis {
/// 诊断时使用的匹配策略。
policy : MatchPolicy
/// 诊断时的回放游标。
cursor : Int
/// cassette 中的记录总数。
total : Int
/// 按「差异最少」排序的候选,最多 `top` 条。
///
/// `total` 为 0 时本数组为空。
candidates : Array[Candidate]
} derive(Eq)
///|
/// 诊断一次未命中:找出与 `request` 最接近的记录,以及差在哪些字段。
///
/// 调用方应先对 `request` 做规范化(必要时再脱敏),否则差异里会混进
/// 易变字段,噪声会盖住真正的原因。`@recorder.Session::diagnose` 已经代劳。
///
/// 代价是 O(记录数 × 字段数):它会把请求与**每一条**记录做一次结构比较。
/// 这是排错路径,不在回放的正常路径上。
pub fn diagnose(
request : @core.Request,
interactions : ArrayView[@core.Interaction],
policy : MatchPolicy,
cursor : Int,
top? : Int,
) -> Diagnosis {
let limit = match top {
Some(value) => value
None => 3
}
let total = interactions.length()
let request_view = request.to_json()
let collected : Array[Candidate] = []
for i = 0; i < total; i = i + 1 {
let recorded = interactions[i].request
let paths : Array[String] = []
collect_differences(
recorded.to_json(),
request_view,
"$",
paths,
max_reported_paths,
)
collected.push({
index: i,
provider: recorded.provider,
model: recorded.model,
differing_paths: paths,
})
}
sort_by_distance(collected)
let ranked : Array[Candidate] = []
for i = 0; i < collected.length() && i < limit; i = i + 1 {
ranked.push(collected[i])
}
{ policy, cursor, total, candidates: ranked }
}
///|
/// 一行结论,可直接拼进错误消息。
pub fn Diagnosis::hint(self : Diagnosis) -> String {
if self.total == 0 {
return "the cassette contains no interactions"
}
match self.candidates {
[] =>
"the cassette has " +
self.total.to_string() +
" interaction(s) but none of them could be compared"
[best, ..] =>
"the cassette has " +
self.total.to_string() +
" interaction(s); closest is #" +
best.index.to_string() +
" (provider=" +
best.provider +
" model=" +
best.model +
"), differing at " +
render_paths(best.differing_paths)
}
}
///|
/// 多行报告,便于打印或写进 CI 日志。
pub fn Diagnosis::lines(self : Diagnosis) -> Array[String] {
let out : Array[String] = []
out.push(
"policy=" +
self.policy.name() +
" cursor=" +
self.cursor.to_string() +
" interactions=" +
self.total.to_string(),
)
if self.candidates.length() == 0 {
out.push("no candidate available for comparison")
}
for candidate in self.candidates {
out.push(
" #" +
candidate.index.to_string() +
" provider=" +
candidate.provider +
" model=" +
candidate.model +
" differs: " +
render_paths(candidate.differing_paths),
)
}
out
}
///|
/// 把差异路径渲染成一行。
fn render_paths(paths : Array[String]) -> String {
if paths.length() == 0 {
return "(no field-level difference; check the matching policy)"
}
let sb = StringBuilder::new()
for i = 0; i < paths.length(); i = i + 1 {
if i > 0 {
sb.write_string(", ")
}
sb.write_string(paths[i])
}
sb.to_string()
}
///|
/// 按差异数量升序排列候选。
///
/// 用插入排序而非库的排序函数:这里要求**稳定**(差异数相同时保持录制顺序),
/// 否则同一个输入可能给出不同的候选次序,诊断结果就无法被测试断言。
fn sort_by_distance(items : Array[Candidate]) -> Unit {
for i = 1; i < items.length(); i = i + 1 {
let value = items[i]
let mut j = i - 1
while j >= 0 &&
items[j].differing_paths.length() > value.differing_paths.length() {
items[j + 1] = items[j]
j = j - 1
}
items[j + 1] = value
}
}
///|
/// 递归比较两个 JSON 值,把不同的位置按路径收集进 `out`。
///
/// 路径形如 `$.body.messages[0].content`,与 cassette 的 JSON 视图对齐,
/// 可以直接照着去改代码或改 cassette。
fn collect_differences(
recorded : Json,
incoming : Json,
path : String,
out : Array[String],
limit : Int,
) -> Unit {
if out.length() >= limit {
return
}
match (recorded, incoming) {
(Object(left), Object(right)) =>
for key in sorted_key_union(left, right) {
let field_path = path + "." + key
match (left.get(key), right.get(key)) {
(Some(a), Some(b)) =>
collect_differences(a, b, field_path, out, limit)
(Some(_), None) =>
push_path(out, field_path + " (only in the recording)", limit)
(None, Some(_)) =>
push_path(out, field_path + " (only in the request)", limit)
(None, None) => ()
}
}
(Array(left), Array(right)) => {
let common = if left.length() < right.length() {
left.length()
} else {
right.length()
}
for i = 0; i < common; i = i + 1 {
collect_differences(
left[i],
right[i],
path + "[" + i.to_string() + "]",
out,
limit,
)
}
if left.length() != right.length() {
push_path(
out,
path +
".length (" +
left.length().to_string() +
" recorded vs " +
right.length().to_string() +
" requested)",
limit,
)
}
}
// 其余情况都按标量处理:取值不同即记为差异,
// 类型不同(对象对数组等)同样会落到这里,报告为路径本身不同。
(a, b) => if a != b { push_path(out, path, limit) }
}
}
///|
fn push_path(out : Array[String], value : String, limit : Int) -> Unit {
if out.length() < limit {
out.push(value)
}
}
///|
/// 两个对象的键的并集,按字典序排序。
///
/// 排序是必要的:`Map` 的迭代顺序不保证稳定,而诊断输出要能被测试断言,
/// 也要能在两次运行之间保持一致。
fn sorted_key_union(
left : Map[String, Json],
right : Map[String, Json],
) -> Array[String] {
let keys : Array[String] = []
for key in left.keys() {
keys.push(key)
}
for key in right.keys() {
if !contains_key(keys, key) {
keys.push(key)
}
}
sort_strings(keys)
keys
}
///|
fn contains_key(keys : Array[String], target : String) -> Bool {
for key in keys {
if key == target {
return true
}
}
false
}
///|
/// 原地插入排序。键的数量很少,不值得为此引入排序依赖。
fn sort_strings(items : Array[String]) -> Unit {
for i = 1; i < items.length(); i = i + 1 {
let value = items[i]
let mut j = i - 1
while j >= 0 && items[j] > value {
items[j + 1] = items[j]
j = j - 1
}
items[j + 1] = value
}
}