File size: 10,170 Bytes
5ecf925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
"""
Verify insertions tab β€” step through CR/TS screenshot pairs from a review
package (produced locally by the `cr-visual-pair` skill) and record OK /
Not OK / Wrong match verdicts. Verdicts live in st.session_state only; the
user downloads a text log at the end.
"""

import base64
import io
import json
import mimetypes
import zipfile
from pathlib import Path

import streamlit as st

VERDICT_LABELS = {
    "ok": "OK",
    "not_ok": "NOT OK",
    "wrong_match": "WRONG MATCH (page shown did not correspond)",
}

SKILL_PATH = Path(__file__).parent / "skills" / "cr-visual-pair" / "SKILL.md"

_ZOOM_CSS = """
<style>
.cr-zoom-thumb { cursor: zoom-in; display: block; }
.cr-zoom-thumb img { max-width: 100%; border-radius: 4px; display: block; }
.cr-zoom-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.9);
    z-index: 999999;
    align-items: center;
    justify-content: center;
}
.cr-zoom-overlay:target { display: flex; }
.cr-zoom-overlay img {
    max-width: 95vw;
    max-height: 95vh;
    object-fit: contain;
    cursor: zoom-out;
}
.cr-zoom-close {
    position: fixed;
    top: 16px;
    right: 24px;
    color: #fff;
    font-size: 2.5rem;
    text-decoration: none;
    line-height: 1;
}
</style>
"""


def _img_data_uri(path: Path) -> str:
    mime = mimetypes.guess_type(path.name)[0] or "image/png"
    data = base64.b64encode(path.read_bytes()).decode()
    return f"data:{mime};base64,{data}"


def _render_zoomable_image(img_path: Path, anchor_id: str) -> None:
    anchor_id = "".join(c if c.isalnum() else "-" for c in anchor_id)
    data_uri = _img_data_uri(img_path)
    st.markdown(
        f"""
<a class="cr-zoom-thumb" href="#{anchor_id}">
  <img src="{data_uri}">
</a>
<div class="cr-zoom-overlay" id="{anchor_id}">
  <a class="cr-zoom-close" href="#">&times;</a>
  <a href="#"><img src="{data_uri}"></a>
</div>
""",
        unsafe_allow_html=True,
    )


def _render_skill_card() -> None:
    with st.expander("🟒 cr-visual-pair"):
        if not SKILL_PATH.exists():
            st.error(f"Skill file not found at {SKILL_PATH}")
            return
        content = SKILL_PATH.read_text()
        st.caption(
            "Run this skill locally (Windows + Word) to produce a `review_package.zip` "
            "from an ApplyCRs run. Save it as `~/.claude/skills/cr-visual-pair/SKILL.md`, "
            "or download it below."
        )
        st.download_button(
            "⬇ Download SKILL.md",
            data=content,
            file_name="SKILL.md",
            mime="text/markdown",
            key="verify_skill_download",
        )
        st.code(content, language="markdown")


def _extract_package(uploaded, dest_dir: Path) -> dict | None:
    dest_dir.mkdir(parents=True, exist_ok=True)
    zip_bytes = uploaded.getbuffer()
    with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
        zf.extractall(dest_dir)
    manifest_path = dest_dir / "manifest.json"
    if not manifest_path.exists():
        return None
    return json.loads(manifest_path.read_text())


def _build_log(manifest: dict, verdicts: dict, review_dir: Path) -> str:
    lines = []
    for pair in manifest.get("pairs", []):
        pid = pair["id"]
        verdict = verdicts.get(pid)
        verdict_label = VERDICT_LABELS.get(verdict, "UNREVIEWED")
        ts_spec = pair.get("ts_spec", "?")
        ts_version = pair.get("ts_version", "?")
        section = pair.get("section_number", "?")
        match_note = "" if pair.get("matched", True) else "  [auto-flagged: low-confidence match]"
        lines.append(
            f"TS {ts_spec} v{ts_version}  [section {section}]  : {verdict_label}{match_note}"
        )
        for change in pair.get("changes", []):
            change_note = "" if change.get("matched", True) else "  [low-confidence match]"
            lines.append(
                f"  - CR {change.get('cr_uid', '?')} ({change.get('change_type', '?')})"
                f"{change_note}"
            )
    return "\n".join(lines) + "\n"


