///|
pub fn json_escape(text : String) -> String {
let builder = StringBuilder()
for c in text {
match c {
'"' => builder.write_string("\\\"")
'\\' => builder.write_string("\\\\")
'\n' => builder.write_string("\\n")
'\r' => builder.write_string("\\r")
'\t' => builder.write_string("\\t")
_ => builder.write_char(c)
}
}
builder.to_string()
}
///|
fn json_string(value : String) -> String {
"\"\{json_escape(value)}\""
}
///|
pub fn json_span(span : Span) -> String {
"{\"start\":\{span.start},\"end\":\{span.end}}"
}
///|
pub fn finding_to_json(finding : Finding) -> String {
"{" +
"\"id\":" +
json_string(finding.id) +
"," +
"\"kind\":" +
json_string(phi_kind_name(finding.kind)) +
"," +
"\"label\":" +
json_string(finding.label) +
"," +
"\"start\":\{finding.start}," +
"\"end\":\{finding.end}," +
"\"text\":" +
json_string(finding.text) +
"," +
"\"replacement\":" +
json_string(finding.replacement) +
"," +
"\"rule_id\":" +
json_string(finding.rule_id) +
"," +
"\"confidence\":\{finding.confidence}" +
"}"
}
///|
pub fn offset_to_json(offset : OffsetMap) -> String {
"{" +
"\"original_start\":\{offset.original_start}," +
"\"original_end\":\{offset.original_end}," +
"\"replacement_start\":\{offset.replacement_start}," +
"\"replacement_end\":\{offset.replacement_end}," +
"\"finding_id\":" +
json_string(offset.finding_id) +
"}"
}
///|
pub fn map_to_json(values : Map[String, Int]) -> String {
let pieces = []
for key, value in values {
pieces.push(json_string(key) + ":\{value}")
}
"{" + pieces.join(",") + "}"
}
///|
pub fn audit_to_json(report : AuditReport) -> String {
let finding_json = report.findings.map(finding_to_json).join(",")
let offset_json = report.offsets.map(offset_to_json).join(",")
"{" +
"\"input_length\":\{report.input_length}," +
"\"output_length\":\{report.output_length}," +
"\"finding_count\":\{report.finding_count}," +
"\"applied_count\":\{report.applied_count}," +
"\"counts\":" +
map_to_json(report.counts) +
"," +
"\"findings\":[" +
finding_json +
"]," +
"\"offsets\":[" +
offset_json +
"]" +
"}"
}
///|
pub fn result_to_json(result : DeidResult) -> String {
"{" +
"\"text\":" +
json_string(result.text) +
"," +
"\"findings\":[" +
result.findings.map(finding_to_json).join(",") +
"]," +
"\"offsets\":[" +
result.offsets.map(offset_to_json).join(",") +
"]," +
"\"audit\":" +
audit_to_json(result.audit) +
"}"
}
///|
pub fn audit_csv(report : AuditReport) -> String {
let lines = ["id,kind,label,start,end,confidence,rule_id,text,replacement"]
for finding in report.findings {
lines.push(
[
csv_cell(finding.id),
csv_cell(phi_kind_name(finding.kind)),
csv_cell(finding.label),
"\{finding.start}",
"\{finding.end}",
"\{finding.confidence}",
csv_cell(finding.rule_id),
csv_cell(finding.text),
csv_cell(finding.replacement),
].join(","),
)
}
lines.join("\n")
}
///|
fn csv_cell(value : String) -> String {
if value.contains(",") || value.contains("\"") || value.contains("\n") {
"\"" + value.replace_all(old="\"", new="\"\"") + "\""
} else {
value
}
}
///|
pub fn findings_csv(findings : Array[Finding]) -> String {
audit_csv({
input_length: 0,
output_length: 0,
finding_count: findings.length(),
applied_count: 0,
counts: Map([]),
findings,
offsets: [],
})
}
///|
pub fn audit_plain_text(report : AuditReport) -> String {
let lines = [
"moonbit-deid audit",
"input length: \{report.input_length}",
"output length: \{report.output_length}",
"findings: \{report.finding_count}",
"applied: \{report.applied_count}",
]
for key, value in report.counts {
lines.push("\{key}: \{value}")
}
lines.join("\n")
}
///|
pub fn audit_risk_level(report : AuditReport) -> RiskLevel {
highest_risk(report.findings)
}
///|
pub fn audit_has_critical(report : AuditReport) -> Bool {
report.findings.any(fn(item) { risk_level(item) == Critical })
}
///|
pub fn audit_is_empty(report : AuditReport) -> Bool {
report.finding_count == 0
}
///|
pub fn audit_offset_coverage(report : AuditReport) -> Int {
report.offsets.fold(init=0, (total, item) => {
total + item.original_end - item.original_start
})
}
///|
pub fn audit_replacement_delta(report : AuditReport) -> Int {
report.output_length - report.input_length
}
///|
pub fn audit_summary_lines(report : AuditReport) -> Array[String] {
[
"input=\{report.input_length}",
"output=\{report.output_length}",
"findings=\{report.finding_count}",
"applied=\{report.applied_count}",
"covered=\{audit_offset_coverage(report)}",
"risk=\{audit_risk_level(report)}",
]
}
///|
pub fn audit_summary(report : AuditReport) -> String {
audit_summary_lines(report).join(" ")
}
///|
pub fn counts_as_sorted_lines(counts : Map[String, Int]) -> Array[String] {
let lines = []
for key, value in counts {
lines.push("\{key}=\{value}")
}
lines.sort()
lines
}
///|
pub fn audit_markdown_table(report : AuditReport) -> String {
let lines = [
"| ID | Kind | Span | Confidence | Replacement |", "| --- | --- | ---: | ---: | --- |",
]
for item in report.findings {
lines.push(
"| \{item.id} | \{phi_kind_name(item.kind)} | \{item.start}..\{item.end} | \{item.confidence} | \{item.replacement} |",
)
}
lines.join("\n")
}
///|
pub fn audit_report_with_context(
input : String,
report : AuditReport,
radius : Int,
) -> String {
let lines = [audit_summary(report), "", audit_markdown_table(report)]
for item in report.findings {
let window = finding_context(input, item, radius)
lines.push("\{item.id}: \{quote_for_log(window.text)}")
}
lines.join("\n")
}
///|
pub fn policy_decisions_to_csv(decisions : Array[PolicyDecision]) -> String {
let lines = ["finding_id,action,reason"]
for decision in decisions {
lines.push(
[
csv_cell(decision.finding_id),
action_name(decision.action),
csv_cell(decision.reason),
].join(","),
)
}
lines.join("\n")
}
///|
pub fn schema_description() -> String {
[
"result.text: redacted document", "result.findings[]: accepted PHI spans", "result.offsets[]: original/output coordinate mapping",
"result.audit: deterministic aggregate counters",
].join("\n")
}
///|
pub fn report_checksum(report : AuditReport) -> String {
stable_hash(audit_to_json(report))
}
///|
pub fn report_is_deterministic(left : AuditReport, right : AuditReport) -> Bool {
audit_to_json(left) == audit_to_json(right)
}