Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/sp42-assessment/src/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ pub const SKIPPED_BOOK_UNRESOLVED: &str =
/// problem) — the book may well have a record; nothing is implied about it.
pub const SKIPPED_BOOK_LOOKUP_FAILED: &str = "cites a book whose catalog lookup did not complete — the tool could not reach the catalog; nothing is implied about the book";

/// Skip reason: a short cite ({{sfn}}-style) whose bibliography entry could
/// not be resolved to a usable identifier — never guessed, disclosed.
pub const SKIPPED_SHORT_CITE_UNRESOLVED: &str =
"is a short cite the tool could not resolve to a bibliography entry with a usable identifier";

/// Books consulted section heading.
pub const BUCKET_BOOKS_CONSULTED: &str = "==== Books consulted ====";

Expand Down
3 changes: 3 additions & 0 deletions crates/sp42-assessment/src/ga_appendix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ fn render_skipped_section(output: &mut String, report: &sp42_citation::PageVerif
crate::copy::SKIPPED_BOOK_UNRESOLVED
}
}
sp42_citation::SkippedReason::UnresolvedShortCite => {
crate::copy::SKIPPED_SHORT_CITE_UNRESOLVED
}
});
// Which book: identifiers (and cited page) make multiple books
// under one ref distinguishable (Codex round 4, PR 154).
Expand Down
92 changes: 92 additions & 0 deletions crates/sp42-citation/src/citation/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,52 @@ pub struct PageVerificationReport {
pub stats: PageVerificationStats,
}

/// Project a page report's findings into review-session overlay markers
/// (PRD-0017): one marker per finding, joined to the article by the
/// finding's `ref_id` — the same anchor coordinate review prompts use — so
/// a review surface can show the report's problems in the context of the
/// article instead of as detached text. Verdicts keep their wire labels;
/// claims are truncated to the outline display limit; grounding caveats and
/// unavailable reasons ride along as `detail`. Findings without a `ref_id`
/// (the standalone single-claim path) cannot anchor and are skipped.
#[must_use]
pub fn review_finding_markers(
report: &PageVerificationReport,
) -> Vec<sp42_platform::ReviewFindingMarker> {
report
.findings
.iter()
.filter(|finding| !finding.ref_id.is_empty())
.map(|finding| sp42_platform::ReviewFindingMarker {
ref_id: finding.ref_id.clone(),
verdict: finding.verdict.as_wire().to_string(),
claim: sp42_platform::truncate_outline_text(&finding.claim),
detail: finding_marker_detail(finding),
})
.collect()
}

/// The marker's short qualifier: the unavailable reason for an abstention,
/// or the grounding caveat for a support judgment that did not locate
/// verbatim (SP42#25 layer 6 — surfaced, never silently upgraded).
fn finding_marker_detail(finding: &CitationFinding) -> Option<String> {
if finding.verdict == CitationVerdict::SourceUnavailable {
return finding
.source_unavailable_reason
.map(|reason| format!("source {}", reason.as_str()));
}
match finding.grounding_status {
crate::citation::verify::GroundingStatus::Located
| crate::citation::verify::GroundingStatus::NotApplicable => None,
crate::citation::verify::GroundingStatus::LocatedFuzzy => {
Some("support located by fuzzy match only".to_string())
}
crate::citation::verify::GroundingStatus::Unlocated => {
Some("support quote not located in source (unverified)".to_string())
}
}
}

/// Try archive fallbacks if the primary URL came back `SourceUnavailable`.
/// Returns the original outcome if no archives work or if no archives exist.
async fn try_archive_fallback<C, M>(
Expand Down Expand Up @@ -1004,6 +1050,52 @@ mod orchestrator_tests {
}
}

#[test]
fn review_finding_markers_project_the_report_onto_review_anchors() {
let judged = |level| CitationVerdict::Judged(level);
let mut unlocated = mk_finding("r_unlocated", 1, judged(SupportLevel::Supported));
unlocated.grounding_status = GroundingStatus::Unlocated;
let mut dead = mk_finding("r_dead", 2, CitationVerdict::SourceUnavailable);
dead.source_unavailable_reason = Some(SourceUnavailableReason::Unreachable);
let mut long_claim = mk_finding("r_long", 3, judged(SupportLevel::Partial));
long_claim.claim = "x".repeat(500);
// The standalone single-claim path has no page ref: nothing to anchor to.
let standalone = mk_finding("", 4, judged(SupportLevel::NotSupported));
let report = PageVerificationReport {
wiki_id: "enwiki".into(),
title: "Cats".into(),
rev_id: 7,
findings: vec![
mk_finding("r_ok", 0, judged(SupportLevel::Supported)),
unlocated,
dead,
long_claim,
standalone,
],
skipped: Vec::new(),
extraction_failures: Vec::new(),
book_resolutions: Vec::new(),
stats: PageVerificationStats::default(),
};

let markers = super::review_finding_markers(&report);

assert_eq!(markers.len(), 4, "ref-less standalone finding is skipped");
assert_eq!(markers[0].ref_id, "r_ok");
assert_eq!(markers[0].verdict, "supported");
assert_eq!(markers[0].detail, None);
assert_eq!(
markers[1].detail.as_deref(),
Some("support quote not located in source (unverified)")
);
assert_eq!(markers[2].verdict, "source_unavailable");
assert_eq!(markers[2].detail.as_deref(), Some("source unreachable"));
assert!(
markers[3].claim.chars().count() <= 200,
"claims are truncated to the outline display limit"
);
}

