File size: 5,499 Bytes
ab5ea78
 
 
 
 
 
 
 
 
 
49dfe4d
 
 
 
 
ab5ea78
 
 
 
 
6f5d619
 
 
 
 
 
 
 
ab5ea78
 
 
 
 
6f5d619
ab5ea78
 
6f5d619
ab5ea78
 
 
6f5d619
49dfe4d
 
 
ab5ea78
 
 
 
 
 
 
 
6f5d619
ab5ea78
 
6f5d619
 
 
 
 
 
 
 
 
 
 
 
ab5ea78
 
 
6f5d619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab5ea78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
"""The frequency-sorted review queue — the pipeline's actual product.

Ordering is the product decision here, and it targets the bottleneck directly:
the expert is the scarce resource, so they should hit the terms whose definition
propagates furthest first. Conflicts are promoted above frequency regardless,
because a contradiction is a decision only they can make.

Each row carries page, section and the verbatim span so review is a matter of
checking a quote against a page, not a claim against memory. That is what makes
the queue finishable.

v2 dropped `extraction_status`, `diff_status`, `definition_conflict` and
`conflict_variants` from the row: all four are already expressed by
`review_reason`, which is the field a reviewer actually reads, and they remain
on the entry for a consumer that wants them.
"""

from __future__ import annotations


def build_queue(entries: list[dict], domain: dict | None = None) -> list[dict]:
    """Rows an expert works through, most-decision-worthy first.

    **The queue is no longer glossary-only.** Every row carries `row_kind`, and
    a consumer must switch on it rather than assume `term`: an unresolved key
    parameter has a surface and no term, no definition and no mention count.
    """

    def sort_key(entry: dict):
        conflicting = (
            entry.get("diff_status") == "conflicting"
            or entry.get("definition_conflict") is True
        )
        return (0 if conflicting else 2, -int(entry.get("mention_count", 0) or 0))

    queue = []
    for entry in sorted(entries, key=sort_key):
        provenance = entry.get("provenance") or {}
        queue.append(
            {
                "row_kind": "term",
                # What an approve/edit/reject decision attaches to. Without it a
                # re-run loses which rows the expert already cleared.
                "term_id": entry.get("term_id"),
                "term": entry.get("term"),
                "definition": entry.get("definition"),
                "source_wording": entry.get("source_wording"),
                "mention_count": entry.get("mention_count", 0),
                "page": provenance.get("page"),
                "section_no": provenance.get("section_no"),
                "span": provenance.get("span"),
                "review_reason": _reason(entry),
                "_sort": sort_key(entry),
            }
        )

    queue.extend(_key_parameter_rows(domain))

    # Ranked once, over both kinds. Unresolved parameters sit between conflicts
    # and routine confirmations: a contradiction is still the more urgent call,
    # but "the model named a parameter nothing in the document corroborates" is
    # a judgement only the expert can make, and it should not sink beneath 60
    # routine rows where nobody reaches it.
    queue.sort(key=lambda row: row["_sort"])
    for rank, row in enumerate(queue, start=1):
        row["rank"] = rank
        del row["_sort"]
    return queue


def _key_parameter_rows(domain: dict | None) -> list[dict]:
    """One row per key parameter that resolved to no extracted term.

    Resolved parameters produce no row — they are corroborated and need nothing
    from a human. Only the dangles are worth an expert's attention, and they are
    worth it precisely because this branch has no span check: an unresolvable
    surface is the one hallucination signal it can produce.
    """
    provenance = (domain or {}).get("provenance") or {}
    rows = []
    for parameter in (domain or {}).get("key_parameters") or []:
        if parameter.get("term_id"):
            continue
        rows.append(
            {
                "row_kind": "key_parameter",
                # The decision attaches to the domain entry plus the surface —
                # there is no term_id, which is the entire point of the row.
                "term_id": None,
                "domain_id": (domain or {}).get("domain_id"),
                "term": parameter.get("surface"),
                "definition": None,
                "source_wording": None,
                "mention_count": 0,
                "page": provenance.get("page"),
                "section_no": provenance.get("section_no"),
                "span": provenance.get("span"),
                "review_reason": (
                    "named as a key parameter but no extracted term corroborates it "
                    "— confirm it is real"
                ),
                "_sort": (1, 0),
            }
        )
    return rows


def _reason(entry: dict) -> str:
    if entry.get("definition_conflict") or entry.get("diff_status") == "conflicting":
        return "conflicting definitions — expert decision required"
    if _wording_differs(entry):
        return "source wording differs from the expanded name — confirm which is correct"
    if entry.get("extraction_status") == "no_definition_found":
        return "term found but no definition in document"
    if not entry.get("definition"):
        return "definition rejected by span check or absent"
    return "routine confirmation"


def _wording_differs(entry: dict) -> bool:
    """The document says "Physical of Availability"; the expansion says
    "Physical Availability". Surfacing that to the expert is a locked
    requirement, so it earns its own review reason."""
    source = (entry.get("source_wording") or "").strip().casefold()
    full = (entry.get("full_name") or "").strip().casefold()
    return bool(source and full) and full not in source