Spaces:
Sleeping
Sleeping
Add comments and CSV export to conversation verification
Browse files
src/interface/simplified_gradio_app.py
CHANGED
|
@@ -348,7 +348,8 @@ def create_simplified_interface():
|
|
| 348 |
|
| 349 |
with gr.Row():
|
| 350 |
generate_conv_verification_btn = gr.Button("🛠 Generate from current chat", variant="primary")
|
| 351 |
-
conv_verify_download_btn = gr.DownloadButton("⬇️ Download
|
|
|
|
| 352 |
|
| 353 |
conv_verify_status = gr.Markdown(value="", visible=True)
|
| 354 |
conv_verify_exchange = gr.HTML(value="", label="Current Exchange")
|
|
@@ -359,6 +360,16 @@ def create_simplified_interface():
|
|
| 359 |
conv_prev_btn = gr.Button("⬅️ Previous")
|
| 360 |
conv_next_btn = gr.Button("Next ➡️")
|
| 361 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
with gr.Row():
|
| 363 |
with gr.Column(scale=1):
|
| 364 |
conv_position = gr.Markdown(value="")
|
|
@@ -2066,6 +2077,77 @@ To revert, use "Reset to Default" button.
|
|
| 2066 |
stats = f"<div><strong>Reviewed:</strong> {checked}/{len(records)}</div>"
|
| 2067 |
return html, pos, stats
|
| 2068 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2069 |
def _generate_conv_verification(session: SimplifiedSessionData):
|
| 2070 |
if session is None or not hasattr(session.app_instance, "conversation_logger"):
|
| 2071 |
return None, [], 0, "❌ No session/conversation found", "", ""
|
|
@@ -2075,9 +2157,13 @@ To revert, use "Reset to Default" button.
|
|
| 2075 |
from src.core.conversation_verification import ConversationVerificationManager
|
| 2076 |
manager = ConversationVerificationManager()
|
| 2077 |
vs = manager.create_verification_session(session.app_instance.conversation_logger, "Medical Professional")
|
| 2078 |
-
|
| 2079 |
-
|
| 2080 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2081 |
|
| 2082 |
records_as_dicts = [
|
| 2083 |
{
|
|
@@ -2099,7 +2185,7 @@ To revert, use "Reset to Default" button.
|
|
| 2099 |
for r in vs.verification_records
|
| 2100 |
]
|
| 2101 |
html, pos, stats = _render_conv_exchange(records_as_dicts, 0)
|
| 2102 |
-
return
|
| 2103 |
|
| 2104 |
def _mark_conv_correct(records: list, idx: int):
|
| 2105 |
if not records:
|
|
@@ -2119,6 +2205,29 @@ To revert, use "Reset to Default" button.
|
|
| 2119 |
html, pos, stats = _render_conv_exchange(records, idx)
|
| 2120 |
return records, idx, "❌ Marked incorrect", html, pos, stats
|
| 2121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2122 |
def _nav_conv(records: list, idx: int, delta: int):
|
| 2123 |
if not records:
|
| 2124 |
return idx, "", "", ""
|
|
@@ -2133,11 +2242,17 @@ To revert, use "Reset to Default" button.
|
|
| 2133 |
)
|
| 2134 |
|
| 2135 |
conv_verify_download_btn.click(
|
| 2136 |
-
|
| 2137 |
-
inputs=[conv_verify_state],
|
| 2138 |
outputs=[conv_verify_download_btn]
|
| 2139 |
)
|
| 2140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2141 |
conv_correct_btn.click(
|
| 2142 |
_mark_conv_correct,
|
| 2143 |
inputs=[conv_verify_records, conv_verify_index],
|
|
@@ -2145,9 +2260,32 @@ To revert, use "Reset to Default" button.
|
|
| 2145 |
)
|
| 2146 |
|
| 2147 |
conv_incorrect_btn.click(
|
| 2148 |
-
|
| 2149 |
inputs=[conv_verify_records, conv_verify_index],
|
| 2150 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2151 |
)
|
| 2152 |
|
| 2153 |
conv_prev_btn.click(
|
|
|
|
| 348 |
|
| 349 |
with gr.Row():
|
| 350 |
generate_conv_verification_btn = gr.Button("🛠 Generate from current chat", variant="primary")
|
| 351 |
+
conv_verify_download_btn = gr.DownloadButton("⬇️ Download reviewed JSON", variant="secondary")
|
| 352 |
+
conv_verify_download_csv_btn = gr.DownloadButton("📄 Download CSV", variant="secondary")
|
| 353 |
|
| 354 |
conv_verify_status = gr.Markdown(value="", visible=True)
|
| 355 |
conv_verify_exchange = gr.HTML(value="", label="Current Exchange")
|
|
|
|
| 360 |
conv_prev_btn = gr.Button("⬅️ Previous")
|
| 361 |
conv_next_btn = gr.Button("Next ➡️")
|
| 362 |
|
| 363 |
+
# Shown only when marking Incorrect
|
| 364 |
+
with gr.Row(visible=False) as conv_incorrect_comment_row:
|
| 365 |
+
conv_incorrect_comment = gr.Textbox(
|
| 366 |
+
label="Comment (why incorrect / what to fix)",
|
| 367 |
+
placeholder="Add a short note for this exchange...",
|
| 368 |
+
lines=3,
|
| 369 |
+
scale=4,
|
| 370 |
+
)
|
| 371 |
+
conv_save_comment_btn = gr.Button("💾 Save comment", variant="secondary", scale=1)
|
| 372 |
+
|
| 373 |
with gr.Row():
|
| 374 |
with gr.Column(scale=1):
|
| 375 |
conv_position = gr.Markdown(value="")
|
|
|
|
| 2077 |
stats = f"<div><strong>Reviewed:</strong> {checked}/{len(records)}</div>"
|
| 2078 |
return html, pos, stats
|
| 2079 |
|
| 2080 |
+
def _export_conv_records_to_json(meta: dict, records: list):
|
| 2081 |
+
"""Write reviewed conversation verification results to a JSON file and return its path."""
|
| 2082 |
+
import json
|
| 2083 |
+
import os
|
| 2084 |
+
from datetime import datetime
|
| 2085 |
+
|
| 2086 |
+
export_dir = os.path.join(os.getcwd(), "verification_sessions")
|
| 2087 |
+
os.makedirs(export_dir, exist_ok=True)
|
| 2088 |
+
|
| 2089 |
+
session_id = (meta or {}).get("session_id") or "conversation_verification"
|
| 2090 |
+
export_filename = f"conversation_verification_reviewed_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{session_id}.json"
|
| 2091 |
+
export_path = os.path.join(export_dir, export_filename)
|
| 2092 |
+
|
| 2093 |
+
payload = {
|
| 2094 |
+
**(meta or {}),
|
| 2095 |
+
"verification_records": records or [],
|
| 2096 |
+
}
|
| 2097 |
+
|
| 2098 |
+
with open(export_path, "w", encoding="utf-8") as f:
|
| 2099 |
+
json.dump(payload, f, ensure_ascii=False, indent=2, default=str)
|
| 2100 |
+
return export_path
|
| 2101 |
+
|
| 2102 |
+
def _export_conv_records_to_csv(meta: dict, records: list):
|
| 2103 |
+
"""Write reviewed conversation verification results to a CSV file and return its path."""
|
| 2104 |
+
import csv
|
| 2105 |
+
import os
|
| 2106 |
+
from datetime import datetime
|
| 2107 |
+
|
| 2108 |
+
export_dir = os.path.join(os.getcwd(), "verification_exports")
|
| 2109 |
+
os.makedirs(export_dir, exist_ok=True)
|
| 2110 |
+
|
| 2111 |
+
session_id = (meta or {}).get("session_id") or "conversation_verification"
|
| 2112 |
+
export_filename = f"conversation_verification_reviewed_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{session_id}.csv"
|
| 2113 |
+
export_path = os.path.join(export_dir, export_filename)
|
| 2114 |
+
|
| 2115 |
+
fieldnames = [
|
| 2116 |
+
"session_id",
|
| 2117 |
+
"patient_name",
|
| 2118 |
+
"verifier_name",
|
| 2119 |
+
"start_time",
|
| 2120 |
+
"exchange_number",
|
| 2121 |
+
"exchange_id",
|
| 2122 |
+
"original_classification",
|
| 2123 |
+
"original_confidence",
|
| 2124 |
+
"is_correct",
|
| 2125 |
+
"verifier_notes",
|
| 2126 |
+
"user_message",
|
| 2127 |
+
"assistant_response",
|
| 2128 |
+
]
|
| 2129 |
+
|
| 2130 |
+
with open(export_path, "w", encoding="utf-8", newline="") as f:
|
| 2131 |
+
w = csv.DictWriter(f, fieldnames=fieldnames)
|
| 2132 |
+
w.writeheader()
|
| 2133 |
+
for r in records or []:
|
| 2134 |
+
row = {
|
| 2135 |
+
"session_id": (meta or {}).get("session_id"),
|
| 2136 |
+
"patient_name": (meta or {}).get("patient_name"),
|
| 2137 |
+
"verifier_name": (meta or {}).get("verifier_name"),
|
| 2138 |
+
"start_time": (meta or {}).get("start_time"),
|
| 2139 |
+
"exchange_number": r.get("exchange_number"),
|
| 2140 |
+
"exchange_id": r.get("exchange_id") or r.get("record_id"),
|
| 2141 |
+
"original_classification": r.get("original_classification"),
|
| 2142 |
+
"original_confidence": r.get("original_confidence"),
|
| 2143 |
+
"is_correct": r.get("is_correct"),
|
| 2144 |
+
"verifier_notes": r.get("verifier_notes") or "",
|
| 2145 |
+
"user_message": r.get("user_message"),
|
| 2146 |
+
"assistant_response": r.get("assistant_response"),
|
| 2147 |
+
}
|
| 2148 |
+
w.writerow(row)
|
| 2149 |
+
return export_path
|
| 2150 |
+
|
| 2151 |
def _generate_conv_verification(session: SimplifiedSessionData):
|
| 2152 |
if session is None or not hasattr(session.app_instance, "conversation_logger"):
|
| 2153 |
return None, [], 0, "❌ No session/conversation found", "", ""
|
|
|
|
| 2157 |
from src.core.conversation_verification import ConversationVerificationManager
|
| 2158 |
manager = ConversationVerificationManager()
|
| 2159 |
vs = manager.create_verification_session(session.app_instance.conversation_logger, "Medical Professional")
|
| 2160 |
+
|
| 2161 |
+
meta = {
|
| 2162 |
+
"session_id": vs.session_id,
|
| 2163 |
+
"patient_name": vs.patient_name,
|
| 2164 |
+
"verifier_name": vs.verifier_name,
|
| 2165 |
+
"start_time": vs.start_time.isoformat() if hasattr(vs, "start_time") else None,
|
| 2166 |
+
}
|
| 2167 |
|
| 2168 |
records_as_dicts = [
|
| 2169 |
{
|
|
|
|
| 2185 |
for r in vs.verification_records
|
| 2186 |
]
|
| 2187 |
html, pos, stats = _render_conv_exchange(records_as_dicts, 0)
|
| 2188 |
+
return meta, records_as_dicts, 0, f"✅ Generated session `{vs.session_id}`", html, pos, stats
|
| 2189 |
|
| 2190 |
def _mark_conv_correct(records: list, idx: int):
|
| 2191 |
if not records:
|
|
|
|
| 2205 |
html, pos, stats = _render_conv_exchange(records, idx)
|
| 2206 |
return records, idx, "❌ Marked incorrect", html, pos, stats
|
| 2207 |
|
| 2208 |
+
def _show_incorrect_comment_ui(records: list, idx: int):
|
| 2209 |
+
"""Mark incorrect and open the comment row, pre-filling any existing note."""
|
| 2210 |
+
records, idx, status, html, pos, stats = _mark_conv_incorrect(records, idx)
|
| 2211 |
+
note = ""
|
| 2212 |
+
if records and isinstance(records[idx], dict):
|
| 2213 |
+
note = records[idx].get("verifier_notes") or ""
|
| 2214 |
+
return records, idx, status, html, pos, stats, gr.update(visible=True), note
|
| 2215 |
+
|
| 2216 |
+
def _save_incorrect_comment(records: list, idx: int, note: str):
|
| 2217 |
+
if not records:
|
| 2218 |
+
return records, idx, "", "", "", gr.update(visible=False), ""
|
| 2219 |
+
idx = max(0, min(idx, len(records) - 1))
|
| 2220 |
+
if isinstance(records[idx], dict):
|
| 2221 |
+
records[idx]["verifier_notes"] = (note or "").strip()
|
| 2222 |
+
html, pos, stats = _render_conv_exchange(records, idx)
|
| 2223 |
+
return records, idx, "💾 Comment saved", html, pos, stats, gr.update(visible=False)
|
| 2224 |
+
|
| 2225 |
+
def _download_reviewed_json(meta: dict, records: list):
|
| 2226 |
+
return _export_conv_records_to_json(meta, records)
|
| 2227 |
+
|
| 2228 |
+
def _download_reviewed_csv(meta: dict, records: list):
|
| 2229 |
+
return _export_conv_records_to_csv(meta, records)
|
| 2230 |
+
|
| 2231 |
def _nav_conv(records: list, idx: int, delta: int):
|
| 2232 |
if not records:
|
| 2233 |
return idx, "", "", ""
|
|
|
|
| 2242 |
)
|
| 2243 |
|
| 2244 |
conv_verify_download_btn.click(
|
| 2245 |
+
_download_reviewed_json,
|
| 2246 |
+
inputs=[conv_verify_state, conv_verify_records],
|
| 2247 |
outputs=[conv_verify_download_btn]
|
| 2248 |
)
|
| 2249 |
|
| 2250 |
+
conv_verify_download_csv_btn.click(
|
| 2251 |
+
_download_reviewed_csv,
|
| 2252 |
+
inputs=[conv_verify_state, conv_verify_records],
|
| 2253 |
+
outputs=[conv_verify_download_csv_btn]
|
| 2254 |
+
)
|
| 2255 |
+
|
| 2256 |
conv_correct_btn.click(
|
| 2257 |
_mark_conv_correct,
|
| 2258 |
inputs=[conv_verify_records, conv_verify_index],
|
|
|
|
| 2260 |
)
|
| 2261 |
|
| 2262 |
conv_incorrect_btn.click(
|
| 2263 |
+
_show_incorrect_comment_ui,
|
| 2264 |
inputs=[conv_verify_records, conv_verify_index],
|
| 2265 |
+
outputs=[
|
| 2266 |
+
conv_verify_records,
|
| 2267 |
+
conv_verify_index,
|
| 2268 |
+
conv_verify_status,
|
| 2269 |
+
conv_verify_exchange,
|
| 2270 |
+
conv_position,
|
| 2271 |
+
conv_stats,
|
| 2272 |
+
conv_incorrect_comment_row,
|
| 2273 |
+
conv_incorrect_comment,
|
| 2274 |
+
]
|
| 2275 |
+
)
|
| 2276 |
+
|
| 2277 |
+
conv_save_comment_btn.click(
|
| 2278 |
+
_save_incorrect_comment,
|
| 2279 |
+
inputs=[conv_verify_records, conv_verify_index, conv_incorrect_comment],
|
| 2280 |
+
outputs=[
|
| 2281 |
+
conv_verify_records,
|
| 2282 |
+
conv_verify_index,
|
| 2283 |
+
conv_verify_status,
|
| 2284 |
+
conv_verify_exchange,
|
| 2285 |
+
conv_position,
|
| 2286 |
+
conv_stats,
|
| 2287 |
+
conv_incorrect_comment_row,
|
| 2288 |
+
]
|
| 2289 |
)
|
| 2290 |
|
| 2291 |
conv_prev_btn.click(
|
tests/test_conversation_verification_export.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
|
|
| 1 |
import json
|
| 2 |
-
import os
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
|
|
@@ -65,3 +65,65 @@ def test_conversation_verification_export_serializes_without_record_id(tmp_path,
|
|
| 65 |
loaded = json.loads(out.read_text(encoding="utf-8"))
|
| 66 |
assert loaded["verification_records"][0]["exchange_id"] == "sess_1"
|
| 67 |
assert loaded["verification_records"][0]["record_id"] == "sess_1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import csv
|
| 2 |
import json
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
|
| 5 |
|
|
|
|
| 65 |
loaded = json.loads(out.read_text(encoding="utf-8"))
|
| 66 |
assert loaded["verification_records"][0]["exchange_id"] == "sess_1"
|
| 67 |
assert loaded["verification_records"][0]["record_id"] == "sess_1"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_conversation_verification_csv_contains_expected_columns(tmp_path):
|
| 71 |
+
meta = {
|
| 72 |
+
"session_id": "verification_test",
|
| 73 |
+
"patient_name": "Test",
|
| 74 |
+
"verifier_name": "Verifier",
|
| 75 |
+
"start_time": "2025-12-12T00:00:00",
|
| 76 |
+
}
|
| 77 |
+
records = [
|
| 78 |
+
{
|
| 79 |
+
"exchange_id": "sess_1",
|
| 80 |
+
"exchange_number": 1,
|
| 81 |
+
"original_classification": "YELLOW",
|
| 82 |
+
"original_confidence": 0.9,
|
| 83 |
+
"is_correct": False,
|
| 84 |
+
"verifier_notes": "Needs follow-up",
|
| 85 |
+
"user_message": "hi",
|
| 86 |
+
"assistant_response": "hello",
|
| 87 |
+
}
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
out = tmp_path / "export.csv"
|
| 91 |
+
|
| 92 |
+
fieldnames = [
|
| 93 |
+
"session_id",
|
| 94 |
+
"patient_name",
|
| 95 |
+
"verifier_name",
|
| 96 |
+
"start_time",
|
| 97 |
+
"exchange_number",
|
| 98 |
+
"exchange_id",
|
| 99 |
+
"original_classification",
|
| 100 |
+
"original_confidence",
|
| 101 |
+
"is_correct",
|
| 102 |
+
"verifier_notes",
|
| 103 |
+
"user_message",
|
| 104 |
+
"assistant_response",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
with out.open("w", encoding="utf-8", newline="") as f:
|
| 108 |
+
w = csv.DictWriter(f, fieldnames=fieldnames)
|
| 109 |
+
w.writeheader()
|
| 110 |
+
for r in records:
|
| 111 |
+
w.writerow(
|
| 112 |
+
{
|
| 113 |
+
"session_id": meta["session_id"],
|
| 114 |
+
"patient_name": meta["patient_name"],
|
| 115 |
+
"verifier_name": meta["verifier_name"],
|
| 116 |
+
"start_time": meta["start_time"],
|
| 117 |
+
"exchange_number": r.get("exchange_number"),
|
| 118 |
+
"exchange_id": r.get("exchange_id"),
|
| 119 |
+
"original_classification": r.get("original_classification"),
|
| 120 |
+
"original_confidence": r.get("original_confidence"),
|
| 121 |
+
"is_correct": r.get("is_correct"),
|
| 122 |
+
"verifier_notes": r.get("verifier_notes"),
|
| 123 |
+
"user_message": r.get("user_message"),
|
| 124 |
+
"assistant_response": r.get("assistant_response"),
|
| 125 |
+
}
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
rows = list(csv.DictReader(out.open("r", encoding="utf-8")))
|
| 129 |
+
assert rows and rows[0]["verifier_notes"] == "Needs follow-up"
|