#[test]
fn apply_reverified_finding_moves_verdict_and_updates_tallies() {
let judged = |level| CitationVerdict::Judged(level);
Expand Down
40 changes: 28 additions & 12 deletions crates/sp42-citation/src/citation/urls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use regex::Regex;
use url::Url;

use crate::errors::CitationVerificationError;
use sp42_platform::wikitext_editor::normalize_title_spacing;

/// Accepted wiki language/site codes — the SSRF guard before host interpolation.
static WIKI_CODE: LazyLock<Regex> = LazyLock::new(|| {
Expand Down Expand Up @@ -166,9 +167,14 @@ pub struct PageTarget {
/// - `https://…/w/index.php?title=Foo&oldid=123` → title `Foo`, `rev_id` `123`.
///
/// Underscores become spaces and percent-escapes are decoded, matching `MediaWiki`
/// title normalization. Anything that is not an `http(s)` URL is treated as a
/// bare title verbatim. The wiki the URL points at is *not* inspected — the caller
/// chooses the wiki separately, and a host mismatch is theirs to reconcile.
/// title normalization. A bare (non-URL) title gets the same spacing
/// normalization — underscores to spaces, whitespace runs collapsed — but no
/// percent-decoding, since a literal `%` is valid title text. First-letter
/// case is deliberately *not* folded: case rules are wiki-dependent
/// (Wiktionary titles are case-sensitive), and conflating two distinct pages
/// would be worse than treating one page as two. The wiki the URL points at
/// is *not* inspected — the caller chooses the wiki separately, and a host
/// mismatch is theirs to reconcile.
#[must_use]
pub fn parse_page_target(input: &str) -> PageTarget {
let trimmed = input.trim();
Expand Down Expand Up @@ -207,20 +213,15 @@ pub fn parse_page_target(input: &str) -> PageTarget {
}

PageTarget {
title: trimmed.to_string(),
title: normalize_title_spacing(trimmed),
rev_id: 0,
}
}

/// Normalize a title extracted from a URL: percent-decode, then map underscores
/// to spaces (`MediaWiki` treats them as equivalent and the action API expects
/// spaces).
/// Normalize a title extracted from a URL: percent-decode, then apply the
/// spacing normalization.
fn normalize_title(raw: &str) -> String {
percent_decode_str(raw)
.decode_utf8_lossy()
.replace('_', " ")
.trim()
.to_string()
normalize_title_spacing(&percent_decode_str(raw).decode_utf8_lossy())
}

#[cfg(test)]
Expand All @@ -237,6 +238,21 @@ mod tests {
assert_eq!(target.rev_id, 0);
}

#[test]
fn parse_page_target_normalizes_bare_title_spacing() {
// Underscores are spaces in MediaWiki, and runs of either collapse
// to one — a bare title must not spell a different page (or review
// session) than the same title pasted as a URL.
assert_eq!(
parse_page_target("Grand_Exemple").title,
"Grand Exemple",
"underscore spelling matches the URL spelling"
);
assert_eq!(parse_page_target("A __ B C").title, "A B C");
// No percent-decoding for bare titles: a literal `%` is valid text.
assert_eq!(parse_page_target("100%_Design").title, "100% Design");
}

#[test]
fn parse_page_target_unwraps_a_wiki_url() {
// The reported failure: a pasted /wiki/ URL with an underscore subpage.
Expand Down
4 changes: 2 additions & 2 deletions crates/sp42-citation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ pub use citation::openlibrary_apply::{
};
pub use citation::page::{
PageVerificationReport, PageVerificationRequest, PageVerificationStats, ReverifyFindingRequest,
ReverifyFindingResponse, apply_reverified_finding, select_reverify_use_site,
verify_one_use_site, verify_page,
ReverifyFindingResponse, apply_reverified_finding, review_finding_markers,
select_reverify_use_site, verify_one_use_site, verify_page,
};
pub use citation::parsing::{
ParsedVerdict, canonicalize_verdict, parse_repair_response, parse_verdict_response,
Expand Down
Loading
Loading