///|
fn visit_input_json(v : @visit.Visit) -> Json {
let measurements : Array[Json] = v.measurements.map(m => {
"lesion_id": m.lesion_id,
"observation": observation_json(m.observation),
})
{
"id": v.id,
"date": v.date,
"technique_id": v.technique_id,
"measurements": measurements.to_json(),
}
}
///|
fn change(
visit_id : String,
field : String,
before : String?,
after : String?,
lesion_id? : String,
) -> @evidence.InputChange {
{ visit_id, lesion_id, field, before, after, }
}
///|
/// Compare replayed tracks of the same exact baseline profile. Visit and lesion
/// identity, rather than array position, aligns changes. Inputs remain untouched.
pub fn compare_tracks(
before : @evidence.Trajectory,
after : @evidence.Trajectory,
) -> Result[@evidence.TrackRevision, Array[Diagnostic]] {
if before.study != after.study {
return Err([
diagnostic(
"revision_baseline",
"Revisions require the same study identity, baseline, acquisition and ordered target set.",
field="$.study",
),
])
}
let changes = []
let impacts : Array[@evidence.VisitImpact] = []
let old_visits : Map[String, @visit.Visit] = Map([])
let old_assessments : Map[String, @evidence.VisitAssessment] = Map([])
let new_ids : Map[String, Bool] = Map([])
for v in before.visits {
old_visits[v.id] = v
}
for a in before.assessments {
old_assessments[a.visit_id] = a
}
for i, v in after.visits {
new_ids[v.id] = true
match old_visits.get(v.id) {
None =>
changes.push(
change(v.id, "visit", None, Some(visit_input_json(v).stringify())),
)
Some(old) => {
if old.date != v.date {
changes.push(change(v.id, "date", Some(old.date), Some(v.date)))
}
if old.technique_id != v.technique_id {
changes.push(
change(
v.id,
"technique_id",
Some(old.technique_id),
Some(v.technique_id),
),
)
}
let old_measurements : Map[String, @visit.Observation] = Map([])
let new_measurements : Map[String, @visit.Observation] = Map([])
for m in old.measurements {
old_measurements[m.lesion_id] = m.observation
}
for m in v.measurements {
new_measurements[m.lesion_id] = m.observation
}
for target in after.study.targets {
let original = old_measurements.get(target.id)
let revised = new_measurements.get(target.id)
if original != revised {
changes.push(
change(
v.id,
"observation",
original.map(o => observation_json(o).stringify()),
revised.map(o => observation_json(o).stringify()),
lesion_id=target.id,
),
)
}
}
}
}
let original = old_assessments.get(v.id)
let revised = after.assessments[i]
if original != Some(revised) {
impacts.push({ visit_id: v.id, before: original, after: Some(revised), })
}
}
for v in before.visits {
if !new_ids.contains(v.id) {
changes.push(
change(v.id, "visit", Some(visit_input_json(v).stringify()), None),
)
impacts.push({
visit_id: v.id,
before: old_assessments.get(v.id),
after: None,
})
}
}
Ok({ study_id: after.study.id, changes, impacts, })
}
///|
fn impact_json(a : @evidence.VisitAssessment?) -> Json {
guard a is Some(a) else { return Json::null() }
let findings : Array[Json] = a.findings.map(f => {
"code": f.code,
"lesion_id": optional_string(f.lesion_id),
"message": f.message,
})
{
"date": a.date,
"target_response": a.response.code(),
"sum_mm": optional_mm(a.sum_tenths_mm),
"prior_nadir_mm": format_diameter_mm(a.nadir_sum_tenths_mm),
"prior_nadir_visit_id": a.nadir_visit_id,
"findings": findings.to_json(),
"rule_checks": checks_json(a),
}
}
///|
pub fn render_revision_json(revision : @evidence.TrackRevision) -> String {
let changes : Array[Json] = revision.changes.map(c => {
"visit_id": c.visit_id,
"lesion_id": optional_string(c.lesion_id),
"field": c.field,
"before": optional_string(c.before),
"after": optional_string(c.after),
})
let impacts : Array[Json] = revision.impacts.map(i => {
"visit_id": i.visit_id,
"before": impact_json(i.before),
"after": impact_json(i.after),
})
let document : Json = {
"schema_version": 1,
"scope": "target_lesions_only",
"study_id": revision.study_id,
"input_changes": changes.to_json(),
"affected_visits": impacts.to_json(),
}
document.stringify(indent=2) + "\n"
}
///|
fn impact_label(a : @evidence.VisitAssessment?) -> String {
match a {
None => "—"
Some(a) =>
a.date +
" / " +
a.response.code() +
" / S=" +
mm_or_missing(a.sum_tenths_mm) +
" mm / N=" +
format_diameter_mm(a.nadir_sum_tenths_mm) +
" mm (" +
a.nadir_visit_id +
")"
}
}
///|
pub fn render_revision_markdown(
revision : @evidence.TrackRevision,
language? : ReportLanguage = English,
) -> String {
let out = StringBuilder()
out.write_string(
"# " +
tr(language, "Target-lesion revision comparison", "靶病灶修订对比") +
"\n\n" +
markdown_escape(revision.study_id) +
"\n\n" +
tr(language, "Input changes", "输入变化") +
": \{revision.changes.length()}; " +
tr(language, "Affected visits", "受影响随访") +
": \{revision.impacts.length()}\n\n",
)
out.write_string(
tr(
language, "| Visit | Target | Field | Before | After |\n", "| 随访 | 病灶 | 字段 | 修订前 | 修订后 |\n",
) +
"|---|---|---|---|---|\n",
)
for c in revision.changes {
out.write_string(
"| " +
markdown_escape(c.visit_id) +
" | " +
markdown_escape(c.lesion_id.unwrap_or("—")) +
" | " +
markdown_escape(c.field) +
" | " +
markdown_escape(c.before.unwrap_or("—")) +
" | " +
markdown_escape(c.after.unwrap_or("—")) +
" |\n",
)
}
out.write_string(
"\n" +
tr(
language, "| Affected visit | Before date / category / sum / prior minimum | After date / category / sum / prior minimum |\n",
"| 受影响随访 | 修订前 日期 / 分类 / 总和 / 此前最低值 | 修订后 日期 / 分类 / 总和 / 此前最低值 |\n",
) +
"|---|---|---|\n",
)
for i in revision.impacts {
out.write_string(
"| " +
markdown_escape(i.visit_id) +
" | " +
markdown_escape(impact_label(i.before)) +
" | " +
markdown_escape(impact_label(i.after)) +
" |\n",
)
}
out.write_string(
"\n" +
tr(
language, "Both tracks are independently replayed. Findings and rule checks in JSON explain changes even when the category is unchanged. Target-lesion results only.\n",
"两版记录分别独立重算。即使分类未变,JSON 中的复核提示和条件比较仍可追溯变化。结果仅描述靶病灶。\n",
),
)
out.to_string()
}
///|
pub fn render_revision_html(
revision : @evidence.TrackRevision,
language? : ReportLanguage = English,
) -> String {
let out = StringBuilder()
out.write_string(
"" +
tr(language, "Revision comparison", "修订对比") +
" " +
tr(language, "Target-lesion revision comparison", "靶病灶修订对比") +
"
" +
html_escape(revision.study_id) +
"
" +
tr(language, "Input changes", "输入变化") +
": \{revision.changes.length()}; " +
tr(language, "Affected visits", "受影响随访") +
": \{revision.impacts.length()}
" +
tr(
language, "Visit Target / field Before After ",
"随访 病灶 / 字段 修订前 修订后 ",
) +
" ",
)
for c in revision.changes {
out.write_string(
"" +
html_escape(c.visit_id) +
" " +
html_escape(c.lesion_id.unwrap_or("—")) +
" / " +
html_escape(c.field) +
" " +
html_escape(c.before.unwrap_or("—")) +
"" +
html_escape(c.after.unwrap_or("—")) +
" ",
)
}
out.write_string(
"
" +
tr(language, "Downstream impact", "后续影响") +
"
" +
tr(
language, "Affected visit Before date / category / sum / prior minimum After date / category / sum / prior minimum ",
"受影响随访 修订前 日期 / 分类 / 总和 / 此前最低值 修订后 日期 / 分类 / 总和 / 此前最低值 ",
) +
" ",
)
for i in revision.impacts {
out.write_string(
"" +
html_escape(i.visit_id) +
" " +
html_escape(impact_label(i.before)) +
" " +
html_escape(impact_label(i.after)) +
" ",
)
}
out.write_string(
"
" +
tr(
language, "Independent replay of both tracks; target-lesion results only. JSON retains findings and exact comparisons for each affected visit.",
"两版记录分别重算;结果仅描述靶病灶。JSON 保留每次受影响随访的复核提示与整数比较。",
) +
"
\n",
)
out.to_string()
}