NoplagDocs

Report JSON cookbook

Rendering highlights from character offsets, walking sources and passages, and handling partial coverage — with working code.

The report from GET /v1/checks/{id}/report is designed for building UIs: every match carries exact character offsets into the query text, and the report includes the query text itself so you can render highlights without keeping your own copy. This page is the recipes; the field-by-field reference is in the API overview.

The shape you're working with

{
  "overall_similarity_pct": 12.4,
  "query_text": "…the full submitted text…",
  "unique_passages": [[120, 348], [1042, 1305]],
  "sources": [
    {
      "source_document_id": "…",
      "similarity_pct": 8.1,
      "source_filename": "essay-2019.docx",
      "source_url": null,
      "passages": [
        {
          "query_start": 120,
          "query_end": 348,
          "overlap_text_preview": "…the matched source text…"
        }
      ]
    }
  ],
  "coverage": "full"
}

Two views of the same matches: sources[].passages[] is per-source (who matched what), unique_passages is the deduplicated union over your text (what to highlight, and what the score sums).

Recipe: highlight matched text

Offsets are [start, end) character positions into query_text. unique_passages is merged and non-overlapping, so rendering is a single pass — walk the text, alternating plain and highlighted slices:

function renderHighlights(report) {
  const text = report.query_text;
  const parts = [];
  let cursor = 0;
  for (const [start, end] of report.unique_passages) {
    if (start > cursor) parts.push(plain(text.slice(cursor, start)));
    parts.push(mark(text.slice(start, end)));
    cursor = end;
  }
  parts.push(plain(text.slice(cursor)));
  return parts;
}

The same in Python, producing HTML:

import html
 
def render(report):
    text, out, cursor = report["query_text"], [], 0
    for start, end in report["unique_passages"]:
        out.append(html.escape(text[cursor:start]))
        out.append(f"<mark>{html.escape(text[start:end])}</mark>")
        cursor = end
    out.append(html.escape(text[cursor:]))
    return "".join(out)

Escape the text slices (as above) — the submitted document is untrusted input to your UI.

Recipe: which sources matched this passage?

Map each unique passage to the sources whose passages overlap it:

function sourcesFor([start, end], report) {
  return report.sources.filter((s) =>
    s.passages.some((p) => p.query_start < end && p.query_end > start),
  );
}

That interval-overlap test is all you need for click-a-passage → show its sources, because per-source passages may cover only part of a merged unique passage.

Recipe: compute your own score

overall_similarity_pct is derived from total_matched_chars (the sum of unique_passages lengths) over the text length. If you filter matches in your UI — say, dropping passages under N characters — recompute honestly from the passages you kept, and label the score as filtered.

Handle partial coverage

Very large documents can hit the per-check time budget. Check coverage: when it's "partial" (coverage_reason: "time_cap"), every match found is present, but unchecked chunks remain — checked_chunks / total_chunks tell you how far it got. Say so in your UI; don't present a partial scan as a complete one.

Don't diff the preview text

overlap_text_preview is a display string for the source side, not an offset-stable copy — render it, but never compute positions against it. Positions always come from query_start / query_end on your own text.

On this page

Report JSON cookbook — Noplag Docs