// mooncassette/core —— 数据模型。
//
// 设计原则:
// 1. 本文件只描述「数据形状」与最基础的构造/查询,不含算法;
// 2. 所有进入 `Interaction` 的值必须是完全确定的;
// 任何跨运行会变化的信息(时间戳、耗时、随机数)只能放进 `CassetteMeta`。
///|
/// cassette 格式版本号。
///
/// 写入时使用当前版本;读取时严格校验。这样未来若改变语义,
/// 旧版本的 reader 会明确报错,而不是把新格式「读歪」。
pub let cassette_format_version : Int = 1
///|
/// 生成器标识。写入每个 cassette,便于追溯产出来源与排查兼容性问题。
///
/// 必须与 `moon.mod` 里的 `version` 保持一致:CI 会比对这两处,不一致
/// 直接失败。之所以要靠校验而不是共享常量,是因为 MoonBit 无法在编译期
/// 读取模块元信息,而这个标识一旦漂移,cassette 就会谎报产出版本。
pub let generator_id : String = "mooncassette/0.3.0"
///|
/// 一次 LLM 请求。
///
/// 不变量:
/// - `body` 的**对象键顺序不参与语义**,由 `@canon` 的规范文本统一归一;
/// - 生成指纹前必须先调用 `Request::normalize` 剔除易变字段,
/// 否则同一语义的请求每次都会得到不同指纹,回放将永远无法命中。
pub(all) struct Request {
provider : String
model : String
body : Json
} derive(Eq)
///|
/// 构造一个请求。
pub fn Request::new(provider : String, model : String, body : Json) -> Request {
{ provider, model, body }
}
///|
/// 供应方返回的 token 用量。
pub(all) struct Usage {
input_tokens : Int
output_tokens : Int
} derive(Eq)
///|
/// 构造用量信息。
pub fn Usage::new(input_tokens : Int, output_tokens : Int) -> Usage {
{ input_tokens, output_tokens }
}
///|
/// 输入与输出 token 数之和。
pub fn Usage::total(self : Usage) -> Int {
self.input_tokens + self.output_tokens
}
///|
/// 一次 LLM 响应。
///
/// `status` 沿用 HTTP 语义;非 HTTP 传输(例如本地 mock)使用 0。
pub(all) struct Response {
status : Int
body : Json
usage : Usage?
} derive(Eq)
///|
/// 构造响应。
pub fn Response::new(status : Int, body : Json, usage? : Usage) -> Response {
{ status, body, usage }
}
///|
/// 构造一个 200 响应。
///
/// 直接构造结构体而不转发给 `Response::new`:可选参数在函数体内
/// 已经是 `Usage?`,直接落入字段可以避免一次无意义的拆装。
pub fn Response::ok(body : Json, usage? : Usage) -> Response {
{ status: 200, body, usage }
}
///|
/// cassette 元信息。
///
/// 与 `Interaction` 相反,本类型**允许**包含非确定性字段。
/// 重新录制时这些字段出现 diff 属预期行为,不影响回放。
pub(all) struct CassetteMeta {
name : String
generator : String
recorded_at : String?
} derive(Eq)
///|
/// 构造元信息,`generator` 自动填为当前 `generator_id`。
pub fn CassetteMeta::new(name : String, recorded_at? : String) -> CassetteMeta {
{ name, generator: generator_id, recorded_at }
}
///|
/// 一条被录制的交互。
///
/// 不变量:本类型必须**完全确定**。任何不确定字段一经混入,
/// 「确定性回放」这一核心承诺即被破坏,因此这里刻意不提供时间戳字段。
pub(all) struct Interaction {
request : Request
response : Response
} derive(Eq)
///|
/// 构造一条交互记录。
pub fn Interaction::new(request : Request, response : Response) -> Interaction {
{ request, response }
}
///|
/// 一个 cassette:一组按录制顺序排列的交互。
pub(all) struct Cassette {
version : Int
meta : CassetteMeta
interactions : Array[Interaction]
} derive(Eq)
///|
/// 构造一个空 cassette,`version` 自动填为当前格式版本。
pub fn Cassette::new(
name : String,
recorded_at? : String,
interactions? : Array[Interaction],
) -> Cassette {
{
version: cassette_format_version,
meta: { name, generator: generator_id, recorded_at },
interactions: match interactions {
Some(items) => items
None => []
},
}
}
///|
/// 记录条数。
pub fn Cassette::length(self : Cassette) -> Int {
self.interactions.length()
}
///|
/// 追加一条记录(录制时使用)。
pub fn Cassette::push(self : Cassette, interaction : Interaction) -> Unit {
self.interactions.push(interaction)
}
///|
/// 输入 token 合计(忽略未上报用量的记录)。
pub fn Cassette::total_input_tokens(self : Cassette) -> Int {
let mut total = 0
for item in self.interactions {
match item.response.usage {
Some(usage) => total = total + usage.input_tokens
None => ()
}
}
total
}
///|
/// 输出 token 合计(忽略未上报用量的记录)。
pub fn Cassette::total_output_tokens(self : Cassette) -> Int {
let mut total = 0
for item in self.interactions {
match item.response.usage {
Some(usage) => total = total + usage.output_tokens
None => ()
}
}
total
}
///|
/// mooncassette 统一错误类型。
///
/// 所有错误都携带足以定位问题的上下文:出错路径、指纹或记录序号。
pub(all) suberror CassetteError {
/// 输入文本不是合法 JSON,或顶层不是 JSON 对象。
Malformed(String)
/// JSON 结构合法,但字段缺失或类型不符;字符串给出出错路径。
SchemaViolation(String)
/// cassette 的格式版本不受支持。
UnsupportedVersion(Int)
/// 存在两条指纹相同、但规范请求不同的记录(哈希碰撞)。
FingerprintCollision(String)
/// 记录中保存的指纹与按记录内容重算的结果不一致(文件被手工改动)。
IntegrityViolation(String)
/// 回放时找不到与请求匹配的录制记录。
///
/// 内容是「请求指纹」加上一句诊断结论:cassette 里有多少条记录、
/// 最接近的是哪一条、差在哪个字段。只给指纹不足以定位问题。
NoMatch(String)
/// 顺序回放模式下记录已耗尽:测试要的调用次数多于录制的条数。
///
/// 与 `NoMatch` 分开是必要的:耗尽要补录制,没有对应记录要改请求,
/// 两者混在一起会把排查引向错误的方向。
Exhausted(String)
/// 当前模式需要真实 Transport,但会话未提供。
MissingTransport(String)
/// 底层 Transport 调用失败(网络、鉴权等)。
TransportFailure(String)
}
///|
pub impl Show for CassetteError with fn output(self, logger) {
match self {
Malformed(message) =>
logger.write_string("mooncassette: malformed cassette: " + message)
SchemaViolation(message) =>
logger.write_string("mooncassette: schema violation: " + message)
UnsupportedVersion(version) =>
logger.write_string(
"mooncassette: unsupported cassette format version \{version}, expected \{cassette_format_version}",
)
FingerprintCollision(fingerprint) =>
logger.write_string(
"mooncassette: fingerprint collision on \{fingerprint}: two different requests share one fingerprint",
)
IntegrityViolation(message) =>
logger.write_string("mooncassette: integrity violation: " + message)
NoMatch(detail) =>
logger.write_string(
"mooncassette: no recorded interaction matches request " + detail,
)
Exhausted(message) =>
logger.write_string("mooncassette: recording exhausted: " + message)
MissingTransport(message) =>
logger.write_string("mooncassette: missing transport: " + message)
TransportFailure(message) =>
logger.write_string("mooncassette: transport failure: " + message)
}
}
// ---------------------------------------------------------------------------
// 规范 JSON 视图
//
// 这些函数定义「数据模型如何映射为 JSON」。它们是**唯一**的映射定义:
// 匹配用的规范文本(@fingerprint)与落盘格式(@codec)都基于它们,
// 从而不会出现「匹配按一种形状算、落盘按另一种形状写」的漂移。
//
// 注意:对象键顺序由 @canon 统一排序,因此这里无需关心插入顺序。
///|
/// 构造一个「看起来像整数」的 JSON 数字,避免输出 `200.0` 这类噪声。
fn json_int(value : Int) -> Json {
Json::number(value.to_double(), repr=value.to_string())
}
///|
/// 由三要素拼出请求的 JSON 形状。
///
/// 独立暴露该函数是为了让「子集匹配」也能基于同一形状投影,
/// 而不是各自拼一遍、日后产生分歧。
pub fn request_json(provider : String, model : String, body : Json) -> Json {
let fields : Map[String, Json] = Map([])
fields.set("provider", Json::string(provider))
fields.set("model", Json::string(model))
fields.set("body", body)
Json::object(fields)
}
///|
/// 请求的 JSON 视图。
pub fn Request::to_json(self : Request) -> Json {
request_json(self.provider, self.model, self.body)
}
///|
/// 用量的 JSON 视图。
pub fn Usage::to_json(self : Usage) -> Json {
let fields : Map[String, Json] = Map([])
fields.set("input_tokens", json_int(self.input_tokens))
fields.set("output_tokens", json_int(self.output_tokens))
Json::object(fields)
}
///|
/// 响应的 JSON 视图。未上报用量时省略 `usage` 字段。
pub fn Response::to_json(self : Response) -> Json {
let fields : Map[String, Json] = Map([])
fields.set("status", json_int(self.status))
fields.set("body", self.body)
match self.usage {
Some(usage) => fields.set("usage", usage.to_json())
None => ()
}
Json::object(fields)
}
///|
/// 交互记录的 JSON 视图(请求 + 响应)。
pub fn Interaction::to_json(self : Interaction) -> Json {
let fields : Map[String, Json] = Map([])
fields.set("request", self.request.to_json())
fields.set("response", self.response.to_json())
Json::object(fields)
}