Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>SAGE SQL Generator</title> | |
| <style> | |
| :root { | |
| --navy: #12365a; | |
| --action: #2f6bff; | |
| --cyan: #007dba; | |
| --teal: #55c8d1; | |
| --slate: #5e7d9c; | |
| --gold: #f6c744; | |
| --white: #ffffff; | |
| --border: #d7dee8; | |
| --bg: #f5f8fc; | |
| --text: #172334; | |
| --muted: #66778a; | |
| --danger: #b42318; | |
| --success: #087443; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| letter-spacing: 0; | |
| } | |
| header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| height: 64px; | |
| padding: 0 22px; | |
| background: var(--navy); | |
| color: var(--white); | |
| border-bottom: 4px solid var(--gold); | |
| } | |
| h1 { | |
| min-width: 0; | |
| margin: 0; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| font-size: 20px; | |
| font-weight: 720; | |
| } | |
| button { | |
| min-height: 36px; | |
| border: 0; | |
| border-radius: 7px; | |
| padding: 8px 12px; | |
| background: var(--action); | |
| color: var(--white); | |
| cursor: pointer; | |
| font: inherit; | |
| font-size: 13px; | |
| font-weight: 700; | |
| } | |
| button:hover { | |
| filter: brightness(0.96); | |
| } | |
| button:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.55; | |
| } | |
| .secondary { | |
| background: #edf3fa; | |
| color: var(--navy); | |
| border: 1px solid var(--border); | |
| } | |
| .header-actions { | |
| display: flex; | |
| flex-shrink: 0; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .cyan { | |
| background: var(--cyan); | |
| } | |
| .gold { | |
| background: var(--gold); | |
| color: #273444; | |
| } | |
| main { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); | |
| gap: 16px; | |
| height: calc(100vh - 64px); | |
| padding: 16px; | |
| } | |
| .panel { | |
| display: flex; | |
| min-width: 0; | |
| min-height: 0; | |
| flex-direction: column; | |
| background: var(--white); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| box-shadow: 0 10px 24px rgba(18, 54, 90, 0.08); | |
| } | |
| .panel-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| min-height: 52px; | |
| padding: 12px 14px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .panel-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| margin: 0; | |
| color: var(--navy); | |
| font-size: 15px; | |
| font-weight: 740; | |
| } | |
| .accent-dot { | |
| width: 9px; | |
| height: 9px; | |
| border-radius: 50%; | |
| background: var(--teal); | |
| box-shadow: 0 0 0 3px rgba(85, 200, 209, 0.22); | |
| } | |
| .toolbar, | |
| .footer-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 14px; | |
| border-top: 1px solid var(--border); | |
| } | |
| .toolbar { | |
| border-top: 0; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .footer-actions { | |
| justify-content: flex-end; | |
| flex-wrap: wrap; | |
| } | |
| .run-settings { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| align-items: end; | |
| padding: 10px 14px; | |
| border-top: 1px solid var(--border); | |
| background: #f8fbff; | |
| } | |
| .run-field { | |
| display: grid; | |
| gap: 4px; | |
| min-width: min(260px, 100%); | |
| color: var(--navy); | |
| font-size: 11px; | |
| font-weight: 760; | |
| text-transform: uppercase; | |
| } | |
| .run-field.hidden { | |
| display: none; | |
| } | |
| .run-field select, | |
| .run-field input { | |
| min-height: 36px; | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| background: var(--white); | |
| color: var(--text); | |
| padding: 7px 9px; | |
| font: inherit; | |
| font-size: 13px; | |
| font-weight: 600; | |
| text-transform: none; | |
| } | |
| .output-tabs { | |
| display: flex; | |
| gap: 6px; | |
| padding: 10px 14px; | |
| border-bottom: 1px solid var(--border); | |
| background: #f8fbff; | |
| } | |
| .tab-button { | |
| min-height: 32px; | |
| border: 1px solid transparent; | |
| border-radius: 7px; | |
| background: transparent; | |
| color: var(--navy); | |
| padding: 6px 10px; | |
| } | |
| .tab-button.active { | |
| border-color: var(--cyan); | |
| background: #e8f5fb; | |
| color: var(--cyan); | |
| } | |
| textarea { | |
| flex: 1; | |
| min-height: 0; | |
| width: 100%; | |
| resize: none; | |
| border: 0; | |
| outline: none; | |
| padding: 16px; | |
| color: var(--text); | |
| background: var(--white); | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| } | |
| .sql-shell { | |
| flex: 1; | |
| min-height: 0; | |
| background: var(--slate); | |
| } | |
| .sql-shell.idle { | |
| display: grid; | |
| place-items: center; | |
| padding: 28px; | |
| color: var(--white); | |
| text-align: center; | |
| } | |
| .sql-shell.running { | |
| display: grid; | |
| place-items: center; | |
| padding: 28px; | |
| background: linear-gradient(135deg, var(--slate), var(--cyan)); | |
| color: var(--white); | |
| text-align: center; | |
| } | |
| pre { | |
| height: 100%; | |
| margin: 0; | |
| overflow: auto; | |
| padding: 18px; | |
| background: #102c49; | |
| color: #f8fbff; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| white-space: pre; | |
| } | |
| .sql-keyword { | |
| color: #55c8d1; | |
| font-weight: 760; | |
| } | |
| .sql-string { | |
| color: #f6c744; | |
| } | |
| .sql-number { | |
| color: #a7f3d0; | |
| } | |
| .sql-comment { | |
| color: #9fb6cc; | |
| font-style: italic; | |
| } | |
| .sql-function { | |
| color: #93c5fd; | |
| } | |
| .sql-punctuation { | |
| color: #d7dee8; | |
| } | |
| .sql-clause-link { | |
| border-bottom: 1px dotted var(--teal); | |
| color: #bfecff; | |
| cursor: pointer; | |
| font-weight: 760; | |
| } | |
| .sql-clause-link:hover { | |
| color: var(--gold); | |
| } | |
| .progress-card { | |
| display: grid; | |
| gap: 12px; | |
| width: min(680px, 100%); | |
| } | |
| .stage-count { | |
| color: var(--gold); | |
| font-size: 15px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| } | |
| .stage-label { | |
| color: var(--white); | |
| font-size: 22px; | |
| font-weight: 760; | |
| } | |
| .stage-note { | |
| color: #e5f4ff; | |
| font-size: 13px; | |
| } | |
| .progress-segments { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 4px; | |
| overflow: hidden; | |
| border-radius: 7px; | |
| } | |
| .progress-segment { | |
| min-height: 11px; | |
| background: rgba(255, 255, 255, 0.28); | |
| } | |
| .progress-segment.stage-01.done, | |
| .progress-segment.stage-01.running { | |
| background: #55c8d1; | |
| } | |
| .progress-segment.stage-02.done, | |
| .progress-segment.stage-02.running { | |
| background: #f6c744; | |
| } | |
| .progress-segment.stage-03.done, | |
| .progress-segment.stage-03.running { | |
| background: #8bd17c; | |
| } | |
| .progress-segment.running { | |
| box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5); | |
| } | |
| .stage-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 8px; | |
| } | |
| .stage-pill { | |
| border: 1px solid rgba(255, 255, 255, 0.34); | |
| border-radius: 7px; | |
| padding: 8px; | |
| text-align: left; | |
| } | |
| .stage-pill strong { | |
| display: block; | |
| color: var(--white); | |
| font-size: 12px; | |
| } | |
| .stage-pill span { | |
| color: #e5f4ff; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| } | |
| .event-note { | |
| border-left: 3px solid var(--gold); | |
| padding-left: 10px; | |
| color: #f8fbff; | |
| font-size: 13px; | |
| text-align: left; | |
| } | |
| .review-view { | |
| height: 100%; | |
| overflow: auto; | |
| background: var(--white); | |
| color: var(--text); | |
| padding: 14px; | |
| } | |
| .empty-state { | |
| display: grid; | |
| height: 100%; | |
| place-items: center; | |
| padding: 28px; | |
| color: var(--muted); | |
| text-align: center; | |
| } | |
| .review-list { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .review-item { | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--white); | |
| padding: 12px; | |
| } | |
| .review-meta { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin: 8px 0; | |
| } | |
| .chip { | |
| border-radius: 999px; | |
| background: #edf3fa; | |
| color: var(--navy); | |
| padding: 3px 8px; | |
| font-size: 11px; | |
| font-weight: 760; | |
| } | |
| .chip-button { | |
| min-height: 0; | |
| border: 0; | |
| cursor: pointer; | |
| font: inherit; | |
| } | |
| .chip-button:hover { | |
| background: #e3f2fb; | |
| filter: none; | |
| } | |
| .review-text { | |
| color: var(--text); | |
| font-size: 13px; | |
| line-height: 1.45; | |
| } | |
| .review-target { | |
| margin-top: 2px; | |
| color: var(--navy); | |
| font-size: 15px; | |
| font-weight: 780; | |
| line-height: 1.3; | |
| overflow-wrap: anywhere; | |
| } | |
| .review-requirement { | |
| margin-top: 6px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.4; | |
| } | |
| .compact-list { | |
| margin: 8px 0 0; | |
| padding-left: 18px; | |
| color: var(--text); | |
| font-size: 13px; | |
| line-height: 1.45; | |
| } | |
| .candidate-table { | |
| display: grid; | |
| gap: 6px; | |
| margin-top: 10px; | |
| } | |
| .candidate-row { | |
| display: grid; | |
| grid-template-columns: 28px minmax(0, 1fr); | |
| gap: 8px; | |
| align-items: center; | |
| border: 1px solid #e8eef6; | |
| border-radius: 7px; | |
| padding: 8px; | |
| background: #fbfdff; | |
| font-size: 12px; | |
| } | |
| .candidate-row.selected { | |
| border-color: var(--cyan); | |
| box-shadow: 0 0 0 2px rgba(0, 125, 186, 0.15); | |
| background: #f2fbff; | |
| } | |
| .candidate-browser { | |
| display: grid; | |
| gap: 8px; | |
| } | |
| .candidate-browser button { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| min-width: 0; | |
| width: 100%; | |
| min-height: 32px; | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| background: var(--white); | |
| color: var(--text); | |
| padding: 5px 8px; | |
| font-size: 12px; | |
| font-weight: 720; | |
| text-align: left; | |
| cursor: pointer; | |
| } | |
| .candidate-browser button:hover { | |
| border-color: var(--cyan); | |
| } | |
| .candidate-browser-count { | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 700; | |
| white-space: nowrap; | |
| } | |
| .candidate-browser-options { | |
| display: grid; | |
| gap: 6px; | |
| } | |
| .candidate-browser-actions { | |
| display: grid; | |
| gap: 6px; | |
| } | |
| .repair-row { | |
| border-style: dashed; | |
| } | |
| .repair-row.has-text { | |
| border-color: var(--gold); | |
| background: #fffaf0; | |
| } | |
| .candidate-check { | |
| display: grid; | |
| place-items: center; | |
| } | |
| .candidate-check input { | |
| width: 16px; | |
| height: 16px; | |
| margin: 0; | |
| accent-color: var(--cyan); | |
| } | |
| .repair-indicator { | |
| width: 16px; | |
| height: 16px; | |
| border: 1px solid var(--border); | |
| border-radius: 3px; | |
| background: var(--white); | |
| } | |
| .repair-indicator.checked { | |
| border-color: var(--gold); | |
| background: var(--gold); | |
| box-shadow: inset 0 0 0 3px var(--white); | |
| } | |
| .candidate-title { | |
| min-width: 0; | |
| } | |
| .candidate-title strong { | |
| display: block; | |
| overflow-wrap: anywhere; | |
| } | |
| .candidate-title span { | |
| color: var(--muted); | |
| } | |
| .candidate-variants { | |
| display: grid; | |
| gap: 3px; | |
| margin-top: 5px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| line-height: 1.35; | |
| } | |
| .candidate-variant { | |
| display: grid; | |
| grid-template-columns: 18px minmax(0, 1fr); | |
| gap: 5px; | |
| align-items: start; | |
| overflow-wrap: anywhere; | |
| } | |
| .candidate-variant input { | |
| width: 14px; | |
| height: 14px; | |
| margin: 1px 0 0; | |
| accent-color: var(--cyan); | |
| } | |
| .candidate-variant.selected { | |
| color: var(--navy); | |
| } | |
| .candidate-variant-toggle { | |
| width: fit-content; | |
| min-height: 26px; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| background: var(--white); | |
| color: var(--navy); | |
| padding: 4px 7px; | |
| font-size: 11px; | |
| font-weight: 720; | |
| cursor: pointer; | |
| } | |
| .option-target-terms { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| margin-top: 5px; | |
| } | |
| .target-term { | |
| border: 1px solid #cfe0f1; | |
| border-radius: 999px; | |
| background: #f5f9fe; | |
| color: var(--navy); | |
| padding: 2px 7px; | |
| font-size: 11px; | |
| font-weight: 720; | |
| line-height: 1.25; | |
| } | |
| .repair-row textarea { | |
| width: 100%; | |
| height: 34px; | |
| min-height: 34px; | |
| max-height: 220px; | |
| margin-top: 6px; | |
| resize: none; | |
| overflow: hidden; | |
| border: 1px solid var(--border); | |
| border-radius: 7px; | |
| padding: 8px 9px; | |
| font: inherit; | |
| font-size: 12px; | |
| line-height: 1.4; | |
| white-space: pre-wrap; | |
| overflow-wrap: anywhere; | |
| } | |
| .repair-row textarea:focus { | |
| min-height: 62px; | |
| outline: 2px solid rgba(0, 125, 186, 0.24); | |
| outline-offset: 1px; | |
| } | |
| .status { | |
| display: inline-flex; | |
| align-items: center; | |
| min-height: 22px; | |
| border-radius: 999px; | |
| padding: 2px 8px; | |
| background: #edf3f8; | |
| color: var(--slate); | |
| font-size: 11px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| } | |
| .status.succeeded { | |
| background: #e8f6ef; | |
| color: var(--success); | |
| } | |
| .status.failed { | |
| background: #fdecec; | |
| color: var(--danger); | |
| } | |
| .status.running, | |
| .status.queued, | |
| .status.cancel_requested { | |
| background: #fff6d8; | |
| color: #7a5800; | |
| } | |
| .sidebar { | |
| position: fixed; | |
| top: 64px; | |
| bottom: 0; | |
| left: 0; | |
| z-index: 20; | |
| width: 340px; | |
| max-width: 88vw; | |
| transform: translateX(-100%); | |
| transition: transform 160ms ease; | |
| background: var(--white); | |
| border-right: 1px solid var(--border); | |
| box-shadow: 14px 0 30px rgba(18, 54, 90, 0.18); | |
| } | |
| .sidebar.open { | |
| transform: translateX(0); | |
| } | |
| .explanation-sidebar { | |
| position: fixed; | |
| top: 64px; | |
| right: 0; | |
| bottom: 0; | |
| z-index: 22; | |
| display: flex; | |
| width: 430px; | |
| max-width: 92vw; | |
| transform: translateX(100%); | |
| transition: transform 160ms ease; | |
| flex-direction: column; | |
| background: var(--white); | |
| border-left: 1px solid var(--border); | |
| box-shadow: -14px 0 30px rgba(18, 54, 90, 0.16); | |
| } | |
| .explanation-sidebar.open { | |
| transform: translateX(0); | |
| } | |
| .sidebar-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| min-height: 52px; | |
| padding: 12px 14px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .explanation-content { | |
| display: grid; | |
| align-content: start; | |
| gap: 14px; | |
| overflow: auto; | |
| padding: 14px; | |
| } | |
| .explanation-section { | |
| display: grid; | |
| gap: 8px; | |
| } | |
| .explanation-section h3 { | |
| margin: 0; | |
| color: var(--navy); | |
| font-size: 12px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| } | |
| .explanation-sentence { | |
| margin: 0; | |
| color: var(--text); | |
| font-size: 13px; | |
| line-height: 1.45; | |
| } | |
| .retrieved-list { | |
| display: grid; | |
| gap: 7px; | |
| } | |
| .retrieved-item { | |
| border: 1px solid #e8eef6; | |
| border-radius: 7px; | |
| background: #fbfdff; | |
| padding: 8px; | |
| font-size: 12px; | |
| line-height: 1.35; | |
| } | |
| .retrieved-item strong { | |
| display: block; | |
| overflow-wrap: anywhere; | |
| } | |
| .retrieved-item span { | |
| color: var(--muted); | |
| } | |
| .history-list { | |
| display: grid; | |
| gap: 8px; | |
| height: calc(100% - 52px); | |
| overflow: auto; | |
| padding: 12px; | |
| } | |
| .history-item { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 28px; | |
| gap: 8px; | |
| align-items: start; | |
| border: 1px solid var(--border); | |
| border-left: 4px solid var(--slate); | |
| border-radius: 7px; | |
| background: var(--white); | |
| padding: 9px; | |
| } | |
| .history-item.active { | |
| border-color: var(--cyan); | |
| border-left-color: var(--cyan); | |
| box-shadow: 0 0 0 3px rgba(0, 125, 186, 0.12); | |
| } | |
| .history-main { | |
| min-width: 0; | |
| border: 0; | |
| background: transparent; | |
| color: var(--text); | |
| padding: 0; | |
| text-align: left; | |
| font-weight: 650; | |
| } | |
| .history-delete { | |
| min-width: 28px; | |
| min-height: 28px; | |
| border-radius: 50%; | |
| padding: 0; | |
| background: transparent; | |
| color: var(--muted); | |
| font-size: 16px; | |
| line-height: 1; | |
| } | |
| .history-delete:hover { | |
| background: #fdecec; | |
| color: var(--danger); | |
| } | |
| .history-prompt { | |
| display: -webkit-box; | |
| margin-top: 6px; | |
| overflow: hidden; | |
| color: var(--text); | |
| font-size: 12px; | |
| font-weight: 500; | |
| line-height: 1.35; | |
| -webkit-box-orient: vertical; | |
| -webkit-line-clamp: 4; | |
| } | |
| .muted { | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .toast { | |
| position: fixed; | |
| right: 18px; | |
| bottom: 18px; | |
| z-index: 30; | |
| display: none; | |
| max-width: 340px; | |
| border: 1px solid var(--border); | |
| border-left: 5px solid var(--teal); | |
| border-radius: 8px; | |
| background: var(--white); | |
| box-shadow: 0 14px 32px rgba(18, 54, 90, 0.16); | |
| padding: 12px 14px; | |
| color: var(--navy); | |
| font-size: 13px; | |
| font-weight: 700; | |
| } | |
| @media (max-width: 860px) { | |
| body { | |
| overflow: auto; | |
| } | |
| main { | |
| grid-template-columns: 1fr; | |
| height: auto; | |
| min-height: calc(100vh - 64px); | |
| } | |
| .panel { | |
| min-height: 520px; | |
| } | |
| .stage-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| @media (max-width: 520px) { | |
| header { | |
| gap: 10px; | |
| padding: 0 12px; | |
| } | |
| h1 { | |
| font-size: 16px; | |
| } | |
| .header-actions { | |
| gap: 6px; | |
| } | |
| .header-actions button { | |
| min-height: 34px; | |
| padding: 7px 9px; | |
| font-size: 12px; | |
| } | |
| } | |
| /* detailed per-stage sub-steps in the running progress card */ | |
| .stage-steps { display: grid; gap: 11px; margin: 12px auto 4px; text-align: left; max-width: 440px; } | |
| .stage-steps-group { display: grid; gap: 3px; } | |
| .ssg-title { font-weight: 720; font-size: 13px; color: #fff; display: flex; align-items: center; gap: 8px; } | |
| .ssg-state { font-size: 10px; text-transform: uppercase; padding: 1px 7px; border-radius: 999px; background: rgba(255,255,255,0.18); font-weight: 800; } | |
| .ssg-state.done { background: rgba(8,118,67,0.5); } .ssg-state.running { background: rgba(246,199,68,0.42); color: #3a2c00; } | |
| .substep { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #cfe2f2; padding-left: 6px; } | |
| .substep .dot { width: 11px; height: 11px; border-radius: 50%; border: 2px solid #9fc7e8; flex: none; } | |
| .substep.done .dot { background: var(--gold); border-color: var(--gold); } | |
| .substep.done { color: #fff; } | |
| .substep.active { color: #fff; font-weight: 700; } | |
| .substep.active .dot { border-color: #fff; animation: pulse 1.1s ease-in-out infinite; } | |
| @keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.55); } 50% { box-shadow: 0 0 0 5px rgba(255,255,255,0); } } | |
| .run-toggle { flex-direction: row ; align-items: center; gap: 7px; cursor: pointer; } | |
| .run-toggle input { width: 16px; height: 16px; } | |
| /* clause-sidebar audit trace (input -> S1 -> S2 -> S3) + word->source */ | |
| .explanation-section.atrace h3 { display: flex; align-items: center; gap: 8px; } | |
| .atrace-path { display: flex; flex-wrap: wrap; gap: 0; margin: 8px 0 10px; } | |
| .atrace-node { position: relative; border-radius: 7px; padding: 6px 9px; color: #fff; font-size: 11px; margin: 0 20px 8px 0; max-width: 190px; } | |
| .atrace-node:after { content: "→"; position: absolute; right: -16px; top: 50%; transform: translateY(-50%); color: #5e7d9c; font-weight: 800; font-size: 15px; } | |
| .atrace-node:last-child:after { content: ""; } | |
| .atrace-node .atrace-tag { font-size: 9px; font-weight: 800; text-transform: uppercase; opacity: 0.92; } | |
| .atrace-node.n-input { background: #5e7d9c; } .atrace-node.n-s1 { background: #2f6bff; } | |
| .atrace-node.n-s2 { background: #007dba; } .atrace-node.n-s3 { background: #087443; } | |
| .atrace-ws { font-size: 12px; margin: 6px 0; color: #172334; line-height: 1.4; } .atrace-ws b { color: #12365a; } | |
| .atrace-kv { font-size: 12px; margin: 2px 0; color: #44566b; } | |
| .atrace-stages { display: inline-flex; gap: 4px; } | |
| .atrace-stage { font-size: 9px; font-weight: 800; padding: 1px 5px; border-radius: 4px; background: #e3e9f1; color: #9aa7b8; } | |
| .atrace-stage.on.s1 { background: #2f6bff; color: #fff; } .atrace-stage.on.s2 { background: #007dba; color: #fff; } .atrace-stage.on.s3 { background: #087443; color: #fff; } | |
| .atrace-summary { margin: 4px 0 12px; font-size: 14px; line-height: 1.5; color: #12243a; font-weight: 600; } | |
| .atrace-detail { margin: 10px 0; } | |
| .atrace-tag2 { font-size: 9px; font-weight: 800; text-transform: uppercase; color: #8a98a8; margin-bottom: 4px; } | |
| .atrace-detail blockquote { margin: 0; padding: 8px 12px; border-left: 3px solid #2f6bff; background: #f3f6fc; border-radius: 0 6px 6px 0; color: #172334; font-size: 13px; line-height: 1.45; } | |
| .atrace-kv { margin: 3px 0; } | |
| .atrace-kv code { background: #eef2f8; padding: 1px 5px; border-radius: 4px; font-size: 11px; color: #2f4666; } | |
| .atrace-badge { font-size: 9px; font-weight: 800; text-transform: uppercase; background: #087443; color: #fff; padding: 1px 5px; border-radius: 4px; margin-left: 4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>SAGE SQL Generator</h1> | |
| <div class="header-actions"> | |
| <button id="create-new-button" class="secondary" type="button">Create new</button> | |
| <button id="history-toggle" class="secondary" type="button">History</button> | |
| </div> | |
| </header> | |
| <aside id="history-sidebar" class="sidebar"> | |
| <div class="sidebar-header"> | |
| <strong>History</strong> | |
| <button id="history-close" class="secondary" type="button">Close</button> | |
| </div> | |
| <div id="history-list" class="history-list"></div> | |
| </aside> | |
| <aside id="clause-sidebar" class="explanation-sidebar" aria-hidden="true"> | |
| <div class="sidebar-header"> | |
| <strong id="clause-sidebar-title">Details</strong> | |
| <button id="clause-close" class="secondary" type="button">Close</button> | |
| </div> | |
| <div id="clause-sidebar-content" class="explanation-content"> | |
| <div class="empty-state">Click a generated clause name to view details.</div> | |
| </div> | |
| </aside> | |
| <main> | |
| <section class="panel"> | |
| <div class="panel-header"> | |
| <h2 class="panel-title"><span class="accent-dot"></span>Request</h2> | |
| </div> | |
| <div class="toolbar"> | |
| <button class="secondary" type="button" data-insert="## ">Heading</button> | |
| <button class="secondary" type="button" data-insert="- ">Bullet</button> | |
| <button class="secondary" type="button" data-insert="1. ">Numbered</button> | |
| </div> | |
| <textarea id="request-text" spellcheck="true" aria-label="Cohort request"></textarea> | |
| <div class="run-settings"> | |
| <label class="run-field"> | |
| Model | |
| <select id="llm-route-select" aria-label="LLM model"> | |
| <option value="">Select a model</option> | |
| <option value="openrouter-owl-alpha-free">OpenRouter · Owl Alpha free</option> | |
| <option value="openrouter-nemotron-3-super-free">OpenRouter · Nemotron 3 Super free</option> | |
| <option value="azure-default">Azure OpenAI default</option> | |
| <option value="local-vllm-default">Local vLLM default</option> | |
| <option value="qwen-vllm-default">Qwen vLLM default</option> | |
| <option value="openrouter-custom">OpenRouter custom model</option> | |
| </select> | |
| </label> | |
| <label id="llm-custom-model-field" class="run-field hidden"> | |
| Custom model | |
| <input id="llm-custom-model" type="text" autocomplete="off" placeholder="provider/model:variant" /> | |
| </label> | |
| <label class="run-field run-toggle" title="Pause after Stage 2 to review/adjust the pipeline's pre-selected source candidates before generating SQL."> | |
| <span>Human review (Stage 2)</span> | |
| <input type="checkbox" id="hitl-toggle" checked /> | |
| </label> | |
| </div> | |
| <div class="footer-actions"> | |
| <button id="run-button" type="button">Run</button> | |
| </div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-header"> | |
| <h2 class="panel-title"><span class="accent-dot"></span>Output</h2> | |
| <span id="selected-status" class="status">Idle</span> | |
| </div> | |
| <div class="output-tabs" role="tablist" aria-label="Output tabs"> | |
| <button class="tab-button active" type="button" data-output-tab="sql">SQL</button> | |
| <button class="tab-button" type="button" data-output-tab="review">Candidate review</button> | |
| </div> | |
| <div id="sql-container" class="sql-shell idle"> | |
| Generated SQL will appear here. | |
| </div> | |
| <div class="footer-actions"> | |
| <button id="copy-button" class="cyan" type="button" disabled>Copy</button> | |
| <button id="save-review-button" class="cyan" type="button" disabled>Save checklist</button> | |
| <button id="generate-sql-button" type="button" disabled>Generate SQL</button> | |
| <button id="retry-button" class="gold" type="button" disabled>Retry</button> | |
| </div> | |
| </section> | |
| </main> | |
| <div id="toast" class="toast"></div> | |
| <script> | |
| let selectedRunId = null; | |
| let selectedRun = null; | |
| let currentSql = ""; | |
| let pollTimer = null; | |
| let activeOutputTab = "sql"; | |
| let confirmationState = null; | |
| let reviewDirty = false; | |
| let reviewExpandedState = {}; | |
| let reviewVariantExpandedState = {}; | |
| let clauseExplanationState = null; | |
| const terminalStates = new Set(["succeeded", "failed", "cancelled"]); | |
| const defaultVisibleCandidateCount = 15; | |
| const additionalCandidatePageSize = 15; | |
| const $ = (id) => document.getElementById(id); | |
| $("create-new-button").addEventListener("click", createNewRequest); | |
| $("history-toggle").addEventListener("click", () => $("history-sidebar").classList.add("open")); | |
| $("history-close").addEventListener("click", () => $("history-sidebar").classList.remove("open")); | |
| $("clause-close").addEventListener("click", closeClauseSidebar); | |
| $("llm-route-select").addEventListener("change", updateLlmRouteControls); | |
| $("run-button").addEventListener("click", createRun); | |
| $("copy-button").addEventListener("click", copySql); | |
| $("retry-button").addEventListener("click", retryRun); | |
| $("save-review-button").addEventListener("click", saveHumanConfirmation); | |
| $("generate-sql-button").addEventListener("click", generateSql); | |
| $("sql-container").addEventListener("click", handleOutputClick); | |
| $("sql-container").addEventListener("keydown", handleOutputKeyDown); | |
| $("sql-container").addEventListener("change", handleOutputChange); | |
| $("sql-container").addEventListener("focusin", handleOutputFocusIn); | |
| $("sql-container").addEventListener("focusout", handleOutputFocusOut); | |
| $("sql-container").addEventListener("input", handleOutputInput); | |
| for (const button of document.querySelectorAll("[data-output-tab]")) { | |
| button.addEventListener("click", () => setActiveOutputTab(button.dataset.outputTab)); | |
| } | |
| for (const button of document.querySelectorAll("[data-insert]")) { | |
| button.addEventListener("click", () => insertAtCursor($("request-text"), button.dataset.insert)); | |
| } | |
| async function createRun() { | |
| const requestText = $("request-text").value.trim(); | |
| if (!requestText) { | |
| showToast("Enter a request first."); | |
| return; | |
| } | |
| const llmSettings = selectedLlmSettings(); | |
| if (!llmSettings) return; | |
| $("run-button").disabled = true; | |
| try { | |
| const response = await fetch("/api/runs", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| request_text: requestText, | |
| case_id: "webui_request", | |
| require_human_confirmation: $("hitl-toggle") ? $("hitl-toggle").checked : true, | |
| stage2_top_k: 100, | |
| stage2_llm_verify_results: false, | |
| stage2_llm_filter_noise: false, | |
| ...llmSettings, | |
| }), | |
| }); | |
| if (!response.ok) throw new Error(await response.text()); | |
| const run = await response.json(); | |
| selectedRunId = run.run_id; | |
| confirmationState = null; | |
| reviewExpandedState = {}; | |
| reviewVariantExpandedState = {}; | |
| clauseExplanationState = null; | |
| closeClauseSidebar(); | |
| reviewDirty = false; | |
| setActiveOutputTab("sql", false); | |
| renderPipelineState(run); | |
| await refreshHistory(); | |
| startPolling(); | |
| } catch (error) { | |
| showToast(error.message || String(error)); | |
| } finally { | |
| $("run-button").disabled = false; | |
| } | |
| } | |
| function updateLlmRouteControls() { | |
| const custom = $("llm-route-select").value === "openrouter-custom"; | |
| $("llm-custom-model-field").classList.toggle("hidden", !custom); | |
| if (custom) $("llm-custom-model").focus(); | |
| } | |
| function selectedLlmSettings() { | |
| const route = $("llm-route-select").value; | |
| const customModel = $("llm-custom-model").value.trim(); | |
| if (!route) { | |
| showToast("Select a model for this run."); | |
| $("llm-route-select").focus(); | |
| return null; | |
| } | |
| if (route === "openrouter-owl-alpha-free") { | |
| return { | |
| llm_provider: "openrouter", | |
| llm_model: "openrouter/owl-alpha", | |
| llm_response_format: "json_schema", | |
| }; | |
| } | |
| if (route === "openrouter-nemotron-3-super-free") { | |
| return { | |
| llm_provider: "openrouter", | |
| llm_model: "nvidia/nemotron-3-super-120b-a12b:free", | |
| llm_response_format: "json_schema", | |
| }; | |
| } | |
| if (route === "openrouter-custom") { | |
| if (!customModel) { | |
| showToast("Enter an OpenRouter model id."); | |
| $("llm-custom-model").focus(); | |
| return null; | |
| } | |
| return { | |
| llm_provider: "openrouter", | |
| llm_model: customModel, | |
| llm_response_format: "none", | |
| }; | |
| } | |
| if (route === "azure-default") return { llm_provider: "azure" }; | |
| if (route === "local-vllm-default") return { llm_provider: "local-vllm" }; | |
| if (route === "qwen-vllm-default") return { llm_provider: "qwen" }; | |
| return null; | |
| } | |
| async function refreshHistory() { | |
| const response = await fetch("/api/runs"); | |
| const runs = await response.json(); | |
| const list = $("history-list"); | |
| list.innerHTML = ""; | |
| if (!runs.length) { | |
| list.innerHTML = '<div class="muted">No saved runs.</div>'; | |
| return; | |
| } | |
| for (const run of runs) { | |
| const row = document.createElement("div"); | |
| row.className = "history-item" + (run.run_id === selectedRunId ? " active" : ""); | |
| const open = document.createElement("button"); | |
| open.className = "history-main"; | |
| open.type = "button"; | |
| open.innerHTML = ` | |
| <div>${escapeHtml(run.run_id)}</div> | |
| <div class="muted">${escapeHtml(run.state)} · ${formatDate(run.updated_at)}</div> | |
| <div class="history-prompt">${escapeHtml(run.original_prompt_preview || "")}</div> | |
| `; | |
| open.addEventListener("click", () => selectRun(run.run_id)); | |
| const remove = document.createElement("button"); | |
| remove.className = "history-delete"; | |
| remove.type = "button"; | |
| remove.title = "Delete"; | |
| remove.textContent = "x"; | |
| // Always deletable (force-remove): a queued/running/cancel-requested run can | |
| // wedge and would otherwise block the single-worker queue and the UI. | |
| remove.disabled = false; | |
| remove.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| deleteRun(run.run_id); | |
| }); | |
| row.appendChild(open); | |
| row.appendChild(remove); | |
| list.appendChild(row); | |
| } | |
| } | |
| async function selectRun(runId) { | |
| selectedRunId = runId; | |
| confirmationState = null; | |
| reviewVariantExpandedState = {}; | |
| clauseExplanationState = null; | |
| closeClauseSidebar(); | |
| reviewDirty = false; | |
| $("history-sidebar").classList.remove("open"); | |
| await renderSelectedRun(); | |
| await refreshHistory(); | |
| startPolling(); | |
| } | |
| async function createNewRequest() { | |
| resetActiveRun(); | |
| $("request-text").value = ""; | |
| $("history-sidebar").classList.remove("open"); | |
| await refreshHistory(); | |
| $("request-text").focus(); | |
| } | |
| function resetActiveRun() { | |
| if (pollTimer) { | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| } | |
| selectedRunId = null; | |
| selectedRun = null; | |
| confirmationState = null; | |
| reviewVariantExpandedState = {}; | |
| clauseExplanationState = null; | |
| closeClauseSidebar(); | |
| reviewDirty = false; | |
| currentSql = ""; | |
| setActiveOutputTab("sql", false); | |
| setStatus("idle"); | |
| setIdle("Generated SQL will appear here."); | |
| updateFooter(activeOutputTab, null); | |
| } | |
| async function renderSelectedRun() { | |
| if (!selectedRunId) return; | |
| const response = await fetch(`/api/runs/${selectedRunId}`); | |
| if (!response.ok) return; | |
| selectedRun = await response.json(); | |
| if (selectedRun.original_prompt) { | |
| $("request-text").value = selectedRun.original_prompt; | |
| } | |
| if (isAwaitingConfirmation(selectedRun) && activeOutputTab === "sql") { | |
| setActiveOutputTab("review", false); | |
| } | |
| await renderActiveOutput(selectedRun); | |
| } | |
| async function renderActiveOutput(run) { | |
| updateTabButtons(); | |
| if (!terminalStates.has(run.state)) { | |
| renderPipelineState(run); | |
| return; | |
| } | |
| if (activeOutputTab === "review") { | |
| await renderConfirmation(run); | |
| } else { | |
| await renderSql(run); | |
| } | |
| } | |
| async function renderSql(run) { | |
| currentSql = ""; | |
| updateFooter("sql", run); | |
| setStatus(run.state); | |
| const sqlPath = run.artifacts && run.artifacts.stage3_sql; | |
| if (!sqlPath) { | |
| setIdle(isAwaitingConfirmation(run) ? "Review and save candidate checklist items, then generate SQL." : (run.error || "No SQL output is available for this run.")); | |
| return; | |
| } | |
| const response = await fetch(`/api/runs/${run.run_id}/artifacts/${sqlPath}`); | |
| if (!response.ok) { | |
| setIdle("SQL output is not available."); | |
| return; | |
| } | |
| currentSql = await response.text(); | |
| await ensureClauseExplanationData(run); | |
| await ensureLineage(run); | |
| buildCteClauseMap(run); | |
| updateFooter("sql", run); | |
| $("sql-container").className = "sql-shell"; | |
| $("sql-container").innerHTML = `<pre><code>${highlightSql(currentSql)}</code></pre>`; | |
| } | |
| async function renderConfirmation(run) { | |
| currentSql = ""; | |
| updateFooter("review", run); | |
| setStatus(run.state); | |
| if (!confirmationState || confirmationState._run_id !== run.run_id) { | |
| const response = await fetch(`/api/runs/${run.run_id}/human-confirmation`); | |
| if (!response.ok) { | |
| setViewHtml("sql-shell review-view", '<div class="empty-state">Candidate review is available after Stage 2 completes.</div>'); | |
| return; | |
| } | |
| confirmationState = await response.json(); | |
| confirmationState._run_id = run.run_id; | |
| reviewExpandedState = {}; | |
| reviewVariantExpandedState = {}; | |
| prepareConfirmationState(); | |
| reviewDirty = false; | |
| } | |
| renderConfirmationState(); | |
| } | |
| function renderConfirmationState() { | |
| const items = (confirmationState && confirmationState.review_items) || []; | |
| updateFooter("review", selectedRun); | |
| if (!items.length) { | |
| setViewHtml("sql-shell review-view", '<div class="empty-state">No non-metadata concept candidates need confirmation for this run.</div>'); | |
| return; | |
| } | |
| setViewHtml("sql-shell review-view", ` | |
| <div class="review-list"> | |
| ${items.map((item, itemIndex) => renderReviewItem(item, itemIndex)).join("")} | |
| </div> | |
| `); | |
| } | |
| function renderReviewItem(item, itemIndex) { | |
| const review = item.human_review || {}; | |
| const repairText = repairInstructions(review); | |
| const target = item.target || {}; | |
| const targetTerms = targetTermsForItem(item); | |
| const targetText = targetTerms.length ? targetTerms.join(" / ") : item.target_text || target.text || ""; | |
| const requirementText = item.normalized_sentence || item.semantic_summary || ""; | |
| const targetKind = item.target_kind || target.kind || ""; | |
| const appliesToCount = new Set(item.applies_to_clause_ids || []).size; | |
| const visibleIndexes = visibleCandidateIndexes(item, itemIndex); | |
| return ` | |
| <section class="review-item"> | |
| <div class="review-meta"> | |
| ${item.clause_id ? `<button class="chip chip-button" type="button" data-explain-clause="${escapeHtml(item.clause_id)}">${escapeHtml(item.clause_id)}</button>` : ""} | |
| <span class="chip">${escapeHtml(item.domain || "")}</span> | |
| <span class="chip">${escapeHtml(item.source_id || "")}</span> | |
| ${targetKind ? `<span class="chip">${escapeHtml(targetKind)}</span>` : ""} | |
| ${appliesToCount > 1 ? `<span class="chip">${appliesToCount} criteria</span>` : ""} | |
| </div> | |
| ${targetText ? `<div class="review-target">${escapeHtml(targetText)}</div>` : ""} | |
| <div class="review-requirement">${escapeHtml(requirementText)}</div> | |
| <div class="candidate-table"> | |
| ${visibleIndexes.map((candidateIndex) => renderCandidate(item, item.candidates[candidateIndex], itemIndex, candidateIndex)).join("")} | |
| ${renderCandidateBrowser(item, itemIndex, visibleIndexes)} | |
| <div class="candidate-row repair-row ${repairText ? "has-text" : ""}"> | |
| <span class="candidate-check repair-indicator ${repairText ? "checked" : ""}" aria-hidden="true"></span> | |
| <div class="candidate-title"> | |
| <strong>Retrieval repair request</strong> | |
| <textarea data-review-repair="${itemIndex}" rows="1" wrap="soft" placeholder="Type repair request">${escapeHtml(repairText)}</textarea> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| `; | |
| } | |
| function visibleCandidateIndexes(item, itemIndex) { | |
| const candidates = item.candidates || []; | |
| const visible = new Set(); | |
| for (let index = 0; index < Math.min(defaultVisibleCandidateCount, candidates.length); index += 1) { | |
| visible.add(index); | |
| } | |
| const selected = new Set((item.human_review && item.human_review.selected_row_uids) || []); | |
| candidates.forEach((candidate, index) => { | |
| if (selected.has(candidate.row_uid)) visible.add(index); | |
| if ((candidate.child_candidates || []).some((child) => selected.has(child.row_uid))) visible.add(index); | |
| }); | |
| return [...visible].sort((left, right) => left - right); | |
| } | |
| function renderCandidateBrowser(item, itemIndex, visibleIndexes) { | |
| const candidates = item.candidates || []; | |
| const visible = new Set(visibleIndexes); | |
| const hiddenIndexes = candidates.map((_, index) => index).filter((index) => !visible.has(index)); | |
| if (!hiddenIndexes.length) return ""; | |
| const additionalCount = reviewAdditionalCountForItem(item, itemIndex); | |
| const revealedIndexes = hiddenIndexes.slice(0, additionalCount); | |
| const remainingCount = hiddenIndexes.length - revealedIndexes.length; | |
| const shownCount = visibleIndexes.length + revealedIndexes.length; | |
| const showMoreButton = remainingCount > 0 ? ` | |
| <button type="button" data-review-more="${itemIndex}" aria-expanded="${additionalCount > 0 ? "true" : "false"}"> | |
| <span>Show ${Math.min(additionalCandidatePageSize, remainingCount)} more options</span> | |
| <span class="candidate-browser-count">${shownCount}/${candidates.length}</span> | |
| </button> | |
| ` : ""; | |
| const hideButton = additionalCount > 0 ? ` | |
| <button type="button" data-review-collapse="${itemIndex}" aria-expanded="true"> | |
| <span>Hide additional retrieved options</span> | |
| <span class="candidate-browser-count">${visibleIndexes.length}/${candidates.length}</span> | |
| </button> | |
| ` : ""; | |
| return ` | |
| <div class="candidate-browser"> | |
| ${revealedIndexes.length ? ` | |
| <div class="candidate-browser-options"> | |
| ${revealedIndexes.map((candidateIndex) => renderCandidate(item, candidates[candidateIndex], itemIndex, candidateIndex)).join("")} | |
| </div> | |
| ` : ""} | |
| <div class="candidate-browser-actions"> | |
| ${showMoreButton} | |
| ${hideButton} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function reviewItemKey(item, itemIndex) { | |
| return item.review_item_id || `${item.clause_id || "item"}:${item.task_id || itemIndex}`; | |
| } | |
| function reviewAdditionalCountForItem(item, itemIndex) { | |
| return Number(reviewExpandedState[reviewItemKey(item, itemIndex)] || 0); | |
| } | |
| function renderCandidate(item, candidate, itemIndex, candidateIndex) { | |
| const review = item.human_review || {}; | |
| const selected = new Set(review.selected_row_uids || []); | |
| const rowUid = candidate.row_uid || ""; | |
| const isSelected = selected.has(rowUid); | |
| const detailLine = candidateDetailLine(candidate); | |
| return ` | |
| <div class="candidate-row ${isSelected ? "selected" : ""}"> | |
| <label class="candidate-check" title="Select for Stage 3"> | |
| <input type="checkbox" aria-label="Select ${escapeHtml(candidate.label || candidate.code || rowUid)}" data-review-selected="${itemIndex}:${candidateIndex}" ${isSelected ? "checked" : ""} /> | |
| </label> | |
| <div class="candidate-title"> | |
| <strong>${escapeHtml(candidate.label || candidate.code || rowUid)}</strong> | |
| <span>${escapeHtml([candidate.code, candidate.vocabulary].filter(Boolean).join(" · "))}</span> | |
| ${detailLine ? `<span>${escapeHtml(detailLine)}</span>` : ""} | |
| ${renderCandidateVariants(item, candidate, itemIndex, candidateIndex)} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function candidateDetailLine(candidate) { | |
| if (candidate && candidate.group_size > 1) { | |
| const included = Number(candidate.included_variant_count || 0); | |
| const total = Number(candidate.group_size || 0); | |
| return included && included !== total ? `${total} variants · ${included} included` : `${total} variants`; | |
| } | |
| const details = (candidate && candidate.lab_details) || {}; | |
| return labDetailLine(details); | |
| } | |
| function renderCandidateVariants(item, candidate, itemIndex, candidateIndex) { | |
| const children = (candidate && candidate.child_candidates) || []; | |
| if (!children.length) return ""; | |
| const expanded = reviewVariantsExpandedForCandidate(item, candidate, itemIndex); | |
| const visible = expanded ? children : children.slice(0, 5); | |
| const remaining = children.length - visible.length; | |
| return ` | |
| <div class="candidate-variants"> | |
| ${visible.map((child, childIndex) => renderCandidateVariant(item, candidate, child, itemIndex, candidateIndex, childIndex)).join("")} | |
| ${children.length > 5 ? ` | |
| <button class="candidate-variant-toggle" type="button" data-review-variants="${itemIndex}:${candidateIndex}"> | |
| ${expanded ? "Hide specific options" : `Show ${remaining} more specific options`} | |
| </button> | |
| ` : ""} | |
| </div> | |
| `; | |
| } | |
| function renderCandidateVariant(item, candidate, child, itemIndex, candidateIndex, childIndex) { | |
| const review = item.human_review || {}; | |
| const selected = new Set(review.selected_row_uids || []); | |
| const parentSelected = selected.has(candidate.row_uid || ""); | |
| const included = new Set(candidate.included_source_row_uids || candidate.source_row_uids || []); | |
| const childUid = child.row_uid || ""; | |
| const checked = selected.has(childUid) || (parentSelected && included.has(childUid)); | |
| return ` | |
| <label class="candidate-variant ${checked ? "selected" : ""}"> | |
| <input type="checkbox" data-review-child-selected="${itemIndex}:${candidateIndex}:${childIndex}" ${checked ? "checked" : ""} /> | |
| <span>${escapeHtml(candidateVariantLine(child))}</span> | |
| </label> | |
| `; | |
| } | |
| function candidateVariantLine(child) { | |
| const details = child.lab_details || {}; | |
| const parts = [child.label, [child.code, child.vocabulary].filter(Boolean).join(" · "), labDetailLine(details)].filter(Boolean); | |
| return parts.join(" · "); | |
| } | |
| function reviewVariantsExpandedForCandidate(item, candidate, itemIndex) { | |
| return Boolean(reviewVariantExpandedState[reviewVariantKey(item, candidate, itemIndex)]); | |
| } | |
| function reviewVariantKey(item, candidate, itemIndex) { | |
| return `${reviewItemKey(item, itemIndex)}:${candidate.row_uid || ""}`; | |
| } | |
| function labDetailLine(details) { | |
| const parts = []; | |
| if (details.specimen) parts.push(`specimen ${details.specimen}`); | |
| if (details.topography) parts.push(`topography ${details.topography}`); | |
| if (details.consensus) parts.push(`consensus ${details.consensus}`); | |
| if (details.sta3n) parts.push(`sta3n ${details.sta3n}`); | |
| return parts.join(" · "); | |
| } | |
| function targetTermsForItem(item) { | |
| const target = item.target || {}; | |
| const terms = (item.target_terms && item.target_terms.length) | |
| ? item.target_terms | |
| : target.terms && target.terms.length | |
| ? target.terms | |
| : []; | |
| return terms.map((term) => String(term || "").trim()).filter(Boolean).slice(0, 2); | |
| } | |
| async function ensureClauseExplanationData(run) { | |
| if (!run) return null; | |
| if (clauseExplanationState && clauseExplanationState.run_id === run.run_id) return clauseExplanationState; | |
| const state = { run_id: run.run_id, entriesByKey: {} }; | |
| const blockPath = run.artifacts && run.artifacts.stage3_block_explanations; | |
| if (blockPath) { | |
| try { | |
| const response = await fetch(`/api/runs/${run.run_id}/artifacts/${blockPath}`); | |
| if (response.ok) { | |
| const payload = await response.json(); | |
| for (const block of payload.blocks || []) { | |
| const retrieval = block.retrieval_summary || {}; | |
| addClauseExplanationEntry(state, [block.clause_id, block.block_label], { | |
| normalizedSentence: block.normalized_sentence || block.semantic_summary || "", | |
| retrievedRows: rowsFromConcepts(retrieval.top_concepts || []), | |
| }); | |
| } | |
| } | |
| } catch (_) { | |
| // Details remain optional; the main SQL view should still render. | |
| } | |
| } | |
| try { | |
| const response = await fetch(`/api/runs/${run.run_id}/human-confirmation`); | |
| if (response.ok) { | |
| const payload = await response.json(); | |
| for (const item of payload.review_items || []) { | |
| addClauseExplanationEntry(state, [item.clause_id, ...(item.applies_to_clause_ids || [])], { | |
| normalizedSentence: item.normalized_sentence || item.semantic_summary || "", | |
| retrievedRows: rowsFromReviewItem(item), | |
| }); | |
| } | |
| } | |
| } catch (_) { | |
| // Runs without review candidates can still expose Stage 3 details. | |
| } | |
| clauseExplanationState = state; | |
| return state; | |
| } | |
| function addClauseExplanationEntry(state, keys, entry) { | |
| const cleanKeys = keys.map((key) => String(key || "").trim()).filter(Boolean); | |
| if (!cleanKeys.length) return; | |
| const existing = cleanKeys | |
| .map((key) => state.entriesByKey[normalizeClauseLookupKey(key)]) | |
| .find(Boolean); | |
| const target = existing || { normalizedSentence: "", retrievedRows: [] }; | |
| if (!target.normalizedSentence && entry.normalizedSentence) { | |
| target.normalizedSentence = entry.normalizedSentence; | |
| } | |
| target.retrievedRows = normalizeRetrievedRows([...(target.retrievedRows || []), ...(entry.retrievedRows || [])]); | |
| for (const key of cleanKeys) { | |
| state.entriesByKey[normalizeClauseLookupKey(key)] = target; | |
| } | |
| } | |
| function normalizeClauseLookupKey(value) { | |
| return String(value || "").trim().toLowerCase(); | |
| } | |
| function clauseExplanationForKey(key) { | |
| if (!clauseExplanationState) return null; | |
| return clauseExplanationState.entriesByKey[normalizeClauseLookupKey(key)] || null; | |
| } | |
| function rowsFromConcepts(concepts) { | |
| return concepts.map((concept) => ({ | |
| label: concept.label || concept.display || "", | |
| code: concept.code || "", | |
| vocabulary: concept.vocabulary || "", | |
| })); | |
| } | |
| function rowsFromReviewItem(item) { | |
| const rows = []; | |
| for (const candidate of item.candidates || []) { | |
| rows.push({ | |
| label: candidate.label || "", | |
| code: candidate.code || "", | |
| vocabulary: candidate.vocabulary || "", | |
| }); | |
| } | |
| return rows; | |
| } | |
| function normalizeRetrievedRows(rows) { | |
| const output = []; | |
| const seen = new Set(); | |
| for (const row of rows || []) { | |
| const label = String(row.label || "").trim(); | |
| const code = String(row.code || "").trim(); | |
| const vocabulary = String(row.vocabulary || "").trim(); | |
| if (!label && !code) continue; | |
| const key = `${label.toLowerCase()}|${code.toLowerCase()}|${vocabulary.toLowerCase()}`; | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| output.push({ label, code, vocabulary }); | |
| } | |
| return output; | |
| } | |
| let lineageCache = {}; | |
| let cteClauseMap = {}; | |
| let currentClauseLineage = null; | |
| async function ensureLineage(run) { | |
| if (lineageCache[run.run_id]) return lineageCache[run.run_id]; | |
| try { | |
| const r = await fetch(`/api/runs/${run.run_id}/lineage`); | |
| if (r.ok) lineageCache[run.run_id] = await r.json(); | |
| } catch (e) {} | |
| return lineageCache[run.run_id] || null; | |
| } | |
| // Map every CTE name in the generated SQL to the clause (source sentence) that | |
| // produced it, so CTE tokens become clickable and resolve to their requirement. | |
| function buildCteClauseMap(run) { | |
| cteClauseMap = {}; | |
| const lin = lineageCache[run.run_id]; | |
| if (!lin || !lin.clauses) return; | |
| for (const c of lin.clauses) { | |
| for (const cte of (c.stage3 || {}).ctes || []) { | |
| cteClauseMap[normalizeClauseLookupKey(cte)] = c.id; | |
| } | |
| } | |
| } | |
| // Fallback explanation entry derived straight from lineage when a clicked token | |
| // (e.g. a CTE name) has no pre-built explanation keyed by clause id. | |
| function entryFromLineage(c) { | |
| const s1 = c.stage1 || {}, s2 = c.stage2 || {}; | |
| const rows = []; | |
| for (const vs of s2.value_sets || []) { | |
| for (const code of vs.sample_codes || []) { | |
| rows.push({ label: vs.concept, code, vocabulary: vs.vocabulary }); | |
| } | |
| } | |
| return { | |
| normalizedSentence: s1.normalized_text || s1.semantic_summary || "", | |
| retrievedRows: rows, | |
| }; | |
| } | |
| function findLineageClause(lin, key) { | |
| if (!lin || !lin.clauses) return null; | |
| return lin.clauses.find((c) => c.id === key) | |
| || lin.clauses.find((c) => ((c.stage3 || {}).ctes || []).includes(key)) | |
| || null; | |
| } | |
| // Collapse near-duplicate value sets: a clean concept ("Type 2 Diabetes") and its | |
| // lexically-expanded twin ("type 2 diabetes mellitus …") share the same source_id | |
| // and leading code, so key on those. Keep the cleanest (shortest) name and the | |
| // broadest code count; drop empty sets (they matched nothing). | |
| function dedupeConcepts(valueSets) { | |
| const byKey = new Map(); | |
| for (const v of valueSets || []) { | |
| if (!String(v.concept || "").trim() || !(v.code_count > 0)) continue; | |
| const key = String(v.source_id || "") + "|" + ((v.sample_codes || [])[0] || ""); | |
| const existing = byKey.get(key); | |
| if (!existing) { | |
| byKey.set(key, { ...v }); | |
| continue; | |
| } | |
| if (String(v.concept).length < String(existing.concept).length) existing.concept = v.concept; | |
| if ((v.code_count || 0) > (existing.code_count || 0)) { | |
| existing.code_count = v.code_count; | |
| existing.sample_codes = v.sample_codes; | |
| } | |
| } | |
| return [...byKey.values()]; | |
| } | |
| // Strip the repeated synonyms a lexically-expanded retrieval query leaves behind | |
| // ("glucose blood glucose serum glucose …" → "glucose blood serum …"). | |
| function cleanConceptName(name) { | |
| const seen = new Set(); | |
| const out = []; | |
| for (const w of String(name || "").trim().split(/\s+/)) { | |
| const k = w.toLowerCase(); | |
| if (k && !seen.has(k)) { seen.add(k); out.push(w); } | |
| } | |
| return out.join(" "); | |
| } | |
| function buildAuditTrace(c) { | |
| if (!c) return ""; | |
| const s1 = c.stage1 || {}, s2 = c.stage2 || {}, s3 = c.stage3 || {}; | |
| const src = (s1.normalized_text || "").trim(); | |
| const summary = (c.label || s1.semantic_summary || "").trim(); | |
| const operation = String(c.operation || "").replace(/_/g, " ").trim(); | |
| const valueSets = dedupeConcepts(s2.value_sets || []); | |
| const totalCodes = valueSets.reduce((n, v) => n + (v.code_count || 0), 0); | |
| const ctes = s3.ctes || []; | |
| const clip = (t, n) => (t.length > n ? t.slice(0, n - 1).trimEnd() + "…" : t); | |
| const node = (cls, tag, body) => `<div class="atrace-node ${cls}"><div class="atrace-tag">${escapeHtml(tag)}</div><div>${escapeHtml(body)}</div></div>`; | |
| // 1) One graph — the request-to-SQL flow at a glance (no relations / SIDs / CTE names). | |
| const matchedLabel = totalCodes ? `${totalCodes} code${totalCodes === 1 ? "" : "s"}` : (s2.present ? "concepts" : "—"); | |
| const sqlLabel = ctes.length ? `${ctes.length} step${ctes.length === 1 ? "" : "s"}` : (s3.produced ? "SQL" : "—"); | |
| const on = [!!src, !!s2.present, !!s3.produced]; | |
| const path = `<div class="atrace-path"> | |
| ${node("n-input", "Request", src ? "sentence" : "request")} | |
| ${node("n-s1", "Intent", operation || "clause")} | |
| ${node("n-s2", "Matched", matchedLabel)} | |
| ${node("n-s3", "SQL", sqlLabel)} | |
| </div>`; | |
| // 2) One summary — the plain-English description of what this clause does. | |
| const summaryBlock = summary ? `<p class="atrace-summary">${escapeHtml(summary)}</p>` : ""; | |
| // 3) Readable details — the source sentence + matched concepts (no internals). | |
| const sourceBlock = src | |
| ? `<div class="atrace-detail"><div class="atrace-tag2">From the request</div><blockquote>“${escapeHtml(src)}”</blockquote></div>` | |
| : ""; | |
| const conceptBlock = valueSets.length | |
| ? `<div class="atrace-detail"><div class="atrace-tag2">Matched concepts</div>${valueSets.map((v) => { | |
| const concept = clip(cleanConceptName(v.concept) || "concept", 46); | |
| const vocab = String(v.vocabulary || "").trim(); | |
| const readable = (v.sample_codes || []).filter((code) => /[A-Za-z.]/.test(String(code))); | |
| const samples = readable.slice(0, 5).join(", "); | |
| const more = readable.length > 5 ? ", …" : ""; | |
| const label = `${v.code_count || 0}${vocab ? " " + escapeHtml(vocab) : ""} code${(v.code_count === 1) ? "" : "s"}`; | |
| return `<div class="atrace-kv"><b>${escapeHtml(concept)}</b> · ${label}${samples ? ` <span class="muted">(${escapeHtml(samples)}${more})</span>` : ""}${v.curated ? ' <span class="atrace-badge">curated</span>' : ""}</div>`; | |
| }).join("")}</div>` | |
| : ""; | |
| const stages = `<span class="atrace-stages">${[1, 2, 3].map((n, i) => `<span class="atrace-stage s${n} ${on[i] ? "on" : ""}">S${n}</span>`).join("")}</span>`; | |
| return `<section class="explanation-section atrace"> | |
| <h3>How this was derived ${stages}</h3> | |
| ${path}${summaryBlock}${sourceBlock}${conceptBlock} | |
| </section>`; | |
| } | |
| async function openClauseExplanation(key) { | |
| if (!selectedRun) return; | |
| await ensureClauseExplanationData(selectedRun); | |
| const lin = await ensureLineage(selectedRun); | |
| currentClauseLineage = findLineageClause(lin, key); | |
| // CTE tokens aren't keyed by their own name; resolve via the owning clause id, | |
| // then fall back to deriving the entry from lineage. | |
| let entry = clauseExplanationForKey(key); | |
| if (!entry && currentClauseLineage) entry = clauseExplanationForKey(currentClauseLineage.id); | |
| if (!entry && currentClauseLineage) entry = entryFromLineage(currentClauseLineage); | |
| if (!entry && !currentClauseLineage) { | |
| showToast("Details are not available for this clause."); | |
| return; | |
| } | |
| renderClauseExplanation(entry || { normalizedSentence: "", retrievedRows: [] }); | |
| $("clause-sidebar").classList.add("open"); | |
| $("clause-sidebar").setAttribute("aria-hidden", "false"); | |
| } | |
| function closeClauseSidebar() { | |
| $("clause-sidebar").classList.remove("open"); | |
| $("clause-sidebar").setAttribute("aria-hidden", "true"); | |
| } | |
| function renderClauseExplanation(entry) { | |
| $("clause-sidebar-title").textContent = "Details"; | |
| // With lineage, the audit trace already tells the whole story (graph + summary + | |
| // readable details), so show just that — no duplicate requirement or code dump. | |
| const trace = buildAuditTrace(currentClauseLineage); | |
| if (trace) { | |
| $("clause-sidebar-content").innerHTML = trace; | |
| return; | |
| } | |
| // Fallback when lineage is unavailable: the requirement and a few readable codes. | |
| const sentence = entry.normalizedSentence || "No details are available for this clause."; | |
| const rows = normalizeRetrievedRows(entry.retrievedRows || []) | |
| .filter((r) => r.label || /[A-Za-z.]/.test(r.code || "")) | |
| .slice(0, 8); | |
| $("clause-sidebar-content").innerHTML = ` | |
| <section class="explanation-section"> | |
| <h3>Requirement</h3> | |
| <p class="explanation-sentence">${escapeHtml(sentence)}</p> | |
| </section> | |
| ${rows.length ? ` | |
| <section class="explanation-section"> | |
| <h3>Matched</h3> | |
| <div class="retrieved-list">${rows.map(renderRetrievedRow).join("")}</div> | |
| </section>` : ""} | |
| `; | |
| } | |
| function renderRetrievedRow(row) { | |
| const codeLine = [row.code, row.vocabulary].filter(Boolean).join(" · "); | |
| return ` | |
| <div class="retrieved-item"> | |
| <strong>${escapeHtml(row.label || row.code)}</strong> | |
| ${codeLine ? `<span>${escapeHtml(codeLine)}</span>` : ""} | |
| </div> | |
| `; | |
| } | |
| async function saveHumanConfirmation(successMessage = "Checklist saved.") { | |
| if (!selectedRun || !confirmationState) return false; | |
| deriveAllReviewStatuses(); | |
| const response = await fetch(`/api/runs/${selectedRun.run_id}/human-confirmation`, { | |
| method: "PUT", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(confirmationState), | |
| }); | |
| if (!response.ok) { | |
| showToast(await response.text()); | |
| return false; | |
| } | |
| confirmationState = await response.json(); | |
| confirmationState._run_id = selectedRun.run_id; | |
| prepareConfirmationState(); | |
| reviewDirty = false; | |
| updateFooter("review", selectedRun); | |
| showToast(successMessage); | |
| await refreshHistory(); | |
| renderConfirmationState(); | |
| return true; | |
| } | |
| async function generateSql() { | |
| if (!selectedRun || !isAwaitingConfirmation(selectedRun)) return; | |
| if (!confirmationReadyForStage3()) { | |
| showToast("Select at least one candidate for every criterion and leave repair text empty, or save to request retrieval repair."); | |
| return; | |
| } | |
| if (reviewDirty) { | |
| const saved = await saveHumanConfirmation(); | |
| if (!saved) return; | |
| } | |
| const response = await fetch(`/api/runs/${selectedRun.run_id}/generate-sql`, { method: "POST" }); | |
| if (!response.ok) { | |
| showToast(await response.text()); | |
| return; | |
| } | |
| const run = await response.json(); | |
| selectedRun = run; | |
| clauseExplanationState = null; | |
| closeClauseSidebar(); | |
| setActiveOutputTab("sql", false); | |
| renderPipelineState(run); | |
| await refreshHistory(); | |
| startPolling(); | |
| } | |
| const STAGE_SUBSTEPS = { | |
| stage_01: [["Extract obligations", "raw_ir"], ["Decompose & check", "stage_01_findings"], ["Finalize IR", "cohortbuild_ir"]], | |
| stage_02: [["Plan retrieval", "retrieval_tasks"], ["Retrieve candidates", "concept_candidates"], ["Clean & verify", "clause_retrieval_context"], ["Bind sources", "binding_candidates"]], | |
| stage_03: [["Compose clauses", "compile_trace"], ["Assemble SQL", "final.sql"]], | |
| }; | |
| const STAGE_STEP_LABELS = { stage_01: "Stage 1 · Understand request", stage_02: "Stage 2 · Bind sources", stage_03: "Stage 3 · Generate SQL" }; | |
| async function renderPipelineState(run) { | |
| selectedRun = run; | |
| currentSql = ""; | |
| updateFooter(activeOutputTab, run); | |
| setStatus(run.state); | |
| $("sql-container").className = "sql-shell running"; | |
| const progress = stageProgress(run); | |
| const segs = stageDetails(run); | |
| // sub-step completion is inferred from which step artifacts already exist | |
| let paths = []; | |
| try { const a = await (await fetch(`/api/runs/${run.run_id}/artifacts`)).json(); paths = (a || []).map((x) => x.path || x.name || ""); } catch (e) {} | |
| if (selectedRun !== run) return; | |
| const hasMarker = (m) => paths.some((p) => p.includes(m)); | |
| const order = ["stage_01", "stage_02", "stage_03"]; | |
| $("sql-container").innerHTML = ` | |
| <div class="progress-card"> | |
| <div class="progress-segments"> | |
| ${segs.map((stage) => `<div class="progress-segment ${stage.name} ${stage.visualState}"></div>`).join("")} | |
| </div> | |
| <div class="stage-count">Stage ${progress.index}/3</div> | |
| <div class="stage-label">${escapeHtml(progress.label)}</div> | |
| <div class="stage-note">${escapeHtml(progress.note)}</div> | |
| <div class="stage-steps"> | |
| ${order.map((name) => renderStageSteps(name, (run.stages || {})[name] || {}, hasMarker)).join("")} | |
| </div> | |
| <div class="event-note">${escapeHtml(currentActivity(run))}</div> | |
| </div> | |
| `; | |
| } | |
| function renderStageSteps(name, stageRec, hasMarker) { | |
| const steps = STAGE_SUBSTEPS[name] || []; | |
| const st = stageRec.state || "pending"; | |
| const done = steps.map(([, marker]) => hasMarker(marker) || st === "succeeded"); | |
| let activeIdx = -1; | |
| if (st === "running") { activeIdx = done.findIndex((d) => !d); if (activeIdx === -1) activeIdx = steps.length - 1; } | |
| const stateCls = st === "succeeded" ? "done" : st; | |
| return `<div class="stage-steps-group"> | |
| <div class="ssg-title">${escapeHtml(STAGE_STEP_LABELS[name] || name)} <span class="ssg-state ${stateCls}">${escapeHtml(st)}</span></div> | |
| ${steps.map(([label], j) => `<div class="substep ${done[j] ? "done" : j === activeIdx ? "active" : ""}"><span class="dot"></span>${escapeHtml(label)}</div>`).join("")} | |
| </div>`; | |
| } | |
| async function retryRun() { | |
| if (!selectedRunId) return; | |
| const response = await fetch(`/api/runs/${selectedRunId}/retry`, { method: "POST" }); | |
| if (!response.ok) { | |
| showToast(await response.text()); | |
| return; | |
| } | |
| const run = await response.json(); | |
| selectedRunId = run.run_id; | |
| confirmationState = null; | |
| reviewVariantExpandedState = {}; | |
| clauseExplanationState = null; | |
| closeClauseSidebar(); | |
| reviewDirty = false; | |
| setActiveOutputTab("sql", false); | |
| renderPipelineState(run); | |
| await refreshHistory(); | |
| startPolling(); | |
| } | |
| async function deleteRun(runId) { | |
| const response = await fetch(`/api/runs/${runId}`, { method: "DELETE" }); | |
| if (!response.ok) { | |
| showToast(await response.text()); | |
| return; | |
| } | |
| if (selectedRunId === runId) { | |
| selectedRunId = null; | |
| selectedRun = null; | |
| confirmationState = null; | |
| reviewVariantExpandedState = {}; | |
| clauseExplanationState = null; | |
| closeClauseSidebar(); | |
| reviewDirty = false; | |
| currentSql = ""; | |
| setStatus("idle"); | |
| setIdle("Generated SQL will appear here."); | |
| updateFooter(activeOutputTab, null); | |
| } | |
| await refreshHistory(); | |
| } | |
| async function copySql() { | |
| if (!currentSql) return; | |
| await navigator.clipboard.writeText(currentSql); | |
| showToast("Copied."); | |
| } | |
| function startPolling() { | |
| if (pollTimer) clearInterval(pollTimer); | |
| pollTimer = setInterval(async () => { | |
| if (!selectedRunId) return; | |
| await renderSelectedRun(); | |
| await refreshHistory(); | |
| if (selectedRun && terminalStates.has(selectedRun.state)) { | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| } | |
| }, 2500); | |
| } | |
| function setActiveOutputTab(tab, shouldRender = true) { | |
| if (tab === "explain") tab = "sql"; | |
| activeOutputTab = tab; | |
| updateTabButtons(); | |
| if (shouldRender && selectedRun) { | |
| renderActiveOutput(selectedRun); | |
| } | |
| } | |
| function updateTabButtons() { | |
| for (const button of document.querySelectorAll("[data-output-tab]")) { | |
| button.classList.toggle("active", button.dataset.outputTab === activeOutputTab); | |
| } | |
| } | |
| function updateFooter(tab, run) { | |
| const awaiting = run && isAwaitingConfirmation(run); | |
| $("copy-button").style.display = tab === "sql" ? "" : "none"; | |
| $("save-review-button").style.display = tab === "review" ? "" : "none"; | |
| $("generate-sql-button").style.display = awaiting ? "" : "none"; | |
| $("copy-button").disabled = tab !== "sql" || !currentSql; | |
| $("save-review-button").disabled = tab !== "review" || !confirmationState || !(confirmationState.review_items || []).length; | |
| $("generate-sql-button").disabled = !awaiting || !confirmationReadyForStage3(); | |
| $("retry-button").disabled = !(run && terminalStates.has(run.state)); | |
| } | |
| function setStatus(state) { | |
| $("selected-status").textContent = state === "idle" ? "Idle" : state; | |
| $("selected-status").className = `status ${state}`; | |
| } | |
| function setIdle(message) { | |
| $("sql-container").className = "sql-shell idle"; | |
| $("sql-container").textContent = message; | |
| } | |
| function setViewHtml(className, html) { | |
| $("sql-container").className = className; | |
| $("sql-container").innerHTML = html; | |
| } | |
| function stageProgress(run) { | |
| const stages = run.stages || {}; | |
| const order = [ | |
| ["stage_01", 1, "Understanding request"], | |
| ["stage_02", 2, "Reviewing source candidates"], | |
| ["stage_03", 3, "Generating SQL"], | |
| ]; | |
| if (isAwaitingConfirmation(run)) { | |
| return { index: 2, label: "Awaiting candidate confirmation", note: "Verify candidate checklist items before SQL generation." }; | |
| } | |
| if (run.state === "queued") { | |
| return { index: 1, label: "Queued", note: "Waiting to start request interpretation." }; | |
| } | |
| for (const [name, index, label] of order) { | |
| if (stages[name] && stages[name].state === "running") { | |
| return { index, label, note: stageNote(name) }; | |
| } | |
| } | |
| for (const [name, index, label] of order) { | |
| if (stages[name] && stages[name].state === "pending") { | |
| return { index, label, note: stageNote(name) }; | |
| } | |
| } | |
| return { index: 3, label: "Finalizing", note: "Preparing output." }; | |
| } | |
| function stageDetails(run) { | |
| const labels = { | |
| stage_01: "Stage 1: intent", | |
| stage_02: "Stage 2: evidence", | |
| stage_03: "Stage 3: SQL", | |
| }; | |
| return ["stage_01", "stage_02", "stage_03"].map((name) => { | |
| const state = ((run.stages || {})[name] || {}).state || "pending"; | |
| return { | |
| name, | |
| label: labels[name], | |
| state, | |
| visualState: state === "succeeded" ? "done" : state === "running" ? "running" : "", | |
| }; | |
| }); | |
| } | |
| function stageNote(stageName) { | |
| if (stageName === "stage_01") return "Converting natural language into structured cohort intent."; | |
| if (stageName === "stage_02") return "Retrieving and binding candidate clinical concepts and source fields."; | |
| return "Assembling SQL and preparing output."; | |
| } | |
| function currentActivity(run) { | |
| const events = run.events || []; | |
| const latest = [...events].reverse().find((event) => event && event.message); | |
| if (latest) return latest.message; | |
| return stageProgress(run).note; | |
| } | |
| function isAwaitingConfirmation(run) { | |
| const stages = run.stages || {}; | |
| return Boolean( | |
| terminalStates.has(run.state) && | |
| stages.stage_02 && | |
| stages.stage_02.state === "succeeded" && | |
| stages.stage_03 && | |
| ["pending", "skipped"].includes(stages.stage_03.state) && | |
| !(run.artifacts && run.artifacts.stage3_sql) | |
| ); | |
| } | |
| function prepareConfirmationState() { | |
| for (const item of confirmationState.review_items || []) { | |
| const legacy = item.human_ranking || {}; | |
| const review = item.human_review || {}; | |
| review.review_status = canonicalReviewStatus(review.review_status || legacy.review_status || "pending"); | |
| review.selected_row_uids = Array.isArray(review.selected_row_uids) | |
| ? review.selected_row_uids | |
| : Array.isArray(legacy.preferred_row_uids) | |
| ? legacy.preferred_row_uids | |
| : []; | |
| review.retrieval_selected_row_uids = Array.isArray(review.retrieval_selected_row_uids) ? review.retrieval_selected_row_uids : []; | |
| // Pre-select the HIGH-CONFIDENCE candidates so the reviewer adjusts sensible | |
| // defaults, not a blank list and not the full noisy pool. Only on the un-reviewed | |
| // base artifact (a saved review is respected). The reranker's keep-decision | |
| // (included_source_row_uids / rerank.included) is computed deterministically even | |
| // in human-review mode, where selected_by_retrieval is empty. Lower-confidence | |
| // near-misses stay in the pool, visible-but-unchecked, for the human to promote. | |
| const fromBaseArtifact = !String(confirmationState._artifact_path || "").includes("review"); | |
| if (fromBaseArtifact && !review.selected_row_uids.length) { | |
| const highConfidence = (item.candidates || []) | |
| .filter((c) => (c.included_source_row_uids && c.included_source_row_uids.length) || (c.rerank && c.rerank.included) || c.selected_by_retrieval) | |
| .map((c) => c.row_uid) | |
| .filter(Boolean); | |
| // Fall back to ALL candidates when nothing is flagged high-confidence: a real | |
| // concept's sibling group (e.g. the ICD-9 set when only ICD-10 hit the | |
| // confidence bar) must not be left blank, or it blocks "continue to Stage 3". | |
| const allUids = (item.candidates || []).map((c) => c.row_uid).filter(Boolean); | |
| const seed = review.retrieval_selected_row_uids.length | |
| ? review.retrieval_selected_row_uids | |
| : (highConfidence.length ? highConfidence : allUids); | |
| if (seed.length) review.selected_row_uids = [...seed]; | |
| } | |
| review.retrieval_repair = review.retrieval_repair || { | |
| requested: review.review_status === "needs_retrieval_repair", | |
| instructions: legacy.notes || review.notes || "", | |
| }; | |
| item.human_review = review; | |
| delete item.human_ranking; | |
| deriveItemReviewStatus(item); | |
| } | |
| } | |
| function handleOutputClick(event) { | |
| const explanationTarget = event.target.closest("[data-explain-clause]"); | |
| if (explanationTarget) { | |
| event.preventDefault(); | |
| openClauseExplanation(explanationTarget.dataset.explainClause); | |
| return; | |
| } | |
| if (!confirmationState) return; | |
| const variantsTarget = event.target.closest("[data-review-variants]"); | |
| if (variantsTarget) { | |
| const [itemIndexText, candidateIndexText] = variantsTarget.dataset.reviewVariants.split(":"); | |
| const item = confirmationState.review_items[Number(itemIndexText)]; | |
| const candidate = item && item.candidates[Number(candidateIndexText)]; | |
| if (!item || !candidate) return; | |
| const key = reviewVariantKey(item, candidate, Number(itemIndexText)); | |
| reviewVariantExpandedState[key] = !reviewVariantExpandedState[key]; | |
| renderConfirmationState(); | |
| return; | |
| } | |
| const moreTarget = event.target.closest("[data-review-more]"); | |
| const collapseTarget = event.target.closest("[data-review-collapse]"); | |
| const target = moreTarget || collapseTarget; | |
| if (!target) return; | |
| const itemIndex = Number(target.dataset.reviewMore ?? target.dataset.reviewCollapse); | |
| const item = confirmationState.review_items[itemIndex]; | |
| if (!item) return; | |
| const key = reviewItemKey(item, itemIndex); | |
| if (moreTarget) { | |
| reviewExpandedState[key] = reviewAdditionalCountForItem(item, itemIndex) + additionalCandidatePageSize; | |
| } else { | |
| reviewExpandedState[key] = 0; | |
| } | |
| renderConfirmationState(); | |
| } | |
| function handleOutputKeyDown(event) { | |
| const explanationTarget = event.target.closest("[data-explain-clause]"); | |
| if (!explanationTarget || !["Enter", " "].includes(event.key)) return; | |
| event.preventDefault(); | |
| openClauseExplanation(explanationTarget.dataset.explainClause); | |
| } | |
| function handleOutputFocusIn(event) { | |
| const target = event.target; | |
| if (target.dataset && target.dataset.reviewRepair !== undefined) { | |
| expandRepairTextarea(target); | |
| } | |
| } | |
| function handleOutputFocusOut(event) { | |
| const target = event.target; | |
| if (target.dataset && target.dataset.reviewRepair !== undefined) { | |
| collapseRepairTextarea(target); | |
| } | |
| } | |
| function handleOutputInput(event) { | |
| if (!confirmationState) return; | |
| const target = event.target; | |
| if (target.dataset.reviewRepair === undefined) return; | |
| updateRepairInstructions(Number(target.dataset.reviewRepair), target.value); | |
| const repairRow = target.closest(".repair-row"); | |
| if (repairRow) { | |
| repairRow.classList.toggle("has-text", Boolean(target.value.trim())); | |
| const indicator = repairRow.querySelector(".repair-indicator"); | |
| if (indicator) indicator.classList.toggle("checked", Boolean(target.value.trim())); | |
| } | |
| expandRepairTextarea(target); | |
| updateFooter("review", selectedRun); | |
| } | |
| function handleOutputChange(event) { | |
| if (!confirmationState) return; | |
| const target = event.target; | |
| if (target.dataset.reviewRepair !== undefined) { | |
| updateRepairInstructions(Number(target.dataset.reviewRepair), target.value); | |
| } | |
| if (target.dataset.reviewSelected !== undefined) { | |
| const [itemIndexText, candidateIndexText] = target.dataset.reviewSelected.split(":"); | |
| const item = confirmationState.review_items[Number(itemIndexText)]; | |
| const candidate = item && item.candidates[Number(candidateIndexText)]; | |
| if (candidate) { | |
| updateCandidateChoice(item, candidate, target.checked); | |
| deriveItemReviewStatus(item); | |
| reviewDirty = true; | |
| renderConfirmationState(); | |
| return; | |
| } | |
| } | |
| if (target.dataset.reviewChildSelected !== undefined) { | |
| const [itemIndexText, candidateIndexText, childIndexText] = target.dataset.reviewChildSelected.split(":"); | |
| const item = confirmationState.review_items[Number(itemIndexText)]; | |
| const candidate = item && item.candidates[Number(candidateIndexText)]; | |
| const child = candidate && (candidate.child_candidates || [])[Number(childIndexText)]; | |
| if (candidate && child) { | |
| updateChildCandidateChoice(item, candidate, child, target.checked); | |
| deriveItemReviewStatus(item); | |
| reviewDirty = true; | |
| renderConfirmationState(); | |
| return; | |
| } | |
| } | |
| updateFooter("review", selectedRun); | |
| } | |
| function updateRepairInstructions(itemIndex, value) { | |
| const item = confirmationState.review_items[itemIndex]; | |
| if (!item) return; | |
| item.human_review.retrieval_repair = { | |
| requested: Boolean(value.trim()), | |
| instructions: value, | |
| }; | |
| deriveItemReviewStatus(item); | |
| reviewDirty = true; | |
| } | |
| function expandRepairTextarea(textarea) { | |
| textarea.style.height = "auto"; | |
| textarea.style.height = `${Math.min(textarea.scrollHeight + 2, 220)}px`; | |
| } | |
| function collapseRepairTextarea(textarea) { | |
| textarea.style.height = ""; | |
| } | |
| function updateCandidateChoice(item, candidate, checked) { | |
| const review = item.human_review; | |
| const selected = new Set(review.selected_row_uids || []); | |
| const rowUid = candidate.row_uid || ""; | |
| for (const child of candidate.child_candidates || []) { | |
| if (child.row_uid) selected.delete(child.row_uid); | |
| } | |
| if (checked) { | |
| selected.add(rowUid); | |
| } else { | |
| selected.delete(rowUid); | |
| } | |
| review.selected_row_uids = orderedSelectableRowUids(item).filter((uid) => selected.has(uid)); | |
| } | |
| function updateChildCandidateChoice(item, candidate, child, checked) { | |
| const review = item.human_review; | |
| const selected = new Set(review.selected_row_uids || []); | |
| const parentUid = candidate.row_uid || ""; | |
| const childUid = child.row_uid || ""; | |
| if (selected.has(parentUid)) { | |
| selected.delete(parentUid); | |
| const included = new Set(candidate.included_source_row_uids || candidate.source_row_uids || []); | |
| for (const sibling of candidate.child_candidates || []) { | |
| if (sibling.row_uid && included.has(sibling.row_uid) && sibling.row_uid !== childUid) { | |
| selected.add(sibling.row_uid); | |
| } | |
| } | |
| } | |
| checked ? selected.add(childUid) : selected.delete(childUid); | |
| review.selected_row_uids = orderedSelectableRowUids(item).filter((uid) => selected.has(uid)); | |
| } | |
| function orderedSelectableRowUids(item) { | |
| const ordered = []; | |
| const seen = new Set(); | |
| for (const candidate of item.candidates || []) { | |
| for (const uid of [candidate.row_uid, ...((candidate.child_candidates || []).map((child) => child.row_uid))]) { | |
| if (uid && !seen.has(uid)) { | |
| seen.add(uid); | |
| ordered.push(uid); | |
| } | |
| } | |
| } | |
| return ordered; | |
| } | |
| function confirmationReadyForStage3() { | |
| if (!confirmationState) return false; | |
| const items = confirmationState.review_items || []; | |
| if (!items.length) return true; | |
| return items.every((item) => { | |
| const review = item.human_review || {}; | |
| return Array.isArray(review.selected_row_uids) && review.selected_row_uids.length > 0 && !repairInstructions(review); | |
| }); | |
| } | |
| function deriveAllReviewStatuses() { | |
| for (const item of confirmationState.review_items || []) { | |
| deriveItemReviewStatus(item); | |
| } | |
| } | |
| function deriveItemReviewStatus(item) { | |
| const review = item.human_review || {}; | |
| const selected = Array.isArray(review.selected_row_uids) ? review.selected_row_uids : []; | |
| const repairText = repairInstructions(review); | |
| const needsRepair = !selected.length || Boolean(repairText); | |
| review.review_status = needsRepair ? "needs_retrieval_repair" : "verified"; | |
| review.retrieval_repair = { | |
| requested: needsRepair, | |
| instructions: repairText, | |
| }; | |
| item.human_review = review; | |
| } | |
| function repairInstructions(review) { | |
| const repair = review && review.retrieval_repair; | |
| return String((repair && repair.instructions) || "").trim(); | |
| } | |
| function canonicalReviewStatus(status) { | |
| if (status === "accepted") return "verified"; | |
| if (status === "rejected" || status === "needs_revision") return "needs_retrieval_repair"; | |
| return status || "pending"; | |
| } | |
| function highlightSql(sql) { | |
| const keywords = new Set([ | |
| "ADD", "ALL", "ALTER", "AND", "AS", "ASC", "BETWEEN", "BY", "CASE", "CAST", "COALESCE", | |
| "COUNT", "CREATE", "CROSS", "DATEADD", "DELETE", "DESC", "DISTINCT", "ELSE", "END", "EXISTS", | |
| "FROM", "FULL", "GROUP", "HAVING", "IN", "INNER", "INSERT", "INTO", "IS", "JOIN", "LEFT", | |
| "LIKE", "MAX", "MIN", "NOT", "NULL", "ON", "OR", "ORDER", "OUTER", "OVER", "PARTITION", | |
| "RIGHT", "ROW_NUMBER", "SELECT", "SET", "SUM", "THEN", "UNION", "UPDATE", "VALUES", "WHEN", | |
| "WHERE", "WITH", "YEAR" | |
| ]); | |
| const tokenPattern = /(--[^\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|\b[A-Za-z_][A-Za-z0-9_]*\b|\b\d+(?:\.\d+)?\b|[(),.;])/g; | |
| let output = ""; | |
| let lastIndex = 0; | |
| for (const match of sql.matchAll(tokenPattern)) { | |
| const token = match[0]; | |
| output += escapeHtml(sql.slice(lastIndex, match.index)); | |
| const linkable = clauseExplanationForKey(token) || cteClauseMap[normalizeClauseLookupKey(token)]; | |
| output += linkable | |
| ? `<span class="sql-clause-link" role="button" tabindex="0" data-explain-clause="${escapeHtml(token)}">${escapeHtml(token)}</span>` | |
| : colorSqlToken(token, keywords); | |
| lastIndex = match.index + token.length; | |
| } | |
| output += escapeHtml(sql.slice(lastIndex)); | |
| return output; | |
| } | |
| function colorSqlToken(token, keywords) { | |
| if (token.startsWith("--") || token.startsWith("/*")) return span("sql-comment", token); | |
| if (token.startsWith("'")) return span("sql-string", token); | |
| if (/^\d/.test(token)) return span("sql-number", token); | |
| if (/^[(),.;]$/.test(token)) return span("sql-punctuation", token); | |
| const upper = token.toUpperCase(); | |
| if (keywords.has(upper)) return span("sql-keyword", token); | |
| return escapeHtml(token); | |
| } | |
| function span(className, text) { | |
| return `<span class="${className}">${escapeHtml(text)}</span>`; | |
| } | |
| function insertAtCursor(textarea, text) { | |
| const start = textarea.selectionStart; | |
| const end = textarea.selectionEnd; | |
| textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end); | |
| textarea.focus(); | |
| textarea.selectionStart = textarea.selectionEnd = start + text.length; | |
| } | |
| function formatDate(value) { | |
| if (!value) return ""; | |
| const date = new Date(value); | |
| return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); | |
| } | |
| function showToast(message) { | |
| const toast = $("toast"); | |
| toast.textContent = message; | |
| toast.style.display = "block"; | |
| setTimeout(() => { | |
| toast.style.display = "none"; | |
| }, 2600); | |
| } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| updateFooter(activeOutputTab, null); | |
| refreshHistory(); | |
| </script> | |
| </body> | |
| </html> | |