def render_verify_tab(session_dir_fn, sid: str) -> None:
    st.subheader("Verify insertions")
    st.caption(
        "Upload a review package (generated locally with the `cr-visual-pair` skill) "
        "and step through each CR ↔ TS screenshot pair to confirm the insertion is correct."
    )

    _render_skill_card()
    st.markdown(_ZOOM_CSS, unsafe_allow_html=True)

    review_dir = session_dir_fn(sid) / "review"
    manifest_key = "verify_manifest"
    review_dir_key = "verify_review_dir"

    if manifest_key not in st.session_state:
        uploaded = st.file_uploader(
            "Upload review package (.zip)", type=["zip"], key="verify_upload"
        )
        if uploaded is not None:
            manifest = _extract_package(uploaded, review_dir)
            if manifest is None:
                st.error("This zip does not contain a manifest.json β€” not a valid review package.")
                return
            st.session_state[manifest_key] = manifest
            st.session_state[review_dir_key] = str(review_dir)
            st.session_state["verify_idx"] = 0
            st.session_state["verify_verdicts"] = {}
            st.rerun()
        return

    manifest = st.session_state[manifest_key]
    pairs = manifest.get("pairs", [])
    review_dir = Path(st.session_state[review_dir_key])

    if not pairs:
        st.warning("This review package has no pairs to review.")
        if st.button("Upload a different package"):
            for k in (manifest_key, review_dir_key, "verify_idx", "verify_verdicts"):
                st.session_state.pop(k, None)
            st.rerun()
        return

    idx = st.session_state.setdefault("verify_idx", 0)
    verdicts = st.session_state.setdefault("verify_verdicts", {})
    idx = max(0, min(idx, len(pairs) - 1))
    st.session_state["verify_idx"] = idx

    pair = pairs[idx]
    pid = pair["id"]

    n_ok = sum(1 for v in verdicts.values() if v == "ok")
    n_not_ok = sum(1 for v in verdicts.values() if v == "not_ok")
    n_wrong = sum(1 for v in verdicts.values() if v == "wrong_match")
    n_reviewed = len(verdicts)
    st.caption(
        f"Pair {idx + 1} / {len(pairs)}  Β·  "
        f"Reviewed: {n_reviewed}/{len(pairs)}  Β·  βœ… {n_ok}  ❌ {n_not_ok}  ⚠ {n_wrong}"
    )

    st.markdown(
        f"**TS** `{pair.get('ts_spec', '?')}` v{pair.get('ts_version', '?')}  Β·  "
        f"section `{pair.get('section_number', '?')}`"
    )
    if not pair.get("matched", True):
        st.warning(
            "⚠ The pairing tool could not confidently locate this section's page in the "
            "TS β€” double-check whether it's the right page before judging the insertion."
        )

    existing_verdict = verdicts.get(pid)
    if existing_verdict:
        st.info(f"Current verdict: **{VERDICT_LABELS[existing_verdict]}**")

    col_cr, col_ts = st.columns(2)
    with col_cr:
        st.markdown("**CR (source change)**")
        changes = pair.get("changes", [])
        if changes:
            for change in changes:
                label = f"`{change.get('cr_uid', '?')}` Β· {change.get('change_type', '?')}"
                if not change.get("matched", True):
                    label += "  ⚠ low-confidence"
                st.caption(label)
                if change.get("summary"):
                    st.caption(change["summary"])
                cr_images = change.get("cr_images", [])
                if cr_images:
                    for img_idx, img in enumerate(cr_images):
                        img_path = review_dir / img
                        if img_path.exists():
                            _render_zoomable_image(
                                img_path, f"cr-zoom-{pid}-{change.get('cr_uid', '?')}-{img_idx}"
                            )
                        else:
                            st.error(f"Missing image: {img}")
                else:
                    st.warning("No CR image for this change.")
        else:
            st.warning("No CR changes recorded for this pair.")
    with col_ts:
        st.markdown("**TS (applied output)**")
        ts_images = pair.get("ts_images", [])
        if ts_images:
            for img_idx, img in enumerate(ts_images):
                img_path = review_dir / img
                if img_path.exists():
                    _render_zoomable_image(img_path, f"ts-zoom-{pid}-{img_idx}")
                else:
                    st.error(f"Missing image: {img}")
        else:
            st.warning("No TS image for this pair β€” pairing tool did not find a candidate page.")

    def _advance():
        if st.session_state["verify_idx"] < len(pairs) - 1:
            st.session_state["verify_idx"] += 1

    col1, col2, col3, col4, col5 = st.columns(5)
    with col1:
        if st.button("βœ… OK", type="primary", key=f"ok_{pid}"):
            verdicts[pid] = "ok"
            _advance()
            st.rerun()
    with col2:
        if st.button("❌ Not OK", key=f"not_ok_{pid}"):
            verdicts[pid] = "not_ok"
            _advance()
            st.rerun()
    with col3:
        if st.button("⚠ Wrong match", key=f"wrong_{pid}"):
            verdicts[pid] = "wrong_match"
            _advance()
            st.rerun()
    with col4:
        if st.button("← Prev", disabled=idx == 0, key=f"prev_{pid}"):
            st.session_state["verify_idx"] -= 1
            st.rerun()
    with col5:
        if st.button("Next β†’", disabled=idx == len(pairs) - 1, key=f"next_{pid}"):
            st.session_state["verify_idx"] += 1
            st.rerun()

    if idx == len(pairs) - 1 and pid in verdicts:
        st.success("Review complete β€” all pairs have a verdict. Download the log below.")

    st.divider()
    log_text = _build_log(manifest, verdicts, review_dir)
    st.download_button(
        "⬇ Download review log",
        data=log_text,
        file_name=f"cr_review_{sid[:8]}.log",
        mime="text/plain",
    )

    if st.button("Upload a different package", key="verify_reset"):
        for k in (manifest_key, review_dir_key, "verify_idx", "verify_verdicts"):
            st.session_state.pop(k, None)
        st.rerun()