Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Report Genius AI β RICS Survey Report Generator</title> | |
| <link rel="icon" href="/favicon.ico" /> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --navy: #1b3a5c; | |
| --navy-lt: #274d7a; | |
| --gold: #c8a020; | |
| --gold-lt: #e8c040; | |
| --purple: #6b3fa0; | |
| --purple-lt:#8a5cc0; | |
| --teal: #0e7c86; | |
| --bg: #f0f3f8; | |
| --card: #ffffff; | |
| --border: #d4dce8; | |
| --text: #1a2332; | |
| --muted: #5a6a7e; | |
| --success: #1a7f4b; | |
| --warn: #c85a00; | |
| --danger: #c0392b; | |
| --radius: 10px; | |
| --shadow: 0 2px 12px rgba(27,58,92,.10); | |
| --transition: .2s ease; | |
| } | |
| /* body baseline β full rule is in the theme block above */ | |
| /* Header */ | |
| header { background: var(--header-bg, var(--navy)); color: #fff; padding: 0 2rem; display: flex; align-items: center; height: 64px; gap: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.25); position: sticky; top: 0; z-index: 100; } | |
| .logo { display: flex; align-items: center; gap: .6rem; font-size: 1.2rem; font-weight: 700; letter-spacing: .03em; } | |
| .logo-icon { width: 36px; height: 36px; background: var(--gold); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; } | |
| .logo span { color: var(--gold); } | |
| header .subtitle { color: rgba(255,255,255,.55); font-size: .82rem; margin-left: .3rem; } | |
| header .spacer { flex: 1; } | |
| .tenant-badge { background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.2); border-radius: 20px; padding: .25rem .8rem; font-size: .78rem; color: rgba(255,255,255,.8); display: none; } | |
| .tenant-badge.visible { display: block; } | |
| /* Steps */ | |
| .steps { background: var(--navy-lt); display: flex; justify-content: center; gap: 0; padding: .7rem 2rem; } | |
| .step-item { display: flex; align-items: center; gap: .5rem; padding: .3rem 1.2rem; color: rgba(255,255,255,.4); font-size: .82rem; font-weight: 500; transition: var(--transition); } | |
| .step-item.active { color: #fff; } | |
| .step-item.done { color: var(--gold); } | |
| .step-num { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.15); color: rgba(255,255,255,.5); display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 700; flex-shrink: 0; transition: var(--transition); } | |
| .step-item.active .step-num { background: var(--gold); color: var(--navy); } | |
| .step-item.done .step-num { background: var(--success); color: #fff; } | |
| .step-sep { color: rgba(255,255,255,.2); font-size: .7rem; align-self: center; } | |
| /* Main layout */ | |
| main { max-width: 900px; margin: 2.5rem auto; padding: 0 1.5rem 4rem; } | |
| /* Cards */ | |
| .card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid var(--border); overflow: hidden; } | |
| .card + .card { margin-top: 1.2rem; } | |
| .card-head { background: linear-gradient(135deg, var(--navy) 0%, var(--navy-lt) 100%); color: #fff; padding: 1.2rem 1.5rem; display: flex; align-items: center; gap: .8rem; } | |
| .card-head h2 { font-size: 1.05rem; font-weight: 600; } | |
| .card-head p { font-size: .82rem; color: rgba(255,255,255,.65); margin-top: .15rem; } | |
| .card-body { padding: 1.5rem; } | |
| /* Buttons */ | |
| .btn { display: inline-flex; align-items: center; gap: .45rem; padding: .6rem 1.4rem; border-radius: 7px; font-size: .9rem; font-weight: 600; cursor: pointer; border: none; transition: all var(--transition); text-decoration: none; } | |
| .btn-primary { background: var(--navy); color: #fff; } | |
| .btn-primary:hover { background: var(--navy-lt); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(27,58,92,.3); } | |
| .btn-gold { background: var(--gold); color: var(--navy); } | |
| .btn-gold:hover { background: var(--gold-lt); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(200,160,32,.35); } | |
| .btn-purple { background: var(--purple); color: #fff; } | |
| .btn-purple:hover { background: var(--purple-lt); transform: translateY(-1px); } | |
| .btn-teal { background: var(--teal); color: #fff; } | |
| .btn-teal:hover { background: #0a6870; transform: translateY(-1px); } | |
| .btn-outline { background: transparent; color: var(--navy); border: 1.5px solid var(--border); } | |
| .btn-outline:hover { border-color: var(--navy); background: var(--bg); } | |
| .btn-sm { padding: .35rem .9rem; font-size: .8rem; } | |
| .btn:disabled { opacity: .45; cursor: not-allowed; transform: none ; } | |
| /* Upload */ | |
| #step-upload { display: block; } | |
| .upload-zone { border: 2.5px dashed var(--border); border-radius: var(--radius); padding: 3rem 2rem; text-align: center; cursor: pointer; transition: all var(--transition); background: var(--bg); } | |
| .upload-zone:hover, .upload-zone.drag-over { border-color: var(--navy); background: #e8eef7; } | |
| .upload-zone.has-file { border-color: var(--success); background: #edf7f2; } | |
| .upload-zone .icon { font-size: 3rem; margin-bottom: .75rem; display: block; } | |
| .upload-zone h3 { font-size: 1.05rem; color: var(--navy); margin-bottom: .4rem; } | |
| .upload-zone p { font-size: .85rem; color: var(--muted); } | |
| .upload-zone .file-name { margin-top: 1rem; background: var(--card); border-radius: 6px; padding: .5rem 1rem; display: inline-flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--success); font-weight: 600; border: 1px solid #b2dfcb; } | |
| #file-input { display: none; } | |
| .form-group { margin-top: 1.2rem; } | |
| .form-group label { display: block; font-size: .85rem; font-weight: 600; color: var(--text); margin-bottom: .35rem; } | |
| .form-group input { width: 100%; padding: .6rem .9rem; border: 1.5px solid var(--border); border-radius: 7px; font-size: .9rem; font-family: inherit; color: var(--text); background: var(--bg); transition: border-color var(--transition); } | |
| .form-group input:focus { outline: none; border-color: var(--navy); background: #fff; box-shadow: 0 0 0 3px rgba(27,58,92,.08); } | |
| .form-hint { font-size: .77rem; color: var(--muted); margin-top: .3rem; } | |
| /* Processing */ | |
| #step-processing { display: none; } | |
| .processing-card { text-align: center; padding: 3rem 2rem; } | |
| .spinner { width: 56px; height: 56px; border-radius: 50%; border: 5px solid var(--border); border-top-color: var(--navy); animation: spin .9s linear infinite; margin: 0 auto 1.5rem; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .processing-card h3 { font-size: 1.1rem; color: var(--navy); margin-bottom: .5rem; } | |
| .processing-card p { color: var(--muted); font-size: .9rem; } | |
| .status-pill { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .9rem; border-radius: 20px; font-size: .8rem; font-weight: 600; margin-top: 1rem; } | |
| .pill-pending { background: #fff3e0; color: #c85a00; } | |
| .pill-processing { background: #e3f0ff; color: var(--navy-lt); } | |
| .pill-complete { background: #e8f5ee; color: var(--success); } | |
| .pill-failed { background: #fdeaea; color: var(--danger); } | |
| /* Style Profile Card */ | |
| .style-profile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: .7rem; margin-bottom: 1rem; } | |
| .style-chip { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: .6rem .9rem; } | |
| .style-chip .sc-label { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: .2rem; } | |
| .style-chip .sc-value { font-size: .88rem; font-weight: 600; color: var(--navy); } | |
| .style-summary-box { background: linear-gradient(135deg, #f0f3f8, #e8eef7); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; font-size: .87rem; color: var(--navy); border-left: 3px solid var(--gold); } | |
| .style-phrases { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; } | |
| .style-phrase { background: #e8f0fb; color: var(--navy-lt); border-radius: 12px; padding: .2rem .7rem; font-size: .78rem; font-style: italic; } | |
| /* Configure */ | |
| #step-configure { display: none; } | |
| .doc-summary { display: flex; align-items: center; gap: 1rem; background: #e8f5ee; border: 1px solid #b2dfcb; border-radius: 8px; padding: .9rem 1.2rem; margin-bottom: 1.5rem; } | |
| .doc-summary .doc-icon { font-size: 1.8rem; } | |
| .doc-summary strong { color: var(--success); display: block; } | |
| .doc-summary span { font-size: .82rem; color: var(--muted); } | |
| .sections-label { font-size: .82rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; margin-bottom: .9rem; } | |
| .section-group-header { display: flex; align-items: center; gap: .7rem; margin: 1.5rem 0 .6rem; } | |
| .section-group-header .grp-badge { background: var(--navy); color: #fff; border-radius: 6px; padding: .2rem .7rem; font-size: .82rem; font-weight: 700; letter-spacing: .04em; } | |
| .section-group-header .grp-label { font-size: .9rem; font-weight: 700; color: var(--navy); } | |
| .section-group-header .grp-line { flex: 1; height: 1px; background: var(--border); } | |
| .section-card { border: 1.5px solid var(--border); border-radius: 9px; margin-bottom: .85rem; overflow: hidden; transition: border-color var(--transition); } | |
| .section-card.active { border-color: var(--navy); } | |
| .section-card.ai-proofread { border-color: var(--gold); } | |
| .section-card.ai-enhance { border-color: var(--purple); } | |
| .section-card.locked { border-color: #ccc; } | |
| .section-head { display: flex; align-items: center; gap: .8rem; padding: .9rem 1.1rem; cursor: pointer; background: var(--card); user-select: none; } | |
| .section-code { background: var(--navy); color: #fff; border-radius: 5px; padding: .15rem .5rem; font-size: .78rem; font-weight: 700; min-width: 34px; text-align: center; } | |
| .section-card.ai-proofread .section-code { background: var(--gold); color: var(--navy); } | |
| .section-card.ai-enhance .section-code { background: var(--purple); color: #fff; } | |
| .section-card.locked .section-code { background: #bbb; color: #fff; } | |
| .section-head-text { flex: 1; } | |
| .section-head-text strong { font-size: .92rem; color: var(--text); } | |
| .section-head-text p { font-size: .78rem; color: var(--muted); margin-top: .05rem; } | |
| .toggle-wrap { display: flex; align-items: center; gap: .5rem; } | |
| .toggle-label { font-size: .75rem; color: var(--muted); } | |
| .toggle { position: relative; width: 40px; height: 22px; -webkit-appearance: none; appearance: none; background: var(--border); border-radius: 11px; cursor: pointer; transition: background var(--transition); flex-shrink: 0; } | |
| .toggle::before { content: ''; position: absolute; width: 16px; height: 16px; background: #fff; border-radius: 50%; top: 3px; left: 3px; transition: left var(--transition); box-shadow: 0 1px 4px rgba(0,0,0,.2); } | |
| .toggle:checked { background: var(--navy); } | |
| .toggle:checked::before { left: 21px; } | |
| .section-body { padding: 0 1.1rem; max-height: 0; overflow: hidden; transition: max-height .3s ease, padding .3s ease; } | |
| .section-body.open { max-height: 600px; padding: 0 1.1rem 1rem; } | |
| /* AI Mode Tabs */ | |
| .ai-mode-panel { margin: .6rem 0 .8rem; } | |
| .ai-mode-label { font-size: .75rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; margin-bottom: .5rem; } | |
| .ai-mode-tabs { display: flex; gap: .4rem; flex-wrap: wrap; } | |
| .ai-mode-tab { display: inline-flex; align-items: center; gap: .35rem; padding: .4rem 1rem; border-radius: 7px; font-size: .82rem; font-weight: 600; cursor: pointer; border: 1.5px solid var(--border); background: var(--bg); color: var(--muted); transition: all var(--transition); } | |
| .ai-mode-tab:hover { border-color: var(--navy); color: var(--navy); background: #edf1f8; } | |
| .ai-mode-tab.active[data-task="generate"] { background: var(--navy); color: #fff; border-color: var(--navy); } | |
| .ai-mode-tab.active[data-task="proofread"] { background: var(--gold); color: var(--navy); border-color: var(--gold); } | |
| .ai-mode-tab.active[data-task="enhance"] { background: var(--purple); color: #fff; border-color: var(--purple); } | |
| .ai-mode-desc { font-size: .77rem; color: var(--muted); margin-top: .4rem; padding: .4rem .8rem; background: #f8f9fb; border-radius: 5px; border-left: 3px solid var(--border); } | |
| .ai-mode-tab.active[data-task="generate"] ~ .ai-mode-desc { border-left-color: var(--navy); } | |
| .ai-mode-tab.active[data-task="proofread"] ~ .ai-mode-desc { border-left-color: var(--gold); } | |
| .ai-mode-tab.active[data-task="enhance"] ~ .ai-mode-desc { border-left-color: var(--purple); } | |
| .keep-msg { display: none; align-items: center; gap: .5rem; background: #f5f5f5; border-radius: 6px; padding: .6rem .9rem; font-size: .82rem; color: var(--muted); margin-top: .5rem; } | |
| .keep-msg.visible { display: flex; } | |
| .bullets-area { width: 100%; min-height: 80px; resize: vertical; padding: .6rem .8rem; border: 1.5px solid var(--border); border-radius: 6px; font-size: .85rem; font-family: inherit; background: var(--bg); color: var(--text); line-height: 1.5; } | |
| .bullets-area:focus { outline: none; border-color: var(--navy); background: #fff; } | |
| .bullets-hint { font-size: .75rem; color: var(--muted); margin-top: .3rem; background: #f8f9fb; border-radius: 4px; padding: .4rem .7rem; border-left: 3px solid var(--border); } | |
| .generate-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 2rem; padding-top: 1.2rem; border-top: 1.5px solid var(--border); } | |
| .generate-bar .count { font-size: .85rem; color: var(--muted); } | |
| .generate-bar .count strong { color: var(--navy); } | |
| /* Generating step */ | |
| #step-generating { display: none; } | |
| .gen-section-list { display: flex; flex-direction: column; gap: .6rem; } | |
| .gen-row { display: flex; align-items: center; gap: .8rem; background: var(--bg); border-radius: 7px; padding: .7rem 1rem; border: 1.5px solid var(--border); } | |
| .gen-row .code { border-radius: 5px; padding: .15rem .5rem; font-size: .78rem; font-weight: 700; min-width: 34px; text-align: center; flex-shrink: 0; color: #fff; } | |
| .gen-row .code.c-generate { background: var(--navy); } | |
| .gen-row .code.c-proofread { background: var(--gold); color: var(--navy); } | |
| .gen-row .code.c-enhance { background: var(--purple); } | |
| .gen-row .code.c-keep { background: #aaa; } | |
| .gen-row .label { flex: 1; font-size: .88rem; } | |
| .gen-row .gen-status { font-size: .78rem; font-weight: 600; padding: .2rem .7rem; border-radius: 12px; } | |
| .gs-waiting { background: #f0f3f8; color: var(--muted); } | |
| .gs-working { background: #e3f0ff; color: var(--navy-lt); } | |
| .gs-done { background: #e8f5ee; color: var(--success); } | |
| .gs-skip { background: #f5f5f5; color: var(--muted); } | |
| .gs-error { background: #fdeaea; color: var(--danger); } | |
| .gen-row .progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; flex: 1; } | |
| .gen-row .progress-fill { height: 100%; border-radius: 2px; width: 0%; transition: width .6s ease; } | |
| .pf-generate { background: var(--navy); } | |
| .pf-proofread { background: var(--gold); } | |
| .pf-enhance { background: var(--purple); } | |
| /* Results */ | |
| #step-results { display: none; } | |
| .results-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.2rem; } | |
| .results-header h2 { font-size: 1.1rem; color: var(--navy); } | |
| .result-card { border: 1.5px solid var(--border); border-radius: 9px; margin-bottom: 1.1rem; overflow: hidden; background: var(--card); box-shadow: var(--shadow); } | |
| .result-card.mode-proofread { border-color: var(--gold); } | |
| .result-card.mode-enhance { border-color: var(--purple); } | |
| .result-head { display: flex; align-items: center; gap: .8rem; padding: .8rem 1.1rem; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: .6rem; } | |
| .result-head.mode-generate { background: linear-gradient(to right, #f0f4fa, #e8eef7); } | |
| .result-head.mode-proofread { background: linear-gradient(to right, #fffbee, #fff6d0); } | |
| .result-head.mode-enhance { background: linear-gradient(to right, #f5f0ff, #ede5ff); } | |
| .result-head.mode-keep { background: linear-gradient(to right, #f8f8f8, #f0f0f0); } | |
| .result-head .code { border-radius: 5px; padding: .15rem .5rem; font-size: .78rem; font-weight: 700; min-width: 34px; text-align: center; color: #fff; } | |
| .code-generate { background: var(--navy); } | |
| .code-proofread { background: var(--gold); color: var(--navy) ; } | |
| .code-enhance { background: var(--purple); } | |
| .code-keep { background: #aaa; } | |
| .result-head strong { flex: 1; font-size: .92rem; } | |
| .mode-badge { font-size: .72rem; font-weight: 700; padding: .2rem .7rem; border-radius: 12px; } | |
| .mb-generate { background: #e3f0ff; color: var(--navy); } | |
| .mb-proofread { background: #fff3cc; color: #7a5c00; } | |
| .mb-enhance { background: #f0e8ff; color: var(--purple); } | |
| .mb-keep { background: #f0f0f0; color: var(--muted); } | |
| .conf-badge { font-size: .75rem; font-weight: 600; padding: .2rem .7rem; border-radius: 12px; background: #e8f5ee; color: var(--success); } | |
| .conf-badge.low { background: #fff3e0; color: var(--warn); } | |
| .cached-badge { font-size: .72rem; background: #e3f0ff; color: var(--navy-lt); padding: .15rem .55rem; border-radius: 10px; font-weight: 600; } | |
| .style-badge { font-size: .72rem; background: #fdf6e3; color: #7a5c00; padding: .15rem .55rem; border-radius: 10px; font-weight: 600; border: 1px solid #f0d080; } | |
| .result-body { padding: 1.1rem; } | |
| /* | |
| * .result-text is contenteditable="plaintext-only" β surveyors can edit | |
| * the rendered output inline. Edits are debounced-saved (700 ms) via | |
| * PATCH /reports/{report_id}/sections/{section_code}, persisted to the | |
| * DB along with meta.user_edited = true, and reflected in | |
| * state.results[code].text so subsequent re-runs use the edited body | |
| * as draft_paragraph. The save flow is implemented in | |
| * bindResultTextEditing() / _saveSectionEdit() β do not alter the | |
| * status-pill copy/sequence or the debounce timing without coordinating | |
| * with the backend contract. The hover/focus styles below provide a | |
| * subtle affordance that the area is editable. | |
| */ | |
| .result-text { font-size: .92rem; line-height: 1.75; color: var(--text); white-space: pre-wrap; background: #fafbfc; border-radius: 6px; padding: .9rem 1rem; border: 1px solid var(--border); transition: border-color var(--transition), background var(--transition); } | |
| .result-text[contenteditable="true"] { cursor: text; } | |
| .result-text[contenteditable="true"]:hover { border-color: #b8d4f5; } | |
| .result-text[contenteditable="true"]:focus { outline: none; border-color: var(--navy); background: #fff; box-shadow: 0 0 0 2px rgba(26, 80, 145, .12); } | |
| .result-text.edited { border-color: #b8d4f5; } | |
| /* Edit status pill β copy/sequence is part of the spec; do not change. */ | |
| .edit-status { font-size: .72rem; font-weight: 600; padding: .15rem .55rem; border-radius: 10px; background: #f0f4fa; color: var(--navy-lt); border: 1px solid #d0dcf0; white-space: nowrap; user-select: none; } | |
| .edit-status.is-saving { background: #fff7e0; color: #7a5c00; border-color: #f0d080; } | |
| .edit-status.is-saved { background: #e8f5ee; color: var(--success); border-color: #b8e0c8; } | |
| .edit-status.is-error { background: #fdecea; color: var(--danger); border-color: #f0b8b0; } | |
| .editor-notes { margin-top: .7rem; background: #fffbee; border: 1px solid #f0d080; border-radius: 6px; padding: .7rem 1rem; font-size: .83rem; color: #7a5c00; } | |
| .editor-notes strong { display: block; margin-bottom: .3rem; } | |
| .verify-tag { background: #fff3e0; color: var(--warn); border-radius: 3px; padding: .05rem .3rem; font-weight: 700; font-size: .85em; border: 1px solid #ffe0b2; } | |
| .result-meta { display: flex; gap: .6rem; margin-top: .8rem; flex-wrap: wrap; align-items: center; } | |
| .interference-result-badge { font-size: .7rem; background: #f0ecff; color: var(--purple); padding: .15rem .55rem; border-radius: 10px; font-weight: 600; border: 1px solid #d0c4f0; cursor: help; } | |
| .meta-chip { font-size: .75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: .2rem .7rem; color: var(--muted); } | |
| .prov-list { margin-top: .8rem; font-size: .78rem; color: var(--muted); background: var(--bg); border-radius: 6px; padding: .6rem .9rem; border: 1px solid var(--border); } | |
| .prov-list summary { cursor: pointer; font-weight: 600; color: var(--navy-lt); } | |
| .prov-item { padding: .3rem 0; border-bottom: 1px solid var(--border); display: flex; gap: .6rem; flex-wrap: wrap; } | |
| .prov-item:last-child { border: none; } | |
| .prov-item code { font-size: .72rem; background: #e8eef7; border-radius: 3px; padding: .05rem .35rem; color: var(--navy); } | |
| .result-actions { display: flex; gap: .5rem; margin-top: .9rem; flex-wrap: wrap; } | |
| /* AI Involvement transparency (aggregate + per-section) */ | |
| .ai-transparency-panel { background: linear-gradient(135deg, #f0f7ff, #e8f0ff); border: 1.5px solid #b8d4f5; border-radius: 10px; padding: 1rem 1.2rem; margin-bottom: 1.2rem; } | |
| .ai-transparency-panel.hidden { display: none; } | |
| .ai-transparency-head { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .5rem; } | |
| .ai-transparency-head strong { font-size: .95rem; color: var(--navy); } | |
| .ai-involve-meter { flex: 1; min-width: 160px; height: 10px; background: #dde8f5; border-radius: 6px; overflow: hidden; } | |
| .ai-involve-fill { height: 100%; background: linear-gradient(90deg, var(--teal), var(--purple)); border-radius: 6px; transition: width .4s ease; } | |
| .ai-involve-pct { font-size: 1.1rem; font-weight: 800; color: var(--navy); min-width: 3.2rem; } | |
| .ai-transparency-details { font-size: .8rem; color: var(--muted); line-height: 1.5; } | |
| .ai-transparency-details summary { cursor: pointer; font-weight: 600; color: var(--navy-lt); margin-bottom: .35rem; } | |
| .section-transparency { font-size: .75rem; color: var(--muted); margin-top: .5rem; padding: .5rem .75rem; background: #f8fafc; border-radius: 6px; border-left: 3px solid var(--teal); } | |
| .cite-row { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .65rem; align-items: center; } | |
| .cite-label { font-size: .72rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; } | |
| .cite-chip { display: inline-flex; align-items: center; gap: .25rem; font-size: .74rem; padding: .2rem .55rem; border-radius: 12px; border: 1px solid var(--border); background: #fff; color: var(--navy-lt); cursor: help; max-width: 100%; } | |
| .cite-chip:hover { border-color: var(--navy); background: #f0f4fa; } | |
| .cite-chip kbd { font-size: .68rem; opacity: .75; } | |
| /* ββ Auth gate βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .auth-overlay { position: fixed; inset: 0; z-index: 9800; display: flex; align-items: center; justify-content: center; padding: 1.5rem; background: linear-gradient(135deg, #0d1a28, #14304a); } | |
| .auth-overlay.hidden-gate { display: none; } | |
| body.auth-gate { overflow: hidden; } | |
| .auth-card { width: min(420px, 100%); background: var(--card); border: 1.5px solid var(--border); border-radius: 14px; box-shadow: 0 18px 60px rgba(0,0,0,.35); padding: 2rem 1.8rem; } | |
| .auth-brand { font-size: 1.4rem; font-weight: 800; color: var(--navy); text-align: center; } | |
| .auth-brand span { color: var(--teal); } | |
| .auth-logo { font-size: 1.5rem; } | |
| .auth-tagline { font-size: .85rem; color: var(--muted); text-align: center; margin: .5rem 0 1.4rem; line-height: 1.5; } | |
| .auth-tabs { display: flex; gap: .4rem; background: #eef2f8; border-radius: 8px; padding: .25rem; margin-bottom: 1.2rem; } | |
| .auth-tab { flex: 1; border: none; background: transparent; padding: .55rem; border-radius: 6px; font-size: .85rem; font-weight: 600; color: var(--muted); cursor: pointer; transition: all var(--transition); } | |
| .auth-tab.active { background: var(--card); color: var(--navy); box-shadow: 0 1px 4px rgba(0,0,0,.08); } | |
| .auth-submit { width: 100%; margin-top: .4rem; justify-content: center; } | |
| html.dark .auth-tabs { background: #18202c; } | |
| html.dark .auth-card { background: #0f1c2b; } | |
| .app-toast { position: fixed; bottom: 1.5rem; right: 1.5rem; max-width: 22rem; padding: .75rem 1rem; border-radius: 8px; color: #fff; font-size: .88rem; z-index: 9999; box-shadow: 0 4px 20px rgba(0,0,0,.15); transition: opacity .25s ease; } | |
| .app-toast.hidden { opacity: 0; pointer-events: none; } | |
| .app-toast.toast-ok { background: #1a5f4a; } | |
| .app-toast.toast-warn { background: #8a6d3b; } | |
| .app-toast.toast-err { background: #b71c1c; } | |
| .bullets-toolbar { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: .5rem; margin-bottom: .35rem; } | |
| .bullets-toolbar label { margin: 0; flex: 1; min-width: 12rem; } | |
| .btn-similar { font-size: .78rem; font-weight: 600; padding: .35rem .75rem; border-radius: 6px; border: 1.5px solid var(--navy); background: #fff; color: var(--navy); cursor: pointer; white-space: nowrap; transition: all var(--transition); } | |
| .btn-similar:hover { background: var(--navy); color: #fff; } | |
| .similar-modal { position: fixed; inset: 0; z-index: 9000; display: flex; align-items: flex-start; justify-content: center; padding: 4vh 1rem; overflow-y: auto; } | |
| .similar-modal.hidden { display: none; } | |
| .similar-modal-backdrop { position: fixed; inset: 0; background: rgba(15, 25, 40, .55); backdrop-filter: blur(2px); } | |
| .similar-modal-panel { position: relative; z-index: 1; width: min(720px, 100%); background: var(--card); border: 1.5px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; } | |
| .similar-modal-head { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.2rem; border-bottom: 1px solid var(--border); background: linear-gradient(135deg, #f8fafc, #eef2f8); } | |
| .similar-modal-head h3 { margin: 0; font-size: 1.05rem; color: var(--navy); } | |
| .similar-modal-close { border: none; background: transparent; font-size: 1.5rem; line-height: 1; cursor: pointer; color: var(--muted); padding: 0 .25rem; } | |
| .similar-modal-close:hover { color: var(--danger); } | |
| .similar-modal-body { padding: 1rem 1.2rem 1.4rem; overflow-y: auto; flex: 1; } | |
| /* ββ RAG Document Manager βββββββββββββββββββββββββββββββββββββββββββ */ | |
| .docmgr-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; flex-wrap: wrap; } | |
| .docmgr-count { font-size: .8rem; color: var(--muted); font-weight: 600; } | |
| .docmgr-table { width: 100%; border-collapse: collapse; font-size: .82rem; } | |
| .docmgr-table th { text-align: left; font-size: .68rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; padding: .4rem .55rem; border-bottom: 1.5px solid var(--border); white-space: nowrap; } | |
| .docmgr-table td { padding: .55rem .55rem; border-bottom: 1px solid var(--border); vertical-align: middle; } | |
| .docmgr-table tr:last-child td { border-bottom: none; } | |
| .docmgr-table tr.row-deleting { opacity: .5; pointer-events: none; } | |
| .docmgr-fname { font-weight: 600; color: var(--navy); word-break: break-word; max-width: 22rem; } | |
| .docmgr-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .68rem; color: var(--muted); } | |
| .docmgr-meta { font-size: .72rem; color: var(--muted); white-space: nowrap; } | |
| .docmgr-badge { display: inline-block; font-size: .66rem; font-weight: 700; padding: .12rem .5rem; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; white-space: nowrap; } | |
| .docmgr-badge.b-ready { background: #e3f4ec; color: #1a5f4a; } | |
| .docmgr-badge.b-processing { background: #fef6e0; color: #8a6d3b; } | |
| .docmgr-badge.b-pending { background: #eef2f8; color: var(--navy-lt); } | |
| .docmgr-badge.b-failed { background: #fdecea; color: #b71c1c; } | |
| .docmgr-badge.b-session { background: var(--teal); color: #fff; margin-left: .35rem; } | |
| .docmgr-badge.b-style { background: #ece3f7; color: #5b3a8a; } | |
| .docmgr-del { font-size: .76rem; font-weight: 600; padding: .32rem .7rem; border-radius: 6px; border: 1.5px solid var(--danger); background: #fff; color: var(--danger); cursor: pointer; white-space: nowrap; transition: all var(--transition); } | |
| .docmgr-del:hover { background: var(--danger); color: #fff; } | |
| .docmgr-del:disabled { opacity: .5; cursor: default; } | |
| .docmgr-empty { text-align: center; padding: 2.5rem 1rem; color: var(--muted); } | |
| .docmgr-empty .icon { font-size: 2.2rem; display: block; margin-bottom: .6rem; } | |
| .docmgr-spinner { width: 1.05rem; height: 1.05rem; border: 2.5px solid rgba(0,0,0,.15); border-top-color: var(--navy); border-radius: 50%; display: inline-block; animation: docmgr-spin .7s linear infinite; vertical-align: middle; } | |
| @keyframes docmgr-spin { to { transform: rotate(360deg); } } | |
| .docmgr-loading { text-align: center; padding: 2rem 1rem; color: var(--muted); } | |
| html.dark .docmgr-badge.b-ready { background: #14352a; color: #6fdab2; } | |
| html.dark .docmgr-badge.b-processing { background: #3a2f17; color: #e6c878; } | |
| html.dark .docmgr-badge.b-pending { background: #18202c; color: #9db4d0; } | |
| html.dark .docmgr-badge.b-failed { background: #3a1715; color: #f0a39d; } | |
| html.dark .docmgr-badge.b-style { background: #2a1f3a; color: #c5a8e8; } | |
| html.dark .similar-modal-head[style*="fdecea"], html.dark #doc-delete-modal .similar-modal-head { background: #3a1715 ; } | |
| .photo-ai-hint { font-size: .78rem; color: var(--muted); margin-top: .45rem; line-height: 1.4; } | |
| .photo-thumb-wrap { position: relative; display: inline-flex; flex-direction: column; align-items: center; gap: 4px; max-width: 96px; } | |
| .photo-thumb-frame { position: relative; display: block; line-height: 0; } | |
| .photo-thumb-wrap img { display: block; width: 86px; height: 64px; object-fit: cover; border-radius: 10px; border: 2px solid #d8deea; background: #e8ecf2; transition: border-color var(--transition), box-shadow var(--transition); } | |
| .photo-thumb-wrap.ai-selected img { border-color: var(--teal); box-shadow: 0 0 0 2px rgba(14,124,134,.25); } | |
| .photo-thumb-name { font-size: .62rem; color: var(--muted); max-width: 86px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } | |
| .photo-ai-tick { position: absolute; top: 4px; right: 4px; width: 26px; height: 26px; border-radius: 50%; border: 2px solid #fff; background: rgba(27,58,92,.55); color: #fff; font-size: .85rem; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; box-shadow: 0 1px 4px rgba(0,0,0,.2); transition: background var(--transition), transform var(--transition); z-index: 2; } | |
| .photo-del-btn { position: absolute; top: 4px; left: 4px; width: 22px; height: 22px; border-radius: 50%; border: 2px solid #fff; background: rgba(192,57,43,.92); color: #fff; font-size: .95rem; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; box-shadow: 0 1px 4px rgba(0,0,0,.2); z-index: 2; padding: 0; } | |
| .photo-del-btn:hover { background: var(--danger); } | |
| .photo-ai-tick:hover { transform: scale(1.06); } | |
| .photo-ai-tick.on { background: var(--teal); border-color: #fff; } | |
| .photo-ai-tick:disabled { opacity: .45; cursor: not-allowed; transform: none; } | |
| .photo-slot-count { font-size: .72rem; color: var(--muted); margin-top: 6px; } | |
| .similar-intro { font-size: .86rem; color: var(--muted); margin: 0 0 1rem; line-height: 1.5; } | |
| .similar-h4 { margin: 1.2rem 0 .6rem; font-size: .88rem; color: var(--navy); text-transform: uppercase; letter-spacing: .04em; } | |
| .similar-h4:first-of-type { margin-top: 0; } | |
| .similar-card { border: 1.5px solid var(--border); border-radius: 8px; padding: .75rem 1rem; margin-bottom: .75rem; background: var(--bg); } | |
| .similar-card.hidden { display: none; } | |
| .similar-meta { font-size: .78rem; color: var(--muted); margin-bottom: .5rem; } | |
| .similar-pre { margin: 0; font-size: .8rem; white-space: pre-wrap; word-break: break-word; max-height: 140px; overflow-y: auto; background: #f4f6fa; padding: .5rem .65rem; border-radius: 5px; border: 1px solid var(--border); } | |
| .similar-cols { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; } | |
| @media (max-width: 640px) { .similar-cols { grid-template-columns: 1fr; } } | |
| .similar-cols .lbl { font-size: .68rem; font-weight: 700; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: .2rem; } | |
| .similar-actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .65rem; } | |
| .similar-actions .btn-sm { font-size: .74rem; padding: .3rem .6rem; border-radius: 5px; border: 1px solid var(--border); background: #fff; cursor: pointer; color: var(--navy); } | |
| .similar-actions .btn-sm:hover { border-color: var(--navy); background: #f0f4fa; } | |
| .similar-actions .btn-muted { color: var(--muted); } | |
| .similar-actions .btn-danger { border-color: #e8a0a0; color: var(--danger); } | |
| .similar-actions .btn-danger:hover { background: #fff5f5; } | |
| .similar-loading, .similar-empty, .similar-err { font-size: .88rem; color: var(--muted); } | |
| .similar-err { color: var(--danger); } | |
| /* Alerts */ | |
| .alert { padding: .7rem 1rem; border-radius: 7px; font-size: .85rem; display: flex; align-items: center; gap: .6rem; margin-bottom: .8rem; } | |
| .alert-info { background: #e3f0ff; color: var(--navy-lt); border: 1px solid #b8d4f5; } | |
| .alert-warn { background: #fff3e0; color: var(--warn); border: 1px solid #ffe0b2; } | |
| .alert-error { background: #fdeaea; color: var(--danger); border: 1px solid #f5c6c6; } | |
| .alert-ok { background: #e8f5ee; color: var(--success); border: 1px solid #b2dfcb; } | |
| /* Notes upload panel (single global) */ | |
| .notes-upload-panel { background: linear-gradient(135deg, #e6f6f7, #d0f0f3); border: 1.5px solid var(--teal); border-radius: 10px; padding: 1.1rem 1.3rem; margin-bottom: 1.2rem; } | |
| .notes-upload-title { font-size: .82rem; font-weight: 700; color: var(--teal); text-transform: uppercase; letter-spacing: .07em; margin-bottom: .5rem; display: flex; align-items: center; gap: .45rem; } | |
| .notes-upload-row { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; } | |
| .notes-upload-desc { font-size: .8rem; color: #0a6870; flex: 1; min-width: 180px; line-height: 1.45; } | |
| .notes-upload-btn { display: inline-flex; align-items: center; gap: .4rem; padding: .5rem 1.2rem; border-radius: 7px; font-size: .85rem; font-weight: 700; cursor: pointer; border: none; background: var(--teal); color: #fff; transition: all var(--transition); flex-shrink: 0; } | |
| .notes-upload-btn:hover { background: #0a6870; transform: translateY(-1px); box-shadow: 0 4px 10px rgba(14,124,134,.3); } | |
| .notes-upload-btn:disabled { opacity: .45; cursor: not-allowed; transform: none; } | |
| .notes-upload-status { font-size: .78rem; margin-top: .55rem; padding: .4rem .75rem; border-radius: 6px; display: none; } | |
| .nus-ok { background: #e8f5ee; color: var(--success); display: block ; } | |
| .nus-err { background: #fdeaea; color: var(--danger); display: block ; } | |
| .nus-busy { background: #e3f0ff; color: var(--navy-lt); display: block ; } | |
| .nus-warn { background: #fff3cd; color: #856404; display: block ; } | |
| /* Report composition depth (minimum | medium | maximum) */ | |
| .ai-interference-panel { background: linear-gradient(135deg, #f8f6ff, #f0ecff); border: 1.5px solid #d0c4f0; border-radius: 12px; padding: 1.1rem 1.25rem; margin-bottom: 1.4rem; } | |
| .ai-interference-title { font-size: .82rem; font-weight: 700; color: var(--purple); text-transform: uppercase; letter-spacing: .07em; margin-bottom: .55rem; display: flex; align-items: center; gap: .45rem; } | |
| .interference-pill-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: .65rem; margin-top: .35rem; } | |
| @media (max-width: 820px) { .interference-pill-row { grid-template-columns: 1fr; } } | |
| .interference-pill { | |
| cursor: pointer; text-align: left; border-radius: 12px; border: 2px solid var(--border); | |
| background: var(--card); padding: .75rem .8rem .7rem; min-height: 5.5rem; | |
| transition: border-color .2s ease, box-shadow .2s ease, background .2s ease, transform .2s ease; | |
| display: flex; flex-direction: column; gap: .35rem; position: relative; color: inherit; font: inherit; | |
| } | |
| .interference-pill:focus-visible { outline: 2px solid var(--purple); outline-offset: 2px; } | |
| .interference-pill:hover { border-color: #b8a0e0; background: #faf9ff; } | |
| .interference-pill.active { | |
| border-width: 3px; border-color: var(--purple); | |
| box-shadow: 0 6px 22px rgba(107,63,160,.18), 0 0 0 1px rgba(107,63,160,.08) inset; | |
| background: linear-gradient(165deg, #faf8ff 0%, #f3ecff 100%); | |
| transform: translateY(-1px); | |
| } | |
| .interference-pill .pill-icon { font-size: 1.35rem; line-height: 1; transition: transform .28s ease; } | |
| .interference-pill.active .pill-icon { transform: scale(1.12) rotate(-3deg); } | |
| .interference-pill .pill-label { font-weight: 800; font-size: .86rem; color: var(--navy); } | |
| .interference-pill .pill-tagline { font-size: .72rem; color: var(--muted); line-height: 1.35; } | |
| .interference-detail { font-size: .78rem; color: var(--muted); margin-top: .65rem; padding: .45rem .85rem; background: rgba(255,255,255,.7); border-radius: 8px; border-left: 3px solid var(--purple); min-height: 2.6rem; } | |
| /* ββ Theme Toggle Button ββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .theme-toggle { | |
| background: rgba(255,255,255,.12); | |
| border: 1.5px solid rgba(255,255,255,.22); | |
| border-radius: 22px; | |
| padding: .28rem .9rem .28rem .55rem; | |
| font-size: .82rem; | |
| font-weight: 600; | |
| color: rgba(255,255,255,.88); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: .45rem; | |
| transition: background var(--transition), border-color var(--transition); | |
| user-select: none; | |
| flex-shrink: 0; | |
| } | |
| .theme-toggle:hover { background: rgba(255,255,255,.22); border-color: rgba(255,255,255,.4); } | |
| .theme-toggle .th-icon { font-size: 1.05rem; transition: transform .35s ease; } | |
| html.dark .theme-toggle .th-icon { transform: rotate(20deg); } | |
| /* ββ βοΈ LIGHT MODE (default) β Sunny White Shine βββββββββββββββββββββββ */ | |
| :root { | |
| --navy: #1b3a5c; | |
| --navy-lt: #274d7a; | |
| --gold: #c8a020; | |
| --gold-lt: #e8c040; | |
| --purple: #6b3fa0; | |
| --purple-lt:#8a5cc0; | |
| --teal: #0e7c86; | |
| --bg: #f5f8ff; | |
| --card: #ffffff; | |
| --border: #d4dce8; | |
| --text: #1a2332; | |
| --muted: #5a6a7e; | |
| --success: #1a7f4b; | |
| --warn: #c85a00; | |
| --danger: #c0392b; | |
| --radius: 10px; | |
| --shadow: 0 2px 16px rgba(27,58,92,.10); | |
| --transition: .2s ease; | |
| /* Light mode extras β sun glow */ | |
| --page-bg: linear-gradient(160deg, #eef4ff 0%, #f9fbff 50%, #fffdf0 100%); | |
| --header-bg: linear-gradient(90deg, #1b3a5c 0%, #2c5285 100%); | |
| --shine-ring: 0 0 0 4px rgba(200,160,32,.08); | |
| } | |
| /* ββ π§οΈ DARK MODE β Rainy Night ββββββββββββββββββββββββββββββββββββββββββ */ | |
| html.dark { | |
| --navy: #7aaddd; | |
| --navy-lt: #9ec5e8; | |
| --gold: #e8c040; | |
| --gold-lt: #f5d060; | |
| --purple: #b48fdf; | |
| --purple-lt:#caaff0; | |
| --teal: #38c5d0; | |
| --bg: #0d1520; | |
| --card: #111d2e; | |
| --border: #1e2e42; | |
| --text: #c8d8ec; | |
| --muted: #5a7a9a; | |
| --success: #3db87a; | |
| --warn: #e8904a; | |
| --danger: #e05555; | |
| --shadow: 0 4px 24px rgba(0,0,0,.55); | |
| /* Dark mode extras β rain atmosphere */ | |
| --page-bg: linear-gradient(160deg, #080f18 0%, #0d1520 60%, #0a1825 100%); | |
| --header-bg: linear-gradient(90deg, #060e1a 0%, #0d1e33 100%); | |
| } | |
| /* Apply page background */ | |
| body { | |
| font-family: 'Segoe UI', system-ui, sans-serif; | |
| background: var(--page-bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| line-height: 1.6; | |
| } | |
| /* Dark card surfaces */ | |
| html.dark .card { background: var(--card); border-color: var(--border); } | |
| html.dark .card-head { background: linear-gradient(135deg, #0b1928 0%, #0d2035 100%); } | |
| html.dark .upload-zone { background: #0c1724; border-color: var(--border); } | |
| html.dark .upload-zone:hover, html.dark .upload-zone.drag-over { border-color: var(--teal); background: #0d1f30; } | |
| html.dark .upload-zone.has-file { border-color: var(--success); background: #0d1e18; } | |
| html.dark .upload-zone h3 { color: var(--navy); } | |
| html.dark .form-group input { background: #0c1724; color: var(--text); border-color: var(--border); } | |
| html.dark .form-group input:focus { background: #0f1e2e; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(56,197,208,.12); } | |
| html.dark .bullets-area { background: #0c1724; color: var(--text); border-color: var(--border); } | |
| html.dark .bullets-area:focus { background: #0f1e2e; border-color: var(--teal); } | |
| html.dark .ai-mode-tab { background: #0c1724; border-color: var(--border); color: var(--muted); } | |
| html.dark .ai-mode-tab:hover { border-color: var(--teal); color: var(--teal); background: #0d2030; } | |
| html.dark .ai-mode-desc { background: #0a1620; border-left-color: var(--border); } | |
| html.dark .keep-msg { background: #0d1624; } | |
| html.dark .bullets-hint { background: #0a1620; border-left-color: var(--border); } | |
| html.dark .section-card { border-color: var(--border); background: var(--card); } | |
| html.dark .section-group-header .grp-label { color: #a8c0e0; } | |
| html.dark .section-group-header .grp-line { background: var(--border); } | |
| html.dark .section-head { background: var(--card); } | |
| html.dark .section-card.active { border-color: var(--teal); } | |
| html.dark .gen-row { background: #0c1724; border-color: var(--border); } | |
| html.dark .result-card { background: var(--card); border-color: var(--border); } | |
| html.dark .result-head.mode-generate { background: linear-gradient(to right, #0d1e30, #0f2236); } | |
| html.dark .result-head.mode-proofread { background: linear-gradient(to right, #1a1600, #221c00); } | |
| html.dark .result-head.mode-enhance { background: linear-gradient(to right, #140b28, #1a1030); } | |
| html.dark .result-head.mode-keep { background: linear-gradient(to right, #111111, #181818); } | |
| html.dark .result-text { background: #0a1218; border-color: var(--border); color: var(--text); } | |
| html.dark .prov-list { background: #0a1218; border-color: var(--border); } | |
| html.dark .prov-item { border-color: var(--border); } | |
| html.dark .prov-item code { background: #0d1e2e; color: var(--navy); } | |
| html.dark .meta-chip { background: #0c1724; border-color: var(--border); } | |
| html.dark .doc-summary { background: #0d1e18; border-color: #1e3a2a; } | |
| html.dark .alert-info { background: #0d1e30; color: var(--navy-lt); border-color: #1a3350; } | |
| html.dark .alert-warn { background: #1e1200; color: var(--warn); border-color: #3a2200; } | |
| html.dark .alert-error { background: #200a0a; color: var(--danger); border-color: #3a1010; } | |
| html.dark .alert-ok { background: #0d1e18; color: var(--success); border-color: #1a3528; } | |
| html.dark .style-summary-box { background: linear-gradient(135deg, #0d1a28, #0e1e30); border-color: var(--border); color: var(--text); } | |
| html.dark .style-phrase { background: #0e1e30; color: var(--navy-lt); } | |
| html.dark .style-chip { background: #0c1724; border-color: var(--border); } | |
| html.dark .style-chip .sc-value { color: var(--navy); } | |
| html.dark .notes-upload-panel { background: linear-gradient(135deg, #081820, #0a2028); border-color: var(--teal); } | |
| html.dark .notes-upload-desc { color: var(--teal); } | |
| html.dark .ai-interference-panel { background: linear-gradient(135deg, #100820, #140a28); border-color: #2a1a4a; } | |
| html.dark .interference-pill { background: #12081f; border-color: #2a1a4a; color: var(--text); } | |
| html.dark .interference-pill:hover { background: #1a0f28; border-color: #3d2a5c; } | |
| html.dark .interference-pill.active { background: linear-gradient(165deg, #1a0f2e 0%, #140a22 100%); border-color: var(--purple-lt); } | |
| html.dark .interference-pill .pill-label { color: var(--navy); } | |
| html.dark .interference-detail { background: rgba(0,0,0,.4); } | |
| html.dark .gs-waiting { background: #0c1724; color: var(--muted); } | |
| html.dark .gs-working { background: #0d1e30; color: var(--navy-lt); } | |
| html.dark .gs-done { background: #0d1e18; color: var(--success); } | |
| html.dark .gs-skip { background: #0f0f0f; color: var(--muted); } | |
| html.dark .gs-error { background: #200a0a; color: var(--danger); } | |
| html.dark .pill-pending { background: #1e1200; color: var(--warn); } | |
| html.dark .pill-processing { background: #0d1e30; color: var(--navy-lt); } | |
| html.dark .pill-complete { background: #0d1e18; color: var(--success); } | |
| html.dark .pill-failed { background: #200a0a; color: var(--danger); } | |
| html.dark .nus-ok { background: #0d1e18; color: var(--success); } | |
| html.dark .nus-err { background: #200a0a; color: var(--danger); } | |
| html.dark .nus-busy { background: #0d1e30; color: var(--navy-lt); } | |
| html.dark .nus-warn { background: #2a2110; color: #f5c46e; } | |
| html.dark .similar-modal-head { background: linear-gradient(135deg, #0d1a28, #0f2236); } | |
| html.dark .similar-pre { background: #0a1218; border-color: var(--border); } | |
| html.dark .btn-similar { background: #0c1724; color: var(--teal); border-color: var(--teal); } | |
| html.dark .btn-similar:hover { background: var(--teal); color: #0a1620; } | |
| html.dark .conf-badge { background: #0d1e18; color: var(--success); } | |
| html.dark .conf-badge.low { background: #1e1200; color: var(--warn); } | |
| html.dark .cached-badge { background: #0d1e30; color: var(--navy-lt); } | |
| html.dark .style-badge { background: #1e1600; color: var(--gold); border-color: #3a2e00; } | |
| html.dark .mb-generate { background: #0d1e30; color: var(--navy-lt); } | |
| html.dark .mb-proofread { background: #1e1600; color: var(--gold); } | |
| html.dark .mb-enhance { background: #140b28; color: var(--purple-lt); } | |
| html.dark .mb-keep { background: #111111; color: var(--muted); } | |
| html.dark .editor-notes { background: #1a1400; border-color: #3a2e00; color: var(--gold); } | |
| html.dark .verify-tag { background: #1e1200; color: var(--warn); border-color: #3a2200; } | |
| html.dark .generate-bar { border-color: var(--border); } | |
| html.dark .toggle { background: var(--border); } | |
| html.dark .toggle:checked { background: var(--teal); } | |
| html.dark .btn-outline { color: var(--text); border-color: var(--border); } | |
| html.dark .btn-outline:hover { border-color: var(--teal); background: #0d1e30; } | |
| html.dark .file-name { background: var(--card); border-color: #1e3a2a; } | |
| /* Dark mode rain atmosphere decorations */ | |
| html.dark body::before { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| background: | |
| radial-gradient(ellipse at 20% 0%, rgba(56,197,208,.04) 0%, transparent 55%), | |
| radial-gradient(ellipse at 80% 0%, rgba(107,63,160,.05) 0%, transparent 50%); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| html.dark main, html.dark header, html.dark .steps { position: relative; z-index: 1; } | |
| /* Light mode sunshine decoration */ | |
| html:not(.dark) body::before { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| background: | |
| radial-gradient(ellipse at 75% -10%, rgba(255,220,80,.18) 0%, transparent 45%), | |
| radial-gradient(ellipse at 10% 5%, rgba(200,230,255,.25) 0%, transparent 50%); | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| html:not(.dark) main, html:not(.dark) header, html:not(.dark) .steps { position: relative; z-index: 1; } | |
| /* Misc */ | |
| .hidden { display: none ; } | |
| .divider { border: none; border-top: 1.5px solid var(--border); margin: 1.2rem 0; } | |
| </style> | |
| </head> | |
| <body class="auth-gate"> | |
| <header> | |
| <div class="logo"> | |
| <div class="logo-icon">π</div> | |
| Report Genius <span>AI</span> | |
| </div> | |
| <span class="subtitle">RICS Survey Report Generator</span> | |
| <div class="spacer"></div> | |
| <div class="tenant-badge" id="tenant-badge">Loadingβ¦</div> | |
| <button class="theme-toggle" id="btn-doc-manager" onclick="openDocManager()" title="View and delete your uploaded RAG documents"> | |
| <span aria-hidden="true">ποΈ</span> | |
| <span>My Documents</span> | |
| </button> | |
| <button class="theme-toggle" id="btn-logout" onclick="logout()" title="Sign out" style="display:none"> | |
| <span aria-hidden="true">πͺ</span> | |
| <span>Log out</span> | |
| </button> | |
| <button class="theme-toggle" id="theme-toggle-btn" onclick="toggleTheme()" title="Switch between light and dark mode"> | |
| <span class="th-icon" id="theme-icon">βοΈ</span> | |
| <span id="theme-label">Light</span> | |
| </button> | |
| </header> | |
| <!-- ββ Auth gate βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div id="auth-overlay" class="auth-overlay" aria-hidden="false"> | |
| <div class="auth-card"> | |
| <div class="auth-brand"><span class="auth-logo">π</span> Report Genius <span>AI</span></div> | |
| <p class="auth-tagline">Sign in to your private workspace. Your documents and reports are isolated to your account.</p> | |
| <div class="auth-tabs"> | |
| <button type="button" class="auth-tab active" id="auth-tab-login" onclick="setAuthMode('login')">Log in</button> | |
| <button type="button" class="auth-tab" id="auth-tab-register" onclick="setAuthMode('register')">Create account</button> | |
| </div> | |
| <form id="auth-form" onsubmit="submitAuth(event)" autocomplete="on"> | |
| <div class="form-group"> | |
| <label for="auth-tenant">Tenant / User ID</label> | |
| <input type="text" id="auth-tenant" name="username" autocomplete="username" placeholder="e.g. acme_surveys" /> | |
| <div class="form-hint">3β128 chars Β· letters, digits, '.', '_', '-'. This is your private workspace key.</div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="auth-pass">Passphrase</label> | |
| <input type="password" id="auth-pass" name="password" autocomplete="current-password" placeholder="At least 8 characters" /> | |
| <div class="form-hint" id="auth-pass-hint">Use a strong, memorable passphrase. There is no recovery β keep it safe.</div> | |
| </div> | |
| <div id="auth-alert" class="hidden"></div> | |
| <button type="submit" class="btn btn-primary auth-submit" id="auth-submit-btn">Log in</button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="steps" id="step-indicator"> | |
| <div class="step-item active" id="si-1"><div class="step-num">1</div> Upload</div> | |
| <div class="step-sep">βΊ</div> | |
| <div class="step-item" id="si-2"><div class="step-num">2</div> Processing</div> | |
| <div class="step-sep">βΊ</div> | |
| <div class="step-item" id="si-3"><div class="step-num">3</div> Configure</div> | |
| <div class="step-sep">βΊ</div> | |
| <div class="step-item" id="si-4"><div class="step-num">4</div> Generating</div> | |
| <div class="step-sep">βΊ</div> | |
| <div class="step-item" id="si-5"><div class="step-num">5</div> Results</div> | |
| </div> | |
| <main> | |
| <!-- STEP 1: UPLOAD --> | |
| <section id="step-upload"> | |
| <div class="card"> | |
| <div class="card-head"> | |
| <div> | |
| <h2>π Upload Reference Documents</h2> | |
| <p>Upload one file, many files at once, or ZIP archives of .docx / .pdf β all stored in the database per file. Label the <strong>RICS survey tier</strong> so the AI only pulls matching reference reports from your library (mixed Level 1 / 2 / 3 uploads).</p> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <div class="upload-zone" id="upload-zone"> | |
| <span class="icon">βοΈ</span> | |
| <h3>Drag & drop files or folders of documents here</h3> | |
| <p>or click to browse β multi-select supported</p> | |
| <p style="margin-top:.5rem;font-size:.78rem;color:var(--border)">Accepted: .docx Β· .pdf Β· .zip (archives are expanded). Per-file and ZIP size limits are enforced on the server. Very large libraries: repeat uploads or split across several ZIPs / batches.</p> | |
| <div class="file-name hidden" id="file-display"> | |
| <span>π</span> <span id="file-name-text"></span> | |
| <span id="file-size-text" style="color:var(--muted);font-weight:400"></span> | |
| </div> | |
| </div> | |
| <input type="file" id="file-input" accept=".docx,.pdf,.zip" multiple /> | |
| <div class="form-group"> | |
| <label for="tenant-input">Tenant / User ID</label> | |
| <input type="text" id="tenant-input" placeholder="e.g. my_company β leave blank to auto-generate" /> | |
| <div class="form-hint">All your documents and reports are isolated under this ID</div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="survey-level-input">RICS survey level for these uploads</label> | |
| <select id="survey-level-input" style="width:100%;max-width:420px;padding:.45rem .6rem;border-radius:6px;border:1px solid var(--border);font-size:.9rem;"> | |
| <option value="" selected>Not set β confirm after upload (recommended)</option> | |
| <option value="1">Level 1 β Condition Report</option> | |
| <option value="2">Level 2 β HomeBuyer / intermediate</option> | |
| <option value="3">Level 3 β Building Survey</option> | |
| </select> | |
| <div class="form-hint">If you pick a tier here, it applies to <strong>every</strong> file in the batch. If you leave it unset, the app suggests a tier per file from your local exemplar folders, then you confirm before the report is created. Per-file changes: <code>PATCH /documents/{id}/survey-level</code> or <code>POST /documents/survey-level/apply</code>.</div> | |
| </div> | |
| <div id="upload-alert" class="hidden"></div> | |
| <div style="display:flex;justify-content:flex-end;margin-top:1.2rem"> | |
| <button type="button" class="btn btn-primary" id="btn-upload" disabled>β¬οΈ Upload & Analyse</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- STEP 2: PROCESSING --> | |
| <section id="step-processing"> | |
| <div class="card"> | |
| <div class="card-body processing-card"> | |
| <div class="spinner" id="proc-spinner"></div> | |
| <h3 id="proc-title">Uploading documentβ¦</h3> | |
| <p id="proc-detail">Please wait while we parse and index your document</p> | |
| <div id="proc-pill" class="status-pill pill-pending">β³ Pending</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- STEP 3: CONFIGURE --> | |
| <section id="step-configure"> | |
| <div class="card"> | |
| <div class="card-head"> | |
| <div> | |
| <h2>π§ Configure Your Report Sections</h2> | |
| <p>Select an AI mode for each section: Generate, Proofread, or Enhance Technical Depth</p> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <div class="doc-summary" id="doc-summary"> | |
| <div class="doc-icon">π</div> | |
| <div> | |
| <strong id="doc-fname">document.docx</strong> | |
| <span id="doc-stats">Ready β 0 chunks indexed</span> | |
| </div> | |
| </div> | |
| <!-- Global Notes Upload Panel --> | |
| <div class="notes-upload-panel"> | |
| <div class="notes-upload-title">π€ Auto-Fill All Sections from Your Notes</div> | |
| <div class="notes-upload-row"> | |
| <div class="notes-upload-desc"> | |
| Upload your raw inspection notes (<b>.docx</b>, <b>.pdf</b>, or <b>.txt</b>) and the AI will read the whole document, | |
| intelligently route each observation to the right RICS section, and pre-fill every bullets box β ready to generate. | |
| </div> | |
| <button type="button" class="notes-upload-btn" id="btn-notes-upload" onclick="triggerGlobalNotesUpload()"> | |
| π Upload Field Notes | |
| </button> | |
| <input type="file" id="global-notes-input" accept=".docx,.pdf,.txt" style="display:none" onchange="handleGlobalNotes(event)" /> | |
| </div> | |
| <div class="notes-upload-status" id="global-notes-status"></div> | |
| </div> | |
| <!-- AI Capabilities Info --> | |
| <div class="alert alert-info" style="margin-bottom:1rem"> | |
| β¨ <span><b>Just write your notes β the AI does the rest.</b> | |
| Enter rough, abbreviated, or incomplete inspection notes per section (or use the button above to auto-fill from a document). | |
| The AI automatically interprets shorthand (e.g. "semi det nw3 1965ish"), | |
| expands vague observations ("roof bad needs work") into professional RICS language, | |
| and matches your personal writing style throughout. | |
| <b>π Proofread</b> refines generated text. <b>βοΈ Enhance</b> adds technical depth.</span> | |
| </div> | |
| <!-- Style Profile Panel (shown after first successful generate) --> | |
| <div id="style-profile-panel" class="hidden" style="margin-bottom:1.2rem"> | |
| <div style="background:linear-gradient(135deg,#1b3a5c,#274d7a);border-radius:9px;padding:1.1rem 1.3rem;color:#fff;"> | |
| <div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.8rem"> | |
| <span style="font-size:1.2rem">π¨</span> | |
| <div> | |
| <div style="font-weight:700;font-size:.95rem">Detected Writing Style Profile</div> | |
| <div style="font-size:.78rem;opacity:.7">Analysed from your uploaded documents β AI will match this voice</div> | |
| </div> | |
| </div> | |
| <div class="style-profile-grid" id="style-chips"></div> | |
| <div class="style-summary-box" id="style-summary" style="background:rgba(255,255,255,.12);border-color:var(--gold);color:#fff"></div> | |
| <div class="style-phrases" id="style-phrases"></div> | |
| </div> | |
| </div> | |
| <!-- Report composition depth --> | |
| <div class="ai-interference-panel"> | |
| <div class="ai-interference-title">Report composition depth</div> | |
| <div style="display:flex;align-items:center;gap:.7rem;margin:.55rem 0 .15rem;flex-wrap:wrap"> | |
| <div style="font-weight:800;color:var(--navy)">Retrieval level</div> | |
| <select id="retrieval-level" style="padding:.38rem .55rem;border-radius:8px;border:1.5px solid var(--border);background:var(--card);color:var(--text)"> | |
| <option value="document">Document level (whole PDF intent)</option> | |
| <option value="section">Section level (page / part scope)</option> | |
| <option value="paragraph" selected>Paragraph level (fine chunks)</option> | |
| </select> | |
| <div style="font-size:.75rem;color:var(--muted)">Strict: generation uses only this levelβs retrieved context</div> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:.55rem;margin:.35rem 0 .2rem;flex-wrap:wrap"> | |
| <label style="display:flex;align-items:center;gap:.45rem;font-size:.82rem;color:var(--muted);user-select:none;cursor:pointer"> | |
| <input id="use-cache-toggle" type="checkbox" checked /> | |
| Use cache (reuse identical section generations) | |
| </label> | |
| <span style="font-size:.74rem;color:var(--muted)"> | |
| Turn off to force a fresh run even if the same section was generated before with the same notes/settings. | |
| </span> | |
| </div> | |
| <label style="display:flex;align-items:flex-start;gap:.45rem;font-size:.82rem;color:var(--muted);user-select:none;cursor:pointer;margin:.35rem 0 .25rem"> | |
| <input id="strict-uploads-toggle" type="checkbox" style="margin-top:.2rem" /> | |
| <span><strong>Strict uploads only</strong> β no firm knowledge base, no standard-paragraph master, minimal tenant style reuse. Use when you want hard isolation to this reportβs files only.</span> | |
| </label> | |
| <div class="interference-pill-row" role="radiogroup" aria-label="Report composition depth"> | |
| <button type="button" class="interference-pill" data-level="minimum" aria-pressed="false" title="Baseline preservation β facts woven in place on past report text."> | |
| <span class="pill-icon" aria-hidden="true">π</span> | |
| <span class="pill-label">Baseline preservation</span> | |
| <span class="pill-tagline">In-place fact updates only; past report scaffolding retained.</span> | |
| </button> | |
| <button type="button" class="interference-pill" data-level="medium" aria-pressed="false" title="Technical edit β proofread in-place updates on the baseline."> | |
| <span class="pill-icon" aria-hidden="true">βοΈ</span> | |
| <span class="pill-label">Technical edit</span> | |
| <span class="pill-tagline">Proofread in-place updates; every claim traceable to your notes.</span> | |
| </button> | |
| <button type="button" class="interference-pill active" data-level="maximum" aria-pressed="true" title="Full narrative edit β long-form baseline retained (Level 3 default)."> | |
| <span class="pill-icon" aria-hidden="true">π</span> | |
| <span class="pill-label">Full narrative edit</span> | |
| <span class="pill-tagline">Long-form past report baseline; formal RICS Home Survey prose.</span> | |
| </button> | |
| </div> | |
| <div class="interference-detail" id="interference-detail-desc"></div> | |
| </div> | |
| <p class="sections-label">RICS Level 3 Report Sections</p> | |
| <div id="section-cards"></div> | |
| <div id="configure-alert" class="hidden"></div> | |
| <div class="generate-bar"> | |
| <div class="count"><strong id="gen-count">0</strong> section(s) queued for AI</div> | |
| <button type="button" class="btn btn-gold" id="btn-generate" disabled>β¨ Run AI on Report</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- STEP 4: GENERATING --> | |
| <section id="step-generating"> | |
| <div class="card"> | |
| <div class="card-head"> | |
| <div> | |
| <h2>β¨ AI Processing Report Sections</h2> | |
| <p id="gen-step-subtitle">Interpreting your notes β expanding observations β retrieving evidence β generating content in your writing style</p> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <div class="progress-bar hidden" id="gen-overall-progress" style="margin-bottom:1rem"> | |
| <div class="progress-fill pf-generate" id="gen-overall-fill" style="width:0%"></div> | |
| </div> | |
| <div class="gen-section-list" id="gen-list"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- STEP 5: RESULTS --> | |
| <section id="step-results"> | |
| <div class="results-header"> | |
| <div> | |
| <h2 style="font-size:1.2rem;color:var(--navy)">π Generated Report</h2> | |
| <p style="font-size:.82rem;color:var(--muted)"> | |
| Items marked <span class="verify-tag">[VERIFY]</span> require human confirmation. | |
| Use the action buttons to apply additional AI modes to any section. | |
| </p> | |
| </div> | |
| <div style="display:flex;gap:.5rem;flex-wrap:wrap"> | |
| <button class="btn btn-teal btn-sm" id="btn-download-docx" title="Download full report as a professional RICS-formatted Word document">β¬ Download DOCX</button> | |
| <button class="btn btn-outline btn-sm" id="btn-download">β¬ Download .txt</button> | |
| <button class="btn btn-primary btn-sm" id="btn-restart">β© New Document</button> | |
| </div> | |
| </div> | |
| <div id="ai-transparency-panel" class="ai-transparency-panel hidden" aria-live="polite"> | |
| <div class="ai-transparency-head"> | |
| <strong>Composition depth (this report)</strong> | |
| <div id="interference-aggregate-chips" style="display:flex;flex-wrap:wrap;gap:.45rem;align-items:center"></div> | |
| </div> | |
| <details class="ai-transparency-details" id="ai-transparency-details"> | |
| <summary>How this summary is built</summary> | |
| <p id="ai-transparency-summary">Counts reflect the <strong>composition depth</strong> stored per section when it was generated (baseline preservation, technical edit, or full narrative edit). Retrieval uses passages from <em>your uploaded past reports</em> under your tenant only.</p> | |
| </details> | |
| </div> | |
| <div id="results-grid"></div> | |
| </section> | |
| </main> | |
| <div id="app-toast" class="app-toast hidden" role="status" aria-live="polite"></div> | |
| <div id="similar-modal" class="similar-modal hidden" role="dialog" aria-modal="true" aria-labelledby="similar-modal-title"> | |
| <div class="similar-modal-backdrop" onclick="closeSimilarityModal()"></div> | |
| <div class="similar-modal-panel"> | |
| <div class="similar-modal-head"> | |
| <h3 id="similar-modal-title">Similar content in your workspace</h3> | |
| <button type="button" class="similar-modal-close" onclick="closeSimilarityModal()" aria-label="Close">Γ</button> | |
| </div> | |
| <div id="similar-modal-body" class="similar-modal-body"></div> | |
| </div> | |
| </div> | |
| <!-- ββ RAG Document Manager modal ββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div id="doc-manager-modal" class="similar-modal hidden" role="dialog" aria-modal="true" aria-labelledby="doc-manager-title"> | |
| <div class="similar-modal-backdrop" onclick="closeDocManager()"></div> | |
| <div class="similar-modal-panel" style="width:min(900px,100%)"> | |
| <div class="similar-modal-head"> | |
| <h3 id="doc-manager-title">ποΈ RAG Document Manager</h3> | |
| <button type="button" class="similar-modal-close" onclick="closeDocManager()" aria-label="Close">Γ</button> | |
| </div> | |
| <div class="similar-modal-body"> | |
| <p class="similar-intro" id="doc-manager-intro"> | |
| All documents indexed under your tenant. Deleting removes the file, its database record, and every | |
| vector embedding for that document from the search index. This cannot be undone. | |
| </p> | |
| <div class="docmgr-toolbar"> | |
| <span id="docmgr-count" class="docmgr-count"></span> | |
| <button type="button" class="btn-similar" id="docmgr-refresh" onclick="loadDocManagerList()">β» Refresh</button> | |
| <button type="button" class="btn-similar" id="docmgr-reingest" onclick="reingestAllDocuments()" title="Re-process every document through the latest table-aware parser and chunker">β³ Re-ingest library</button> | |
| </div> | |
| <div id="doc-manager-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββ Delete confirmation modal βββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div id="doc-delete-modal" class="similar-modal hidden" role="alertdialog" aria-modal="true" aria-labelledby="doc-delete-title" style="z-index:9500"> | |
| <div class="similar-modal-backdrop" onclick="cancelDocDelete()"></div> | |
| <div class="similar-modal-panel" style="max-width:480px"> | |
| <div class="similar-modal-head" style="background:#fdecea;border-bottom:2px solid var(--danger)"> | |
| <h3 id="doc-delete-title" style="color:var(--danger)">Delete document?</h3> | |
| <button type="button" class="similar-modal-close" onclick="cancelDocDelete()" aria-label="Close">Γ</button> | |
| </div> | |
| <div class="similar-modal-body" id="doc-delete-body" style="padding:1.2rem"></div> | |
| </div> | |
| </div> | |
| <!-- ββ Tier mismatch warning modal ββββββββββββββββββββββββββββββββββββββββ --> | |
| <div id="tier-mismatch-modal" class="similar-modal hidden" role="alertdialog" aria-modal="true" aria-labelledby="tier-mismatch-title"> | |
| <div class="similar-modal-backdrop"></div> | |
| <div class="similar-modal-panel" style="max-width:520px"> | |
| <div id="tier-mismatch-head" class="similar-modal-head" style="background:#fff3cd;border-bottom:2px solid #e6a817"> | |
| <h3 id="tier-mismatch-title" style="color:#856404;margin:0;display:flex;align-items:center;gap:.5rem"> | |
| <span style="font-size:1.3rem">β οΈ</span> <span id="tier-mismatch-title-text">Survey Tier Mismatch Detected</span> | |
| </h3> | |
| </div> | |
| <div id="tier-mismatch-body" class="similar-modal-body" style="padding:1.2rem"></div> | |
| <div id="tier-mismatch-footer" style="padding:.8rem 1.2rem 1.2rem;display:flex;gap:.6rem;justify-content:flex-end;flex-wrap:wrap;border-top:2px solid #e6a817;background:#fff8e1"> | |
| <button type="button" class="btn btn-outline" id="tier-mismatch-cancel-btn">β Go back & fix tier</button> | |
| <button type="button" class="btn" id="tier-mismatch-proceed-btn" | |
| style="background:#dc3545;color:#fff;border-color:#dc3545"> | |
| Proceed with wrong tier anyway | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="survey-tier-modal" class="similar-modal hidden" role="dialog" aria-modal="true" aria-labelledby="survey-tier-modal-title"> | |
| <div class="similar-modal-backdrop" onclick="cancelSurveyTierModal()"></div> | |
| <div class="similar-modal-panel"> | |
| <div class="similar-modal-head"> | |
| <h3 id="survey-tier-modal-title">Confirm RICS survey tier(s)</h3> | |
| <button type="button" class="similar-modal-close" onclick="cancelSurveyTierModal()" aria-label="Close">Γ</button> | |
| </div> | |
| <div id="survey-tier-modal-body" class="similar-modal-body"></div> | |
| <div style="padding:0 1.2rem 1.2rem;display:flex;gap:.6rem;justify-content:flex-end;flex-wrap:wrap;border-top:1px solid var(--border);background:var(--card)"> | |
| <button type="button" class="btn btn-outline" onclick="cancelSurveyTierModal()">β Back to upload</button> | |
| <button type="button" class="btn btn-primary" id="survey-tier-confirm-btn">Confirm & continue</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Report Genius AI β Multi-Mode Frontend | |
| Modes: generate | proofread | enhance | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| const state = { | |
| tenantId: null, | |
| token: null, | |
| docId: null, | |
| docIds: [], // all accepted document UUIDs from last batch upload | |
| sessionDocIds: [], // every doc uploaded during THIS browser session (for "current" vs "previous" tagging) | |
| reportId: null, | |
| surveyLevel: 3, | |
| file: null, | |
| files: [], // File[] selected for upload | |
| sections: {}, // { code: { action:'ai'|'keep', aiTask:'generate'|'proofread'|'enhance', bullets:'' } } | |
| results: {}, // { code: SectionPayload } | |
| styleProfile: null, | |
| interferenceLevel: 'maximum', | |
| }; | |
| const AI_MODE_DESCRIPTIONS = { | |
| generate: 'Personalised content generation: your raw notes are automatically interpreted, expanded, and transformed into polished RICS report prose β matching your writing style. Works even with rough, abbreviated, or incomplete notes.', | |
| proofread: 'Proofreading: reviews the generated text for grammar, clarity, and style consistency with your detected writing voice.', | |
| enhance: 'Technical enhancement: expands the content with deeper technical insights using additional passages retrieved from your own uploaded documents (same tenant only).', | |
| }; | |
| /* ββ Default: RICS Level 3 Building Survey (replaced after upload from GET /templates/catalog) */ | |
| const STATIC_L3_SECTIONS = [ | |
| { code:'A1', group:'A', title:'Surveyor\'s details', hint:'surveyor name Β· RICS number Β· qualifications Β· company Β· contact' }, | |
| { code:'A2', group:'A', title:'Date of inspection', hint:'inspection date Β· time on site' }, | |
| { code:'A3', group:'A', title:'Related party disclosure', hint:'related party Β· conflict of interest' }, | |
| { code:'A4', group:'A', title:'Weather conditions', hint:'weather Β· dry Β· wet Β· overcast' }, | |
| { code:'A5', group:'A', title:'Status of the property at the time of inspection', hint:'occupied Β· vacant Β· furnished Β· access limitations' }, | |
| { code:'B1', group:'B', title:'Summary of condition ratings', hint:'condition ratings summary Β· Cat 1 Β· Cat 2 Β· Cat 3' }, | |
| { code:'B2', group:'B', title:'Overall opinion', hint:'overall opinion Β· key findings' }, | |
| { code:'B3', group:'B', title:'Further investigations', hint:'further investigations Β· specialist reports' }, | |
| { code:'C1', group:'C', title:'Type and construction', hint:'property type Β· construction Β· detached Β· cavity Β· timber frame' }, | |
| { code:'C2', group:'C', title:'Approximate year of construction', hint:'year built Β· approximate age Β· period' }, | |
| { code:'C3', group:'C', title:'Accommodation', hint:'accommodation Β· bedrooms Β· bathrooms Β· storeys Β· room matrix' }, | |
| { code:'C4', group:'C', title:'Energy efficiency', hint:'EPC Β· energy efficiency Β· double glazing' }, | |
| { code:'C5', group:'C', title:'Location and facilities', hint:'location Β· facilities Β· flood Β· radon Β· noise' }, | |
| { code:'D1', group:'D', title:'Chimney stacks', hint:'chimney stacks Β· pots Β· flaunching Β· flashings Β· lean Β· condition rating' }, | |
| { code:'D2', group:'D', title:'Roof coverings', hint:'roof covering Β· slate Β· tile Β· felt Β· slipped tiles Β· moss Β· condition rating' }, | |
| { code:'D3', group:'D', title:'Rainwater pipes and gutters', hint:'gutters Β· downpipes Β· rainwater Β· hopper Β· leakage Β· condition rating' }, | |
| { code:'D4', group:'D', title:'Main walls', hint:'main walls Β· cavity Β· render Β· DPC Β· cracking Β· condition rating' }, | |
| { code:'D5', group:'D', title:'Windows', hint:'windows Β· glazing Β· FENSA Β· frames Β· condition rating' }, | |
| { code:'D6', group:'D', title:'Outside doors (including patio doors)', hint:'outside doors Β· patio doors Β· entrance door Β· condition rating' }, | |
| { code:'D7', group:'D', title:'Conservatory and porches', hint:'conservatory Β· porch Β· glazing Β· condition rating' }, | |
| { code:'D8', group:'D', title:'Other joinery and finishes', hint:'fascias Β· soffits Β· external joinery Β· condition rating' }, | |
| { code:'D9', group:'D', title:'Other', hint:'other outside elements Β· balconies Β· external stairs' }, | |
| { code:'E1', group:'E', title:'Roof structure', hint:'roof structure Β· trusses Β· rafters Β· purlins Β· loft access Β· condition rating' }, | |
| { code:'E2', group:'E', title:'Ceilings', hint:'ceilings Β· plaster Β· artex Β· cracking Β· condition rating' }, | |
| { code:'E3', group:'E', title:'Walls and partitions', hint:'walls Β· partitions Β· damp Β· cracking Β· condition rating' }, | |
| { code:'E4', group:'E', title:'Floors', hint:'floors Β· suspended Β· solid Β· springy Β· condition rating' }, | |
| { code:'E5', group:'E', title:'Fireplaces, chimney breasts and flues', hint:'fireplaces Β· chimney breasts Β· flues Β· hearth Β· condition rating' }, | |
| { code:'E6', group:'E', title:'Built-in fittings', hint:'built-in kitchen Β· fitted wardrobes Β· cupboards Β· condition rating' }, | |
| { code:'E7', group:'E', title:'Woodwork', hint:'staircase Β· skirting Β· architrave Β· internal doors Β· condition rating' }, | |
| { code:'E8', group:'E', title:'Bathroom fittings', hint:'bathroom fittings Β· sanitaryware Β· shower Β· WC Β· condition rating' }, | |
| { code:'E9', group:'E', title:'Other', hint:'cellar Β· basement Β· other inside Β· condition rating' }, | |
| { code:'F1', group:'F', title:'Electricity', hint:'electricity Β· consumer unit Β· wiring Β· EICR Β· condition rating' }, | |
| { code:'F2', group:'F', title:'Gas and Oil', hint:'gas Β· oil Β· meter Β· pipework Β· Gas Safe Β· condition rating' }, | |
| { code:'F3', group:'F', title:'Water', hint:'water supply Β· stopcock Β· lead pipes Β· condition rating' }, | |
| { code:'F4', group:'F', title:'Heating', hint:'heating Β· boiler Β· radiators Β· controls Β· condition rating' }, | |
| { code:'F5', group:'F', title:'Water heating', hint:'water heating Β· cylinder Β· combi Β· immersion Β· condition rating' }, | |
| { code:'F6', group:'F', title:'Drainage', hint:'drainage Β· manholes Β· sewers Β· condition rating' }, | |
| { code:'F7', group:'F', title:'Common services', hint:'common services Β· shared utilities Β· flats only' }, | |
| { code:'G1', group:'G', title:'Garage', hint:'garage Β· car port Β· door Β· condition rating' }, | |
| { code:'G2', group:'G', title:'Permanent outbuildings', hint:'outbuildings Β· shed Β· workshop Β· condition rating' }, | |
| { code:'G3', group:'G', title:'Other', hint:'boundaries Β· garden Β· driveway Β· shared areas Β· condition rating' }, | |
| { code:'H1', group:'H', title:'Regulation', hint:'regulations Β· planning Β· listed building Β· conservation area' }, | |
| { code:'H2', group:'H', title:'Guarantees', hint:'guarantees Β· NHBC Β· FENSA Β· warranties' }, | |
| { code:'H3', group:'H', title:'Other matters', hint:'tenure Β· lease Β· easements Β· other legal matters' }, | |
| { code:'I1', group:'I', title:'Risks to the building', hint:'structural movement Β· dampness Β· timber defects Β· building risks' }, | |
| { code:'I2', group:'I', title:'Risks to the grounds', hint:'flood Β· radon Β· mining Β· knotweed Β· ground risks' }, | |
| { code:'I3', group:'I', title:'Risks to people', hint:'asbestos Β· fire safety Β· safety glass Β· people risks' }, | |
| { code:'I4', group:'I', title:'Other risks or hazards', hint:'other risks Β· contamination Β· hazards' }, | |
| { code:'J1', group:'J', title:'Insulation', hint:'insulation Β· loft Β· cavity Β· floor insulation' }, | |
| { code:'J2', group:'J', title:'Heating', hint:'heating efficiency Β· boiler Β· heat pump' }, | |
| { code:'J3', group:'J', title:'Lighting', hint:'lighting Β· LED Β· natural light' }, | |
| { code:'J4', group:'J', title:'Ventilation', hint:'ventilation Β· trickle vents Β· extract fans' }, | |
| { code:'J5', group:'J', title:'General', hint:'general energy Β· solar Β· EPC improvements' }, | |
| { code:'K1', group:'K', title:'Surveyor\'s declaration details', hint:'surveyor declaration Β· signature Β· RICS Β· qualifications' }, | |
| { code:'L1', group:'L', title:'Advice on getting quotes and next steps', hint:'quotations Β· next steps Β· recommended actions' }, | |
| { code:'M1', group:'M', title:'Terms and Service Description', hint:'terms of engagement Β· scope Β· fee Β· complaints' }, | |
| { code:'N1', group:'N', title:'Diagram reference', hint:'typical house diagram Β· illustration reference' }, | |
| ]; | |
| let RICS_SECTIONS = STATIC_L3_SECTIONS.slice(); | |
| let PHOTO_POLICY = {}; // section_code -> { policy, reason } | |
| let PHOTO_LIMITS = { max_photos_per_section: 5, max_photos_for_ai: 2 }; | |
| async function loadTemplateCatalog(surveyLevel) { | |
| const sl = (Number.isFinite(surveyLevel) && surveyLevel >= 1 && surveyLevel <= 3) | |
| ? Math.floor(Number(surveyLevel)) : 3; | |
| const cat = await apiFetch('GET', `/templates/catalog?survey_level=${encodeURIComponent(sl)}`, null); | |
| RICS_SECTIONS = (cat.sections || []).map(s => ({ | |
| code: s.code, | |
| group: s.group, | |
| title: s.title, | |
| hint: s.hint || '', | |
| })); | |
| RICS_GROUP_LABELS = { ...(cat.group_labels || {}) }; | |
| state.surveyLevel = cat.survey_level; | |
| } | |
| async function loadPhotoPolicy(reportId) { | |
| if (!reportId) return; | |
| const pol = await apiFetch('GET', `/reports/${encodeURIComponent(reportId)}/photo-policy`, null); | |
| PHOTO_POLICY = {}; | |
| (pol.sections || []).forEach(s => { PHOTO_POLICY[s.code] = { policy: s.policy, reason: s.reason }; }); | |
| if (pol.photo_limits) { | |
| PHOTO_LIMITS = { | |
| max_photos_per_section: pol.photo_limits.max_photos_per_section ?? 5, | |
| max_photos_for_ai: pol.photo_limits.max_photos_for_ai ?? 2, | |
| }; | |
| } | |
| } | |
| const $ = id => document.getElementById(id); | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| function showStep(n) { | |
| ['step-upload','step-processing','step-configure','step-generating','step-results'] | |
| .forEach((id, i) => $(id).style.display = (i === n-1) ? 'block' : 'none'); | |
| for (let i = 1; i <= 5; i++) { | |
| const el = $(`si-${i}`); | |
| el.classList.toggle('active', i === n); | |
| el.classList.toggle('done', i < n); | |
| } | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| function showAlert(id, type, msg) { | |
| const el = $(id); | |
| el.className = `alert alert-${type}`; | |
| el.innerHTML = msg; | |
| el.classList.remove('hidden'); | |
| } | |
| function hideAlert(id) { $(id).classList.add('hidden'); } | |
| function fmtSize(b) { | |
| if (b < 1024) return b + ' B'; | |
| if (b < 1048576) return (b/1024).toFixed(1) + ' KB'; | |
| return (b/1048576).toFixed(1) + ' MB'; | |
| } | |
| // ββ API base URL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // FastAPI serves this HTML file directly, so the browser's current origin IS | |
| // the API server β same-origin requests work on localhost:8000, HF Spaces | |
| // (https://...hf.space), and any other host without configuration. | |
| // Override via ?api=https://... query param or localStorage key "API_BASE" | |
| // only when running the frontend from a separate dev server (e.g. :5173). | |
| const API_BASE_DEFAULT = location.origin; | |
| function getApiBase() { | |
| const qp = new URLSearchParams(location.search); | |
| const fromQuery = (qp.get('api') || '').trim(); | |
| if (fromQuery) return fromQuery.replace(/\/+$/, ''); | |
| const fromStorage = (localStorage.getItem('API_BASE') || '').trim(); | |
| if (fromStorage) return fromStorage.replace(/\/+$/, ''); | |
| return API_BASE_DEFAULT; | |
| } | |
| const API_BASE = getApiBase(); | |
| function apiUrl(path) { | |
| return new URL(path, API_BASE).toString(); | |
| } | |
| /** Auth headers for every API call (Bearer required in production). */ | |
| function buildAuthHeaders(extra = {}) { | |
| const h = { ...extra }; | |
| if (state.token) h['Authorization'] = `Bearer ${state.token}`; | |
| if (state.tenantId) h['X-Tenant-ID'] = state.tenantId; // dev fallback when DEV_MODE=true | |
| return h; | |
| } | |
| async function apiFetch(method, path, body, headers = {}, options = {}) { | |
| const baseHeaders = buildAuthHeaders(headers); | |
| const opts = { method, headers: baseHeaders }; | |
| if (body instanceof FormData) { opts.body = body; } | |
| else if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } | |
| // Optional AbortSignal β used by _saveSectionEdit to cancel an in-flight save | |
| // when the user resumes typing before the previous request resolves. Other | |
| // callers can ignore this; behaviour is unchanged when signal is omitted. | |
| if (options && options.signal) opts.signal = options.signal; | |
| const res = await fetch(apiUrl(path), opts); | |
| if (res.status === 401 && !(options && options.noAuthRedirect)) { | |
| handleAuthExpired(); | |
| throw new Error('Your session expired. Please log in again.'); | |
| } | |
| if (res.status === 204) { | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| return null; | |
| } | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) { | |
| const errId = data && data.error_id ? ` (error_id: ${data.error_id})` : ''; | |
| const detail = (data && data.detail != null) ? data.detail : null; | |
| let msg = `HTTP ${res.status}`; | |
| if (typeof detail === 'string' && detail.trim()) msg = detail.trim(); | |
| else if (detail && typeof detail === 'object') { | |
| // FastAPI can return dict details (we use this for tier mismatch guardrails). | |
| if (typeof detail.message === 'string' && detail.message.trim()) { | |
| msg = detail.message.trim(); | |
| if (typeof detail.rationale === 'string' && detail.rationale.trim()) { | |
| msg += `\n\nWhy: ${detail.rationale.trim()}`; | |
| } | |
| if (typeof detail.hint === 'string' && detail.hint.trim()) { | |
| msg += `\n\nNext: ${detail.hint.trim()}`; | |
| } | |
| } else { | |
| try { msg = JSON.stringify(detail, null, 2); } catch { /* ignore */ } | |
| } | |
| } | |
| const err = new Error(msg + errId); | |
| // Attach raw response for callers that want structured handling. | |
| err.data = data; // eslint-disable-line no-param-reassign | |
| throw err; | |
| } | |
| return data; | |
| } | |
| /* ββ Authentication (must run before protected API calls) βββββββββββββββ */ | |
| const AUTH_KEY = 'rics_auth'; | |
| let _authMode = 'login'; | |
| function setAuthMode(mode) { | |
| _authMode = mode; | |
| $('auth-tab-login').classList.toggle('active', mode === 'login'); | |
| $('auth-tab-register').classList.toggle('active', mode === 'register'); | |
| $('auth-submit-btn').textContent = mode === 'register' ? 'Create account & sign in' : 'Log in'; | |
| $('auth-pass').setAttribute('autocomplete', mode === 'register' ? 'new-password' : 'current-password'); | |
| $('auth-pass-hint').textContent = mode === 'register' | |
| ? 'At least 8 characters. There is no recovery β store it safely.' | |
| : 'Enter the passphrase you registered with this Tenant/User ID.'; | |
| hideAlert('auth-alert'); | |
| } | |
| function showAuthOverlay() { | |
| const el = $('auth-overlay'); | |
| if (el) { | |
| el.classList.remove('hidden-gate'); | |
| el.setAttribute('aria-hidden', 'false'); | |
| } | |
| document.body.classList.add('auth-gate'); | |
| setTimeout(() => { const t = $('auth-tenant'); if (t) t.focus(); }, 50); | |
| } | |
| function hideAuthOverlay() { | |
| const el = $('auth-overlay'); | |
| if (el) { | |
| el.classList.add('hidden-gate'); | |
| el.setAttribute('aria-hidden', 'true'); | |
| } | |
| document.body.classList.remove('auth-gate'); | |
| } | |
| function _persistAuth(sess) { | |
| localStorage.setItem(AUTH_KEY, JSON.stringify(sess)); | |
| } | |
| function applyAuthSession(sess) { | |
| state.token = sess.access_token; | |
| state.tenantId = sess.tenant_id; | |
| _persistAuth(sess); | |
| $('tenant-badge').textContent = `π ${sess.tenant_id}`; | |
| $('tenant-badge').classList.add('visible'); | |
| $('btn-logout').style.display = ''; | |
| const ti = $('tenant-input'); | |
| if (ti) { ti.value = sess.tenant_id; ti.readOnly = true; ti.title = 'Logged in as this tenant'; } | |
| hideAuthOverlay(); | |
| } | |
| async function submitAuth(event) { | |
| event.preventDefault(); | |
| const tenant = $('auth-tenant').value.trim(); | |
| const pass = $('auth-pass').value; | |
| if (!tenant || !pass) { showAlert('auth-alert', 'warn', 'Enter both a Tenant/User ID and a passphrase.'); return; } | |
| const btn = $('auth-submit-btn'); | |
| btn.disabled = true; | |
| const original = btn.textContent; | |
| btn.textContent = _authMode === 'register' ? 'Creating accountβ¦' : 'Signing inβ¦'; | |
| try { | |
| const path = _authMode === 'register' ? '/auth/register' : '/auth/login'; | |
| const res = await apiFetch('POST', path, { tenant_id: tenant, passphrase: pass }, {}, { noAuthRedirect: true }); | |
| applyAuthSession(res); | |
| $('auth-pass').value = ''; | |
| showToast(_authMode === 'register' ? 'Account created β welcome.' : 'Signed in.', 'ok'); | |
| } catch (err) { | |
| let msg = err.message || 'Authentication failed.'; | |
| if (err.data && Array.isArray(err.data.detail)) { | |
| msg = err.data.detail.map(d => d.msg || JSON.stringify(d)).join(' '); | |
| } | |
| showAlert('auth-alert', 'error', escapeHtml(msg)); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = original; | |
| } | |
| } | |
| function logout() { | |
| localStorage.removeItem(AUTH_KEY); | |
| state.token = null; | |
| state.tenantId = null; | |
| $('btn-logout').style.display = 'none'; | |
| $('tenant-badge').classList.remove('visible'); | |
| if (typeof closeDocManager === 'function') closeDocManager(); | |
| const ti = $('tenant-input'); | |
| if (ti) { ti.readOnly = false; ti.value = ''; ti.title = ''; } | |
| showAuthOverlay(); | |
| showToast('Signed out.', 'ok'); | |
| } | |
| function handleAuthExpired() { | |
| localStorage.removeItem(AUTH_KEY); | |
| state.token = null; | |
| state.tenantId = null; | |
| $('btn-logout').style.display = 'none'; | |
| $('tenant-badge').classList.remove('visible'); | |
| const ti = $('tenant-input'); | |
| if (ti) { ti.readOnly = false; ti.value = ''; ti.title = ''; } | |
| showAuthOverlay(); | |
| showAlert('auth-alert', 'warn', 'Your session expired. Please log in again.'); | |
| } | |
| async function bootstrapAuth() { | |
| setAuthMode('login'); | |
| let sess = null; | |
| try { sess = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch { sess = null; } | |
| const stillValid = sess && sess.access_token && (!sess.expires_at || sess.expires_at * 1000 > Date.now() + 30000); | |
| if (!stillValid) { showAuthOverlay(); return; } | |
| // Restore UI immediately; verify token in the background (avoids blocking | |
| // the login gate on an extra round-trip every page load). | |
| applyAuthSession(sess); | |
| apiFetch('GET', '/auth/me', null, {}, { noAuthRedirect: true }) | |
| .then(me => { | |
| if (me && me.tenant_id) applyAuthSession({ ...sess, tenant_id: me.tenant_id }); | |
| }) | |
| .catch(() => handleAuthExpired()); | |
| } | |
| bootstrapAuth(); | |
| /* ββ Tier mismatch modal βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| let _mismatchResolve = null; | |
| let _mismatchReject = null; | |
| function _openTierMismatchModal(errData) { | |
| return new Promise((resolve, reject) => { | |
| _mismatchResolve = resolve; | |
| _mismatchReject = reject; | |
| const d = (errData && errData.detail) ? errData.detail : {}; | |
| const predicted = d.predicted_survey_level || '?'; | |
| const requested = d.requested_survey_level || '?'; | |
| const confVal = d.confidence != null ? d.confidence : null; | |
| const confPct = confVal != null ? Math.round(confVal * 100) : null; | |
| const confStr = confPct != null ? `${confPct}%` : 'unknown'; | |
| const rationale = d.rationale || ''; | |
| const isLowConf = confVal != null && confVal < 0.45; | |
| const levelName = { 1: 'Level 1 β Condition Report', 2: 'Level 2 β HomeBuyer', 3: 'Level 3 β Building Survey' }; | |
| // Tone depends on confidence: low-confidence = advisory note, high-confidence = strong warning. | |
| const headerNote = isLowConf | |
| ? `The classifier has a <strong>weak signal</strong> (${confStr} confidence) suggesting the document | |
| may lean towards ${levelName[predicted] || `Level ${predicted}`}, but it is <strong>not certain</strong>. | |
| If you know the correct tier, you can proceed safely.` | |
| : `The system analysed the uploaded document and found that its content | |
| <strong>does not match the survey tier you selected</strong> (${confStr} confidence). | |
| Generating with the wrong tier will produce an incorrectly structured report.`; | |
| const actionNote = isLowConf | |
| ? `<p style="margin:0;font-size:.82rem;background:#e8f4fd;border:1px solid #7bc8f6;border-radius:6px;padding:.55rem .75rem"> | |
| <strong>Low confidence:</strong> the classifier is uncertain. If you are confident this is | |
| <strong>${levelName[requested] || `Level ${requested}`}</strong>, click <strong>Proceed</strong>. | |
| If you think it may actually be <strong>${levelName[predicted] || `Level ${predicted}`}</strong>, | |
| go back and select that tier instead. | |
| </p>` | |
| : `<p style="margin:0;font-size:.82rem;color:#721c24;background:#fff3cd;border:1px solid #e6a817;border-radius:6px;padding:.55rem .75rem"> | |
| <strong>Recommended action:</strong> click <strong>β Go back & fix tier</strong> and re-upload | |
| selecting <strong>${levelName[predicted] || `Level ${predicted}`}</strong> from the dropdown. | |
| </p>`; | |
| $('tier-mismatch-body').innerHTML = ` | |
| <p style="margin:0 0 .9rem;font-size:.92rem">${headerNote}</p> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:.6rem;margin-bottom:.9rem"> | |
| <div style="border:2px solid #28a745;border-radius:8px;padding:.65rem .8rem;background:#f0fff4"> | |
| <div style="font-size:.72rem;color:#155724;font-weight:700;text-transform:uppercase;letter-spacing:.04em">Document detected as</div> | |
| <div style="font-size:.95rem;font-weight:700;color:#155724;margin-top:.25rem">${levelName[predicted] || `Level ${predicted}`}</div> | |
| <div style="font-size:.75rem;color:#155724;margin-top:.15rem">Confidence: ${confStr}${isLowConf ? ' β low' : ''}</div> | |
| </div> | |
| <div style="border:2px solid #dc3545;border-radius:8px;padding:.65rem .8rem;background:#fff5f5"> | |
| <div style="font-size:.72rem;color:#721c24;font-weight:700;text-transform:uppercase;letter-spacing:.04em">Tier you selected</div> | |
| <div style="font-size:.95rem;font-weight:700;color:#721c24;margin-top:.25rem">${levelName[requested] || `Level ${requested}`}</div> | |
| <div style="font-size:.75rem;color:#721c24;margin-top:.15rem">β Does not match classifier</div> | |
| </div> | |
| </div> | |
| ${rationale ? `<p style="font-size:.8rem;color:var(--muted);border-left:3px solid var(--border);padding-left:.7rem;margin:0 0 .8rem"><em>${escapeHtml(rationale)}</em></p>` : ''} | |
| ${actionNote}`; | |
| // Adjust header and footer colour based on confidence level. | |
| if (isLowConf) { | |
| $('tier-mismatch-head').style.background = '#e8f4fd'; | |
| $('tier-mismatch-head').style.borderBottomColor = '#7bc8f6'; | |
| $('tier-mismatch-title').style.color = '#0c5460'; | |
| $('tier-mismatch-title-text').textContent = 'Survey Tier Uncertainty'; | |
| $('tier-mismatch-footer').style.background = '#f0f8ff'; | |
| $('tier-mismatch-footer').style.borderTopColor = '#7bc8f6'; | |
| $('tier-mismatch-proceed-btn').style.background = '#0069d9'; | |
| $('tier-mismatch-proceed-btn').style.borderColor = '#0062cc'; | |
| $('tier-mismatch-proceed-btn').textContent = 'Yes, my tier is correct β proceed'; | |
| } else { | |
| $('tier-mismatch-head').style.background = '#fff3cd'; | |
| $('tier-mismatch-head').style.borderBottomColor = '#e6a817'; | |
| $('tier-mismatch-title').style.color = '#856404'; | |
| $('tier-mismatch-title-text').textContent = 'Survey Tier Mismatch Detected'; | |
| $('tier-mismatch-footer').style.background = '#fff8e1'; | |
| $('tier-mismatch-footer').style.borderTopColor = '#e6a817'; | |
| $('tier-mismatch-proceed-btn').style.background = '#dc3545'; | |
| $('tier-mismatch-proceed-btn').style.borderColor = '#dc3545'; | |
| $('tier-mismatch-proceed-btn').textContent = 'Proceed with wrong tier anyway'; | |
| } | |
| $('tier-mismatch-modal').classList.remove('hidden'); | |
| }); | |
| } | |
| $('tier-mismatch-cancel-btn').onclick = () => { | |
| $('tier-mismatch-modal').classList.add('hidden'); | |
| if (_mismatchReject) { _mismatchReject(new Error('cancelled')); _mismatchReject = null; _mismatchResolve = null; } | |
| }; | |
| $('tier-mismatch-proceed-btn').onclick = () => { | |
| $('tier-mismatch-modal').classList.add('hidden'); | |
| if (_mismatchResolve) { _mismatchResolve(true); _mismatchResolve = null; _mismatchReject = null; } | |
| }; | |
| let _tierModalResolve = null; | |
| let _tierModalReject = null; | |
| function cancelSurveyTierModal() { | |
| const m = $('survey-tier-modal'); | |
| if (!m.classList.contains('hidden')) { | |
| m.classList.add('hidden'); | |
| if (_tierModalReject) { | |
| const rj = _tierModalReject; | |
| _tierModalReject = null; | |
| _tierModalResolve = null; | |
| rj(new Error('cancelled')); | |
| } | |
| } | |
| } | |
| function _primaryTierFromModalBody(body, primaryDocId) { | |
| const rows = body.querySelectorAll('.tier-row'); | |
| for (const row of rows) { | |
| if (row.getAttribute('data-doc-id') === primaryDocId) { | |
| const sel = row.querySelector('.tier-row-select'); | |
| if (sel) return parseInt(sel.value, 10); | |
| } | |
| } | |
| const first = body.querySelector('.tier-row-select'); | |
| return first ? parseInt(first.value, 10) : 3; | |
| } | |
| function openSurveyTierConfirmModal(cl, primaryDocId) { | |
| return new Promise((resolve, reject) => { | |
| _tierModalResolve = resolve; | |
| _tierModalReject = reject; | |
| const body = $('survey-tier-modal-body'); | |
| let html = '<p style="font-size:.88rem;margin-top:0">You left the batch tier unset. The server scored each file using wording plus your local exemplar folders (<code>knowledge_base_dirs</code>). Adjust any row, then confirm β tiers are saved only when you click <strong>Confirm & continue</strong>.</p>'; | |
| if (Number(cl.corpus_labelled_files || 0) === 0) { | |
| html += '<p class="alert alert-info" style="font-size:.82rem;margin-top:.6rem">No labelled exemplar corpus was detected β suggestions use document wording only. Override manually if unsure.</p>'; | |
| } | |
| html += '<div style="margin-top:.8rem;display:flex;flex-direction:column;gap:.5rem">'; | |
| (cl.items || []).forEach(it => { | |
| const id = escapeHtml(it.document_id || ''); | |
| const esc = escapeHtml(it.filename || 'upload'); | |
| const rationale = escapeHtml(it.rationale || ''); | |
| const conf = escapeHtml(String(it.confidence != null ? it.confidence : '')); | |
| const p = it.predicted_survey_level; | |
| html += `<div class="tier-row" data-doc-id="${id}" style="border:1px solid var(--border);border-radius:8px;padding:.65rem .75rem">`; | |
| html += `<div style="font-weight:700;font-size:.85rem">${esc}</div>`; | |
| html += `<div style="font-size:.74rem;color:var(--muted);margin:.25rem 0">${rationale} <span title="score separation">(confidence ${conf})</span></div>`; | |
| html += '<label style="font-size:.8rem;display:flex;align-items:center;gap:.5rem;margin-top:.35rem;flex-wrap:wrap"><span>Survey tier</span>'; | |
| html += '<select class="tier-row-select" style="padding:.35rem .5rem;border-radius:6px;border:1px solid var(--border);min-width:220px">'; | |
| if (p === 0) { | |
| html += '<option value="" selected disabled style="color:#888">β Select tier (unknown) β</option>'; | |
| } | |
| html += `<option value="1" ${p === 1 ? 'selected' : ''}>Level 1 β Condition Report</option>`; | |
| html += `<option value="2" ${p === 2 ? 'selected' : ''}>Level 2 β HomeBuyer</option>`; | |
| html += `<option value="3" ${p === 3 ? 'selected' : ''}>Level 3 β Building Survey</option>`; | |
| html += '</select></label></div>'; | |
| }); | |
| html += '</div>'; | |
| body.innerHTML = html; | |
| $('survey-tier-confirm-btn').onclick = async () => { | |
| try { | |
| const rows = Array.from(body.querySelectorAll('.tier-row')); | |
| const unset = rows.filter(r => !r.querySelector('.tier-row-select').value); | |
| if (unset.length) { | |
| showAlert('upload-alert', 'warn', | |
| `Please select a survey tier for ${unset.length} file(s) before continuing.`); | |
| return; | |
| } | |
| const items = rows.map(row => ({ | |
| document_id: row.getAttribute('data-doc-id'), | |
| survey_level: parseInt(row.querySelector('.tier-row-select').value, 10), | |
| })); | |
| await apiFetch('POST', '/documents/survey-level/apply', { items }); | |
| const primaryTier = _primaryTierFromModalBody(body, primaryDocId); | |
| $('survey-tier-modal').classList.add('hidden'); | |
| _tierModalReject = null; | |
| _tierModalResolve = null; | |
| resolve(primaryTier); | |
| } catch (e) { | |
| if (_tierModalReject) _tierModalReject(e); | |
| _tierModalReject = null; | |
| _tierModalResolve = null; | |
| } | |
| }; | |
| $('survey-tier-modal').classList.remove('hidden'); | |
| }); | |
| } | |
| /* ββ STEP 1: Upload βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| const zone = $('upload-zone'); | |
| const fileInput = $('file-input'); | |
| zone.addEventListener('click', () => fileInput.click()); | |
| zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); | |
| zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); | |
| zone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| zone.classList.remove('drag-over'); | |
| const dt = e.dataTransfer.files; | |
| if (dt && dt.length) setFiles(dt); | |
| }); | |
| fileInput.addEventListener('change', () => { if (fileInput.files && fileInput.files.length) setFiles(fileInput.files); }); | |
| function setFiles(fileList) { | |
| const allowed = new Set(['docx', 'pdf', 'zip']); | |
| const arr = Array.from(fileList); | |
| const bad = arr.filter(f => !allowed.has((f.name.split('.').pop() || '').toLowerCase())); | |
| if (bad.length) { | |
| showAlert('upload-alert', 'warn', 'β οΈ Only <b>.docx</b>, <b>.pdf</b>, and <b>.zip</b> are supported.'); | |
| return; | |
| } | |
| if (!arr.length) return; | |
| hideAlert('upload-alert'); | |
| state.files = arr; | |
| state.file = arr[0]; | |
| const totalBytes = arr.reduce((s, f) => s + f.size, 0); | |
| $('file-name-text').textContent = arr.length === 1 ? arr[0].name : `${arr.length} files selected`; | |
| $('file-size-text').textContent = ` (${fmtSize(totalBytes)} total)`; | |
| $('file-display').classList.remove('hidden'); | |
| zone.classList.add('has-file'); | |
| $('btn-upload').disabled = false; | |
| } | |
| $('btn-upload').addEventListener('click', handleUpload); | |
| async function handleUpload() { | |
| if (!state.files.length) { showAlert('upload-alert', 'warn', 'Please choose at least one file.'); return; } | |
| if (!state.token || !state.tenantId) { showAuthOverlay(); return; } | |
| const tid = state.tenantId; // identity comes from the verified token, not the input | |
| $('tenant-badge').textContent = `π ${tid}`; | |
| $('tenant-badge').classList.add('visible'); | |
| showStep(2); | |
| $('proc-title').textContent = 'Uploading reference libraryβ¦'; | |
| $('proc-detail').textContent = 'Saving files to the database (one row per document)'; | |
| $('proc-pill').className = 'status-pill pill-processing'; | |
| $('proc-pill').textContent = 'β« Uploading'; | |
| try { | |
| const fd = new FormData(); | |
| state.files.forEach(f => fd.append('files', f, f.name)); | |
| fd.append('tenant_id', state.tenantId); | |
| const explicitTier = ($('survey-level-input') && $('survey-level-input').value) || ''; | |
| if (explicitTier) fd.append('survey_level', explicitTier); | |
| const up = await apiFetch('POST', '/upload/batch', fd); | |
| if (up.accepted === 0) { | |
| const msg = (up.items && up.items[0] && up.items[0].message) || up.message || 'No files accepted'; | |
| throw new Error(msg); | |
| } | |
| state.docIds = up.items.filter(it => it.document_id).map(it => it.document_id); | |
| if (!state.docIds.length) throw new Error('Batch upload returned no document IDs'); | |
| state.sessionDocIds = Array.from(new Set([...(state.sessionDocIds || []), ...state.docIds])); | |
| $('proc-title').textContent = 'Indexing reference documentsβ¦'; | |
| $('proc-detail').textContent = `Queued ${up.accepted} file(s) for parsing & embedding${up.rejected ? ` (${up.rejected} rejected)` : ''}`; | |
| $('proc-pill').textContent = 'βοΈ Processing'; | |
| const maxPolls = 7200; | |
| let lastSummary = null; | |
| for (let i = 0; i < maxPolls; i++) { | |
| await sleep(2000); | |
| lastSummary = await apiFetch('POST', '/documents/batch-status', { document_ids: state.docIds }); | |
| const busy = lastSummary.pending + lastSummary.processing; | |
| const total = state.docIds.length; | |
| const done = lastSummary.complete + lastSummary.failed; | |
| $('proc-detail').textContent = | |
| `Indexing: ${lastSummary.complete} complete, ${lastSummary.failed} failed, ${busy} in progress (${done}/${total})`; | |
| if (busy === 0) break; | |
| } | |
| if (!lastSummary || lastSummary.pending + lastSummary.processing > 0) { | |
| throw new Error('Indexing still running β open the app later or increase server timeout. Partial results may exist.'); | |
| } | |
| const firstOk = lastSummary.items.find(x => x.status === 'complete'); | |
| if (!firstOk) { | |
| const bad = lastSummary.items.filter(x => x.status === 'failed' || x.status === 'not_found'); | |
| const hints = bad.map(x => (x.filename ? `${x.filename}: ` : '') + (x.error || x.status)).filter(Boolean); | |
| const suffix = hints.length ? ` Details: ${hints.slice(0, 4).join(' Β· ')}` : ' Check file formats and server logs.'; | |
| throw new Error('No documents finished indexing successfully.' + suffix); | |
| } | |
| state.docId = firstOk.document_id; | |
| const summary = await apiFetch('GET', '/documents/tenant-chunk-summary', null); | |
| if (!summary.indexed_chunk_count) { | |
| throw new Error( | |
| 'Documents were saved but no text was indexed for AI retrieval. ' + | |
| 'Wait for indexing to finish or re-upload; check GET /health (faiss_vector_count).' | |
| ); | |
| } | |
| let reportSurveyLevel; | |
| if (explicitTier) { | |
| reportSurveyLevel = parseInt(explicitTier, 10); | |
| } else { | |
| $('proc-title').textContent = 'Suggesting RICS survey tiersβ¦'; | |
| $('proc-detail').textContent = 'Review and confirm each file in the dialog (no tier is saved until you confirm).'; | |
| const cl = await apiFetch('POST', '/documents/survey-level/classify', { | |
| document_ids: state.docIds, | |
| force_refresh_corpus: false, | |
| }); | |
| reportSurveyLevel = await openSurveyTierConfirmModal(cl, firstOk.document_id); | |
| } | |
| const tierQ = `&survey_level=${encodeURIComponent(String(reportSurveyLevel))}`; | |
| let rep; | |
| try { | |
| rep = await apiFetch('POST', `/reports?document_id=${encodeURIComponent(state.docId)}${tierQ}`, null); | |
| } catch (e) { | |
| // Server-side guardrail: if the primary document is classified as a different tier, | |
| // show a full-screen warning modal β not a dismiss-able browser confirm() dialog. | |
| const msg = (e && e.message) ? String(e.message) : ''; | |
| const low = msg.toLowerCase(); | |
| if ( | |
| low.includes('tier mismatch') || | |
| low.includes('tier not verified') || | |
| low.includes('primary document looks like rics level') || | |
| low.includes('could not confidently verify') | |
| ) { | |
| const confirmUrl = | |
| `/reports?document_id=${encodeURIComponent(state.docId)}${tierQ}&confirm_tier_mismatch=true`; | |
| try { | |
| // _openTierMismatchModal resolves if user clicks "proceed", rejects if cancelled. | |
| await _openTierMismatchModal(e.data || {}); | |
| } catch { | |
| // User clicked back β abort and let them fix the tier selection. | |
| showStep(1); | |
| showAlert('upload-alert', 'warn', | |
| 'Upload cancelled. Please re-upload and select the correct survey tier from the dropdown.'); | |
| return; | |
| } | |
| rep = await apiFetch('POST', confirmUrl, null); | |
| } else { | |
| throw e; | |
| } | |
| } | |
| state.reportId = rep.report_id; | |
| await loadTemplateCatalog(reportSurveyLevel); | |
| await loadPhotoPolicy(state.reportId); | |
| const label = state.files.length === 1 ? (state.files[0].name) : `${up.accepted} reference document(s)`; | |
| $('doc-fname').textContent = label; | |
| $('doc-stats').textContent = | |
| `β ${up.accepted} stored in DB β ${summary.indexed_chunk_count} chunks indexed for AI retrieval` + | |
| (up.rejected ? ` (${up.rejected} rejected in upload)` : ''); | |
| buildSectionCards(); | |
| showStep(3); | |
| } catch (err) { | |
| if (err && err.message === 'cancelled') { | |
| $('proc-spinner').style.display = ''; | |
| $('proc-pill').className = 'status-pill pill-pending'; | |
| $('proc-pill').textContent = 'β³ Pending'; | |
| showStep(1); | |
| showAlert('upload-alert', 'warn', 'Tier confirmation was cancelled. Pick a batch tier on the form, or leave it unset to confirm per-file suggestions after upload.'); | |
| return; | |
| } | |
| $('proc-spinner').style.display = 'none'; | |
| $('proc-pill').className = 'status-pill pill-failed'; | |
| $('proc-pill').textContent = 'β Failed'; | |
| $('proc-title').textContent = 'Something went wrong'; | |
| $('proc-detail').textContent = err.message || String(err); | |
| } | |
| } | |
| /* ββ Group label map (overwritten by /templates/catalog to match survey tier) β */ | |
| const STATIC_L3_GROUP_LABELS = { | |
| A:'A β About the inspection', | |
| B:'B β Overall opinion and summary of ratings', | |
| C:'C β About the property', | |
| D:'D β Outside the property', | |
| E:'E β Inside the property', | |
| F:'F β Services', | |
| G:'G β Grounds (including shared areas for flats)', | |
| H:'H β Issues for your legal advisers', | |
| I:'I β Risks', | |
| J:'J β Energy matters', | |
| K:"K β Surveyor's declaration", | |
| L:'L β What to do now', | |
| M:'M β Description of the RICS Home Survey - Level 3 service and terms of engagement', | |
| N:'N β Typical house diagram', | |
| }; | |
| let RICS_GROUP_LABELS = { ...STATIC_L3_GROUP_LABELS }; | |
| /* ββ STEP 3: Section Config ββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function buildSectionCards() { | |
| const container = $('section-cards'); | |
| container.innerHTML = ''; | |
| let lastGroup = null; | |
| RICS_SECTIONS.forEach(sec => { | |
| state.sections[sec.code] = { action: 'ai', aiTask: 'generate', bullets: '' }; | |
| // Insert group header when the group changes | |
| if (sec.group !== lastGroup) { | |
| lastGroup = sec.group; | |
| const hdr = document.createElement('div'); | |
| hdr.className = 'section-group-header'; | |
| hdr.innerHTML = `<span class="grp-badge">${sec.group}</span><span class="grp-label">${RICS_GROUP_LABELS[sec.group] || sec.group}</span><span class="grp-line"></span>`; | |
| container.appendChild(hdr); | |
| } | |
| const card = document.createElement('div'); | |
| card.className = 'section-card active'; | |
| card.id = `sc-${sec.code}`; | |
| card.innerHTML = ` | |
| <div class="section-head" onclick="toggleCardOpen('${sec.code}')"> | |
| <span class="section-code" id="scode-${sec.code}">${sec.code}</span> | |
| <div class="section-head-text"> | |
| <strong>${sec.title}</strong> | |
| <p id="shead-desc-${sec.code}">βοΈ Generate Content β expand to configure</p> | |
| </div> | |
| <div class="toggle-wrap"> | |
| <span class="toggle-label" id="tl-${sec.code}">AI Active</span> | |
| <input type="checkbox" class="toggle" id="tog-${sec.code}" checked | |
| onclick="event.stopPropagation(); toggleAction('${sec.code}')" /> | |
| </div> | |
| </div> | |
| <div class="section-body" id="sb-${sec.code}"> | |
| <div class="keep-msg" id="keep-msg-${sec.code}"> | |
| π This section will be left blank for manual writing | |
| </div> | |
| <div id="gen-area-${sec.code}"> | |
| <div id="photo-area-${sec.code}"></div> | |
| <div class="ai-mode-panel"> | |
| <div class="ai-mode-label">AI Mode</div> | |
| <div class="ai-mode-tabs" id="tabs-${sec.code}"> | |
| <button type="button" class="ai-mode-tab active" data-task="generate" onclick="event.preventDefault(); setAiTask('${sec.code}','generate')">βοΈ Generate Content</button> | |
| <button type="button" class="ai-mode-tab" data-task="proofread" onclick="event.preventDefault(); setAiTask('${sec.code}','proofread')">π Proofread</button> | |
| <button type="button" class="ai-mode-tab" data-task="enhance" onclick="event.preventDefault(); setAiTask('${sec.code}','enhance')">βοΈ Enhance Technical Depth</button> | |
| </div> | |
| <div class="ai-mode-desc" id="mode-desc-${sec.code}">${AI_MODE_DESCRIPTIONS.generate}</div> | |
| </div> | |
| <div class="bullets-toolbar"> | |
| <label style="font-size:.82rem;font-weight:600;color:var(--navy);margin:0"> | |
| Inspection Notes <span style="font-weight:400;color:var(--muted)">(one per line β rough shorthand is fine)</span> | |
| </label> | |
| <button type="button" class="btn-similar" title="Compare this text to your uploads and other sections" onclick="event.preventDefault(); runSimilarityCheck('${sec.code}')">π Check similar</button> | |
| </div> | |
| <textarea class="bullets-area" id="bullets-${sec.code}" | |
| placeholder="Write rough notes β the AI will interpret and expand them:\ne.g. main roof hip slate some slipped tiles eaves gutters blocked\ne.g. solid brick walls 275mm DPC visible slight crack NW corner\ne.g. gas CH Vaillant combi boiler 2015 last serviced 2022" | |
| oninput="saveBullets('${sec.code}')" | |
| ></textarea> | |
| <div class="bullets-hint">π‘ Suggested detail: <em>${sec.hint}</em></div> | |
| </div> | |
| </div> | |
| `; | |
| container.appendChild(card); | |
| renderPhotoArea(sec.code); | |
| // Auto-open the first few key sections | |
| if (['D','E2','E4'].includes(sec.code)) toggleCardOpen(sec.code); | |
| }); | |
| updateGenCount(); | |
| } | |
| function renderPhotoArea(code) { | |
| const host = $(`photo-area-${code}`); | |
| if (!host) return; | |
| // Always display the upload control to avoid "feature disappears" UX. | |
| // Photo policy is guidance only (required/optional/not needed) and should not hide the tool. | |
| const p = PHOTO_POLICY[code] || { policy: 'NO_IMAGE_NEEDED', reason: 'No photo guidance available yet.' }; | |
| const pol = (p.policy || 'NO_IMAGE_NEEDED').toUpperCase(); | |
| const isReq = pol === 'REQUIRES_IMAGE'; | |
| const label = isReq ? 'π· Photos recommended' : (pol === 'OPTIONAL_IMAGE' ? 'π· Photos (optional)' : 'π· Photos (optional)'); | |
| host.innerHTML = ` | |
| <div class="photo-panel ${isReq ? 'photo-required' : 'photo-optional'}"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;gap:12px"> | |
| <div> | |
| <div style="font-weight:700;color:var(--navy)">${label}</div> | |
| <div style="font-size:.82rem;color:var(--muted)">${escapeHtml(p.reason || '')}</div> | |
| </div> | |
| <div> | |
| <input type="file" id="photo-input-${code}" accept="image/*" multiple style="display:none" | |
| onchange="uploadSectionPhotos('${code}')" /> | |
| <button type="button" class="btn-similar" onclick="event.preventDefault(); document.getElementById('photo-input-${code}').click();"> | |
| Upload photo(s) | |
| </button> | |
| </div> | |
| </div> | |
| <p class="photo-ai-hint">Upload up to ${PHOTO_LIMITS.max_photos_per_section} photos. Tick β on up to ${PHOTO_LIMITS.max_photos_for_ai} for AI analysis when generating this section.</p> | |
| <div class="photo-thumbs" id="photo-thumbs-${code}" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px"></div> | |
| <div class="photo-slot-count" id="photo-slot-count-${code}"></div> | |
| </div> | |
| `; | |
| refreshSectionPhotos(code); | |
| } | |
| const _photoBlobUrls = new Map(); | |
| function _revokeSectionPhotoBlobUrls(sectionCode) { | |
| const prefix = `${sectionCode}:`; | |
| for (const [key, url] of [..._photoBlobUrls.entries()]) { | |
| if (key.startsWith(prefix)) { | |
| URL.revokeObjectURL(url); | |
| _photoBlobUrls.delete(key); | |
| } | |
| } | |
| } | |
| /** URL for <img src> β browsers cannot send Authorization headers. */ | |
| function photoAuthenticatedSrc(path) { | |
| if (!state.token || !path) return ''; | |
| const u = new URL(apiUrl(path)); | |
| u.searchParams.set('access_token', state.token); | |
| return u.toString(); | |
| } | |
| async function loadAuthenticatedPhotoInto(img, path, cacheKey) { | |
| if (!state.token) return; | |
| try { | |
| const res = await fetch(apiUrl(path), { headers: buildAuthHeaders() }); | |
| if (res.status === 401) { | |
| handleAuthExpired(); | |
| return; | |
| } | |
| if (!res.ok) return; | |
| const blob = await res.blob(); | |
| const prev = _photoBlobUrls.get(cacheKey); | |
| if (prev) URL.revokeObjectURL(prev); | |
| const objUrl = URL.createObjectURL(blob); | |
| _photoBlobUrls.set(cacheKey, objUrl); | |
| img.src = objUrl; | |
| } catch { /* leave placeholder */ } | |
| } | |
| function _sectionPhotoSelectedIds(photos) { | |
| return (photos || []).filter(p => p.selected_for_ai).map(p => p.photo_id); | |
| } | |
| async function setSectionPhotoAiSelection(code, photoIds) { | |
| const res = await apiFetch( | |
| 'PUT', | |
| `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}/photos/ai-selection`, | |
| { photo_ids: photoIds }, | |
| ); | |
| return res; | |
| } | |
| async function togglePhotoForAi(code, photoId, isSelected, currentSelectedIds) { | |
| const maxAi = PHOTO_LIMITS.max_photos_for_ai || 2; | |
| let next = [...(currentSelectedIds || [])]; | |
| if (isSelected) { | |
| next = next.filter(id => id !== photoId); | |
| } else { | |
| if (next.length >= maxAi) { | |
| showToast(`You can only select ${maxAi} photo(s) for AI per section.`, 'warn'); | |
| return; | |
| } | |
| next.push(photoId); | |
| } | |
| try { | |
| await setSectionPhotoAiSelection(code, next); | |
| await refreshSectionPhotos(code); | |
| } catch (err) { | |
| showToast(err.message || 'Could not update photo selection.', 'err'); | |
| } | |
| } | |
| async function deleteSectionPhoto(code, photoId) { | |
| if (!confirm('Remove this photo from this section?')) return; | |
| try { | |
| await apiFetch( | |
| 'DELETE', | |
| `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}/photos/${encodeURIComponent(photoId)}`, | |
| null, | |
| ); | |
| const cacheKey = `${code}:${photoId}`; | |
| const prev = _photoBlobUrls.get(cacheKey); | |
| if (prev) { | |
| URL.revokeObjectURL(prev); | |
| _photoBlobUrls.delete(cacheKey); | |
| } | |
| showToast('Photo removed.', 'ok'); | |
| await refreshSectionPhotos(code); | |
| } catch (err) { | |
| showToast(err.message || 'Could not delete photo.', 'err'); | |
| } | |
| } | |
| async function refreshSectionPhotos(code) { | |
| if (!state.reportId) return; | |
| const thumbs = $(`photo-thumbs-${code}`); | |
| const slotCount = $(`photo-slot-count-${code}`); | |
| if (!thumbs) return; | |
| const maxTotal = PHOTO_LIMITS.max_photos_per_section || 5; | |
| const maxAi = PHOTO_LIMITS.max_photos_for_ai || 2; | |
| _revokeSectionPhotoBlobUrls(code); | |
| try { | |
| const res = await apiFetch('GET', `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}/photos`, null); | |
| if (res.max_photos_per_section != null) PHOTO_LIMITS.max_photos_per_section = res.max_photos_per_section; | |
| if (res.max_photos_for_ai != null) PHOTO_LIMITS.max_photos_for_ai = res.max_photos_for_ai; | |
| const photos = res.photos || []; | |
| const selectedIds = _sectionPhotoSelectedIds(photos); | |
| thumbs.innerHTML = ''; | |
| photos.forEach(p => { | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'photo-thumb-wrap' + (p.selected_for_ai ? ' ai-selected' : ''); | |
| const frame = document.createElement('div'); | |
| frame.className = 'photo-thumb-frame'; | |
| const img = document.createElement('img'); | |
| img.alt = ''; | |
| img.title = p.original_filename || 'Photo'; | |
| const cacheKey = `${code}:${p.photo_id}`; | |
| const authedSrc = photoAuthenticatedSrc(p.url); | |
| if (authedSrc) { | |
| img.src = authedSrc; | |
| img.onerror = () => loadAuthenticatedPhotoInto(img, p.url, cacheKey); | |
| } else { | |
| loadAuthenticatedPhotoInto(img, p.url, cacheKey); | |
| } | |
| const delBtn = document.createElement('button'); | |
| delBtn.type = 'button'; | |
| delBtn.className = 'photo-del-btn'; | |
| delBtn.title = 'Delete photo'; | |
| delBtn.setAttribute('aria-label', 'Delete photo'); | |
| delBtn.textContent = 'Γ'; | |
| delBtn.onclick = (ev) => { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| deleteSectionPhoto(code, p.photo_id); | |
| }; | |
| const tick = document.createElement('button'); | |
| tick.type = 'button'; | |
| tick.className = 'photo-ai-tick' + (p.selected_for_ai ? ' on' : ''); | |
| tick.title = p.selected_for_ai ? 'Remove from AI analysis' : 'Use for AI analysis'; | |
| tick.textContent = 'β'; | |
| const atCap = !p.selected_for_ai && selectedIds.length >= maxAi; | |
| tick.disabled = atCap; | |
| tick.onclick = (ev) => { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| togglePhotoForAi(code, p.photo_id, !!p.selected_for_ai, selectedIds); | |
| }; | |
| frame.appendChild(img); | |
| frame.appendChild(delBtn); | |
| frame.appendChild(tick); | |
| wrap.appendChild(frame); | |
| const nameEl = document.createElement('div'); | |
| nameEl.className = 'photo-thumb-name'; | |
| nameEl.textContent = p.original_filename || 'Photo'; | |
| nameEl.title = p.original_filename || ''; | |
| wrap.appendChild(nameEl); | |
| thumbs.appendChild(wrap); | |
| }); | |
| if (slotCount) { | |
| const aiLabel = selectedIds.length | |
| ? `${selectedIds.length}/${maxAi} selected for AI` | |
| : `0/${maxAi} selected for AI`; | |
| slotCount.textContent = `${photos.length}/${maxTotal} uploaded Β· ${aiLabel}`; | |
| } | |
| const inp = document.getElementById(`photo-input-${code}`); | |
| if (inp) { | |
| inp.disabled = photos.length >= maxTotal; | |
| const btn = inp.parentElement && inp.parentElement.querySelector('button'); | |
| if (btn) btn.disabled = photos.length >= maxTotal; | |
| } | |
| } catch (e) { | |
| thumbs.innerHTML = ''; | |
| if (slotCount) slotCount.textContent = ''; | |
| } | |
| } | |
| async function uploadSectionPhotos(code) { | |
| const inp = document.getElementById(`photo-input-${code}`); | |
| if (!inp || !inp.files || inp.files.length === 0) return; | |
| const maxTotal = PHOTO_LIMITS.max_photos_per_section || 5; | |
| let existing = 0; | |
| try { | |
| const cur = await apiFetch('GET', `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}/photos`, null); | |
| existing = (cur.photos || []).length; | |
| } catch { /* ignore */ } | |
| const incoming = inp.files.length; | |
| if (existing + incoming > maxTotal) { | |
| showToast(`Maximum ${maxTotal} photos per section (${existing} already uploaded).`, 'warn'); | |
| inp.value = ''; | |
| return; | |
| } | |
| const fd = new FormData(); | |
| Array.from(inp.files).forEach(f => fd.append('files', f)); | |
| await apiFetch('POST', `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}/photos`, fd); | |
| inp.value = ''; | |
| await refreshSectionPhotos(code); | |
| } | |
| function toggleCardOpen(code) { $(`sb-${code}`).classList.toggle('open'); } | |
| function toggleAction(code) { | |
| const tog = $(`tog-${code}`); | |
| const card = $(`sc-${code}`); | |
| const label = $(`tl-${code}`); | |
| const keepMsg = $(`keep-msg-${code}`); | |
| const genArea = $(`gen-area-${code}`); | |
| if (tog.checked) { | |
| state.sections[code].action = 'ai'; | |
| const task = state.sections[code].aiTask; | |
| _applyCardStyle(code, task); | |
| label.textContent = 'AI Active'; | |
| keepMsg.classList.remove('visible'); | |
| genArea.style.display = 'block'; | |
| } else { | |
| state.sections[code].action = 'keep'; | |
| card.className = 'section-card locked'; | |
| $(`scode-${code}`).className = 'section-code'; | |
| $(`scode-${code}`).style.background = '#aaa'; | |
| label.textContent = 'Keep Blank'; | |
| keepMsg.classList.add('visible'); | |
| genArea.style.display = 'none'; | |
| $(`sb-${code}`).classList.add('open'); | |
| } | |
| updateGenCount(); | |
| } | |
| function setAiTask(code, task) { | |
| state.sections[code].aiTask = task; | |
| // Update tab active states | |
| const tabs = $(`tabs-${code}`).querySelectorAll('.ai-mode-tab'); | |
| tabs.forEach(t => t.classList.toggle('active', t.dataset.task === task)); | |
| // Update description | |
| $(`mode-desc-${code}`).textContent = AI_MODE_DESCRIPTIONS[task]; | |
| // Update header description | |
| const labels = { generate: 'βοΈ Generate Content', proofread: 'π Proofread', enhance: 'βοΈ Enhance Technical Depth' }; | |
| $(`shead-desc-${code}`).textContent = `${labels[task]} β add fact bullets below`; | |
| // Style card border | |
| _applyCardStyle(code, task); | |
| updateGenCount(); | |
| } | |
| function _applyCardStyle(code, task) { | |
| const card = $(`sc-${code}`); | |
| const scode = $(`scode-${code}`); | |
| card.className = 'section-card ' + (task === 'generate' ? 'active' : task === 'proofread' ? 'ai-proofread' : 'ai-enhance'); | |
| scode.style.background = task === 'generate' ? '' : task === 'proofread' ? 'var(--gold)' : 'var(--purple)'; | |
| scode.style.color = task === 'proofread' ? 'var(--navy)' : ''; | |
| } | |
| function saveBullets(code) { state.sections[code].bullets = $(`bullets-${code}`).value; } | |
| /* ββ Global Notes Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function triggerGlobalNotesUpload() { | |
| $('global-notes-input').click(); | |
| } | |
| /** | |
| * Server-side cascade router (P1 code β P1b element label β P2 keywords β P3 fallback), | |
| * then collapse L3 hits onto the active survey-level section codes. | |
| */ | |
| async function routeLinesToSections(lines) { | |
| const data = await apiFetch('POST', '/route-notes', { lines }); | |
| const l3Routed = data.routed || {}; | |
| const unmatched = data.unmatched || []; | |
| const activeCodes = new Set(RICS_SECTIONS.map(s => s.code)); | |
| const result = Object.fromEntries(RICS_SECTIONS.map(s => [s.code, []])); | |
| const stats = { | |
| activeLevel: state.surveyLevel || null, | |
| totalLines: 0, | |
| routedDirect: 0, | |
| collapsedFromL3: 0, | |
| collapseMap: {}, | |
| droppedToFallback: 0, | |
| fallbackCode: null, | |
| unmatchedCount: unmatched.length, | |
| }; | |
| function _collapse(l3Code) { | |
| if (activeCodes.has(l3Code)) return l3Code; | |
| const groupLetter = l3Code.charAt(0); | |
| if (activeCodes.has(groupLetter)) return groupLetter; | |
| let bestNum = -1; | |
| let bestCode = null; | |
| for (const c of activeCodes) { | |
| if (c.charAt(0) !== groupLetter) continue; | |
| const m = c.match(/^([A-Z])(\d+)$/); | |
| if (!m) continue; | |
| const n = parseInt(m[2], 10); | |
| if (n > bestNum) { bestNum = n; bestCode = c; } | |
| } | |
| return bestCode; | |
| } | |
| for (const [l3Code, lineList] of Object.entries(l3Routed)) { | |
| if (!lineList.length) continue; | |
| stats.totalLines += lineList.length; | |
| const target = _collapse(l3Code); | |
| if (target) { | |
| result[target].push(...lineList); | |
| if (target === l3Code) { | |
| stats.routedDirect += lineList.length; | |
| } else { | |
| stats.collapsedFromL3 += lineList.length; | |
| stats.collapseMap[l3Code] = target; | |
| } | |
| } else { | |
| const fb = RICS_SECTIONS[0]?.code || null; | |
| if (fb && result[fb]) { | |
| result[fb].push(...lineList); | |
| stats.fallbackCode = fb; | |
| stats.droppedToFallback += lineList.length; | |
| } | |
| } | |
| } | |
| if (unmatched.length) { | |
| const fb = RICS_SECTIONS.find(s => s.code === 'UNASSIGNED')?.code || null; | |
| if (fb && result[fb]) { | |
| result[fb].push(...unmatched); | |
| } | |
| } | |
| return { routed: result, stats }; | |
| } | |
| async function handleGlobalNotes(event) { | |
| const file = event.target.files && event.target.files[0]; | |
| if (!file) return; | |
| const btn = $('btn-notes-upload'); | |
| const status = $('global-notes-status'); | |
| btn.disabled = true; | |
| status.className = 'notes-upload-status nus-busy'; | |
| status.textContent = `π€ Reading "${file.name}" and routing observations to sectionsβ¦`; | |
| try { | |
| const fd = new FormData(); | |
| fd.append('file', file, file.name); | |
| if (!state.token) { showAuthOverlay(); throw new Error('Log in to upload notes.'); } | |
| const res = await fetch(apiUrl('/extract-notes'), { | |
| method: 'POST', | |
| headers: buildAuthHeaders(), | |
| body: fd, | |
| }); | |
| if (res.status === 401) { handleAuthExpired(); throw new Error('Your session expired. Please log in again.'); } | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) { | |
| const detail = data && data.detail; | |
| throw new Error(typeof detail === 'string' ? detail : `HTTP ${res.status}`); | |
| } | |
| const lines = (data.lines || []).filter(l => l.trim()); | |
| if (!lines.length) throw new Error('No text found in the document.'); | |
| // Notes-content tier guard: if the classifier read the *content* of these | |
| // notes as a different tier from the report's selected tier, surface the | |
| // existing tier-mismatch modal before routing. The classifier returns 0 + | |
| // confidence 0.0 when there isn't enough text β those cases skip the modal | |
| // so users aren't pestered with meaningless warnings on short notes. | |
| const noteLevel = parseInt(data.predicted_survey_level, 10) || 0; | |
| const reportLevel = parseInt(state.surveyLevel, 10) || 0; | |
| const noteConf = Number(data.confidence) || 0; | |
| if (noteLevel && reportLevel && noteLevel !== reportLevel && noteConf >= 0.20) { | |
| try { | |
| await _openTierMismatchModal({ | |
| detail: { | |
| predicted_survey_level: noteLevel, | |
| requested_survey_level: reportLevel, | |
| confidence: noteConf, | |
| rationale: | |
| `Notes content classifier: ${data.rationale || ''} ` + | |
| `Routing will fold any out-of-level codes into the closest active section ` + | |
| `(e.g. on Level 1, all element observations land in sections E, F, G, H β ` + | |
| `no per-element ratings exist at this tier).`, | |
| }, | |
| }); | |
| } catch (cancelled) { | |
| // User clicked back β abort the upload and let them re-upload onto the | |
| // correct report level (or with a different notes file). | |
| status.className = 'notes-upload-status nus-warn'; | |
| status.textContent = 'β Upload cancelled β tier mismatch was not accepted.'; | |
| return; | |
| } | |
| } | |
| // Replace prior bullet boxes β do not prepend onto stale misrouted content. | |
| RICS_SECTIONS.forEach(sec => { | |
| const ta = $(`bullets-${sec.code}`); | |
| if (ta) { | |
| ta.value = ''; | |
| saveBullets(sec.code); | |
| } | |
| }); | |
| const { routed, stats } = await routeLinesToSections(lines); | |
| let filled = 0; | |
| for (const [code, sectionLines] of Object.entries(routed)) { | |
| if (!sectionLines.length) continue; | |
| const ta = $(`bullets-${code}`); | |
| if (!ta) continue; | |
| const existing = ta.value.trim(); | |
| ta.value = existing ? sectionLines.join('\n') + '\n' + existing : sectionLines.join('\n'); | |
| saveBullets(code); | |
| const body = $(`sb-${code}`); | |
| if (body && !body.classList.contains('open')) body.classList.add('open'); | |
| filled += sectionLines.length; | |
| } | |
| const sectionsFilled = Object.values(routed).filter(l => l.length).length; | |
| // Report routing transparency so silent misrouting is impossible: if any lines were | |
| // collapsed from L3 codes (e.g. K1 β K on L1) or fell back to the first section, | |
| // surface that count and the affected sections directly in the toast. | |
| const collapseEntries = Object.entries(stats.collapseMap); | |
| const extras = []; | |
| if (collapseEntries.length) { | |
| const sample = collapseEntries.slice(0, 4) | |
| .map(([from, to]) => `${from}β${to}`) | |
| .join(', '); | |
| const more = collapseEntries.length > 4 ? ` (+${collapseEntries.length - 4} more)` : ''; | |
| extras.push(`${stats.collapsedFromL3} line(s) folded to active-level sections [${sample}${more}]`); | |
| } | |
| if (stats.droppedToFallback) { | |
| extras.push(`${stats.droppedToFallback} unmatched line(s) attached to section ${stats.fallbackCode}`); | |
| } | |
| status.className = 'notes-upload-status nus-ok'; | |
| status.textContent = extras.length | |
| ? `β "${data.filename}" β ${lines.length} line(s) distributed across ${sectionsFilled} section(s). ` + | |
| `Routing notes: ${extras.join('; ')}. Review each section below, then click β¨ Run AI.` | |
| : `β "${data.filename}" β ${lines.length} line(s) distributed across ${sectionsFilled} section(s). ` + | |
| `Review each section below, then click β¨ Run AI.`; | |
| } catch (err) { | |
| status.className = 'notes-upload-status nus-err'; | |
| status.textContent = `β ${err.message}`; | |
| } finally { | |
| btn.disabled = false; | |
| event.target.value = ''; | |
| } | |
| } | |
| /* ββ Report composition depth (minimum | medium | maximum) βββββββββββββββββ */ | |
| const INTERFERENCE_MODES = { | |
| minimum: { | |
| label: 'Baseline preservation', | |
| badge: 'Baseline preservation', | |
| oneLine: 'In-place fact updates on your past report baseline; no invented defects or materials.', | |
| tooltip: 'Past report scaffolding retained verbatim where notes are silent.', | |
| }, | |
| medium: { | |
| label: 'Technical edit', | |
| badge: 'Technical edit', | |
| oneLine: 'Proofread in-place updates on the baseline; claims traceable to your field notes.', | |
| tooltip: 'Formal British English; surgical edits only.', | |
| }, | |
| maximum: { | |
| label: 'Full narrative edit', | |
| badge: 'Full narrative edit', | |
| oneLine: 'Long-form past report baseline retained; full RICS Home Survey narrative depth.', | |
| tooltip: 'Default for Level 3. Never fabricate; unmatched notes surface as UNMATCHED.', | |
| }, | |
| }; | |
| function hasGeneratedReportContent() { | |
| return !!(state.reportId && Object.values(state.results || {}).some( | |
| (r) => r && String(r.text || '').replace(/\s+/g, '').length > 0, | |
| )); | |
| } | |
| function normalizeInterferenceLevel(v) { | |
| const t = String(v || '').trim().toLowerCase(); | |
| if (t === 'minimal') return 'minimum'; | |
| return INTERFERENCE_MODES[t] ? t : null; | |
| } | |
| function getInterferenceLevel() { | |
| const n = normalizeInterferenceLevel(state.interferenceLevel); | |
| return n || 'maximum'; | |
| } | |
| function syncInterferencePillsUi() { | |
| const cur = getInterferenceLevel(); | |
| document.querySelectorAll('.interference-pill').forEach((btn) => { | |
| const lv = btn.getAttribute('data-level'); | |
| const on = lv === cur; | |
| btn.classList.toggle('active', on); | |
| btn.setAttribute('aria-pressed', on ? 'true' : 'false'); | |
| }); | |
| const m = INTERFERENCE_MODES[cur]; | |
| const el = $('interference-detail-desc'); | |
| if (el && m) el.textContent = m.oneLine; | |
| } | |
| function setInterferenceLevel(nextRaw, { skipConfirm } = {}) { | |
| const next = normalizeInterferenceLevel(nextRaw) || 'maximum'; | |
| const cur = getInterferenceLevel(); | |
| if (next === cur) return true; | |
| if (!skipConfirm && hasGeneratedReportContent()) { | |
| const ok = confirm('Changing the composition depth will regenerate your report. Continue?'); | |
| if (!ok) return false; | |
| } | |
| state.interferenceLevel = next; | |
| syncInterferencePillsUi(); | |
| return true; | |
| } | |
| function initInterferencePills() { | |
| document.querySelectorAll('.interference-pill').forEach((btn) => { | |
| btn.addEventListener('click', () => { | |
| const lv = btn.getAttribute('data-level'); | |
| if (lv) setInterferenceLevel(lv); | |
| }); | |
| btn.addEventListener('mouseenter', () => { | |
| const lv = normalizeInterferenceLevel(btn.getAttribute('data-level')); | |
| const m = lv && INTERFERENCE_MODES[lv]; | |
| const el = $('interference-detail-desc'); | |
| if (el && m) el.textContent = m.oneLine; | |
| }); | |
| btn.addEventListener('mouseleave', () => { | |
| syncInterferencePillsUi(); | |
| }); | |
| }); | |
| syncInterferencePillsUi(); | |
| } | |
| function interferenceFromSectionPayload(r) { | |
| if (!r) return null; | |
| const il = normalizeInterferenceLevel(r.interference_level); | |
| if (il) return il; | |
| return null; | |
| } | |
| function getRetrievalLevel() { | |
| const el = $('retrieval-level'); | |
| const v = el ? String(el.value || '').trim() : ''; | |
| return (v === 'document' || v === 'section' || v === 'paragraph') ? v : 'paragraph'; | |
| } | |
| function useCacheEnabled() { return !!($('use-cache-toggle') && $('use-cache-toggle').checked); } | |
| function strictUploadsOnlyEnabled() { | |
| return !!($('strict-uploads-toggle') && $('strict-uploads-toggle').checked); | |
| } | |
| function updateGenCount() { | |
| const n = Object.values(state.sections).filter(s => s.action === 'ai').length; | |
| $('gen-count').textContent = n; | |
| $('btn-generate').disabled = n === 0; | |
| } | |
| $('btn-generate').addEventListener('click', handleGenerate); | |
| initInterferencePills(); | |
| /* ββ STEP 4: Generation Flow βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function handleGenerate() { | |
| const toAI = RICS_SECTIONS.filter(s => state.sections[s.code].action === 'ai'); | |
| const toKeep = RICS_SECTIONS.filter(s => state.sections[s.code].action === 'keep'); | |
| const genList = $('gen-list'); | |
| genList.innerHTML = ''; | |
| RICS_SECTIONS.forEach(sec => { | |
| const s = state.sections[sec.code]; | |
| const isAI = s.action === 'ai'; | |
| const task = s.aiTask; | |
| const colorClass = isAI ? `c-${task}` : 'c-keep'; | |
| const statusText = isAI ? 'β³ Waiting' : 'β Skipped'; | |
| const statusClass = isAI ? 'gs-waiting' : 'gs-skip'; | |
| const modeLabel = isAI ? { generate:'βοΈ Generate', proofread:'π Proofread', enhance:'βοΈ Enhance' }[task] : 'Keep Blank'; | |
| const row = document.createElement('div'); | |
| row.className = 'gen-row'; row.id = `gr-${sec.code}`; | |
| row.innerHTML = ` | |
| <span class="code ${colorClass}">${sec.code}</span> | |
| <span class="label">${sec.title}</span> | |
| <span style="font-size:.73rem;color:var(--muted);flex-shrink:0">${modeLabel}</span> | |
| <div class="progress-bar hidden" id="gpb-${sec.code}"><div class="progress-fill pf-${isAI ? task : 'generate'}" id="gpf-${sec.code}"></div></div> | |
| <span class="gen-status ${statusClass}" id="gs-${sec.code}">${statusText}</span> | |
| `; | |
| genList.appendChild(row); | |
| }); | |
| const il = getInterferenceLevel(); | |
| const minfo = INTERFERENCE_MODES[il] || INTERFERENCE_MODES.medium; | |
| const cacheLabel = useCacheEnabled() ? 'cache on' : 'cache off'; | |
| const maxHint = il === 'maximum' ? ' Maximum mode: expect ~2β6 min per section.' : ''; | |
| $('gen-step-subtitle').textContent = | |
| `${minfo.label} (${cacheLabel}) β notes β evidence β generation.${maxHint}`; | |
| showStep(4); | |
| if (toAI.length > 1 && canBatchSections(toAI)) { | |
| await generateSectionsBatch(toAI); | |
| } else { | |
| for (const sec of toAI) { | |
| await generateSection(sec); | |
| } | |
| } | |
| await loadResults(); | |
| showStep(5); | |
| } | |
| function referenceDocumentIdsForRag() { | |
| if (!state.docIds || !state.docIds.length) return []; | |
| const primary = state.docId; | |
| return state.docIds.filter(id => id && id !== primary); | |
| } | |
| /** Batch API requires every selected section to use the same AI task (generate / proofread / enhance). */ | |
| function canBatchSections(sections) { | |
| if (!sections || sections.length < 2) return false; | |
| const tasks = new Set(sections.map(sec => state.sections[sec.code].aiTask)); | |
| return tasks.size === 1; | |
| } | |
| function isReportJobDone(status) { | |
| return status === 'complete' || status === 'partial'; | |
| } | |
| /** Poll budget scales with section count (sequential jobs can run 30β90s per section). */ | |
| function generationPollPlan(sectionCount, il) { | |
| const perSectionSec = il === 'maximum' ? 120 : il === 'high' ? 90 : 60; | |
| const pollMs = il === 'maximum' ? 3000 : 2000; | |
| const totalSec = Math.min(4 * 3600, Math.max(600, sectionCount * perSectionSec)); | |
| const maxPolls = Math.ceil((totalSec * 1000) / pollMs); | |
| return { pollMs, maxPolls, totalSec, sectionCount }; | |
| } | |
| function formatGenerationProgressSubtitle(st, plan, elapsedMs) { | |
| const saved = Number(st.sections_saved) || 0; | |
| const withText = Number(st.sections_with_text) || 0; | |
| const total = st.generation_section_total != null | |
| ? Number(st.generation_section_total) | |
| : (plan?.sectionCount ?? null); | |
| const elapsedSec = st.generation_elapsed_seconds != null | |
| ? Math.round(Number(st.generation_elapsed_seconds)) | |
| : Math.round(elapsedMs / 1000); | |
| let line; | |
| if (total != null && total > 0) { | |
| line = `Progress: ${saved} / ${total} sections saved (${withText} with text)`; | |
| } else if (saved > 0) { | |
| line = `Progress: ${saved} sections saved (${withText} with text)`; | |
| } else { | |
| line = 'Interpreting notes β expanding β retrieving β generating'; | |
| } | |
| const budget = plan?.totalSec; | |
| if (budget) line += ` Β· ${elapsedSec}s / ~${budget}s`; | |
| else if (elapsedSec > 0) line += ` Β· ${elapsedSec}s`; | |
| return line; | |
| } | |
| function applyBatchGenerationProgress(st, sections, plan, elapsedMs) { | |
| const sub = $('gen-step-subtitle'); | |
| if (sub) sub.textContent = formatGenerationProgressSubtitle(st, plan, elapsedMs); | |
| const saved = Number(st.sections_saved) || 0; | |
| const total = st.generation_section_total != null | |
| ? Number(st.generation_section_total) | |
| : sections.length; | |
| const overallBar = $('gen-overall-progress'); | |
| const overallFill = $('gen-overall-fill'); | |
| if (overallBar && overallFill) { | |
| if (total > 0 && st.status === 'generating') { | |
| overallBar.classList.remove('hidden'); | |
| overallFill.style.width = `${Math.min(100, Math.round((saved / total) * 100))}%`; | |
| } else { | |
| overallBar.classList.add('hidden'); | |
| } | |
| } | |
| sections.forEach((sec, idx) => { | |
| const gpf = $(`gpf-${sec.code}`); | |
| const gs = $(`gs-${sec.code}`); | |
| const gpb = $(`gpb-${sec.code}`); | |
| if (!gpf || !gs) return; | |
| gpb?.classList.remove('hidden'); | |
| if (idx < saved) { | |
| gpf.style.width = '100%'; | |
| gs.className = 'gen-status gs-done'; | |
| gs.textContent = 'β Saved'; | |
| } else if (idx === saved && st.status === 'generating') { | |
| gpf.style.width = '55%'; | |
| gs.className = 'gen-status gs-working'; | |
| gs.textContent = 'β³ Processingβ¦'; | |
| } else if (st.status === 'generating') { | |
| gpf.style.width = '15%'; | |
| gs.className = 'gen-status gs-waiting'; | |
| gs.textContent = 'β³ Waiting'; | |
| } | |
| }); | |
| } | |
| async function pollReportGenerationDone({ plan, sections, onProgress }) { | |
| for (let i = 0; i < plan.maxPolls; i++) { | |
| await sleep(plan.pollMs); | |
| const st = await apiFetch('GET', `/reports/${state.reportId}/status`, null); | |
| const elapsedMs = (i + 1) * plan.pollMs; | |
| if (onProgress) onProgress(st, elapsedMs); | |
| if (isReportJobDone(st.status)) return st; | |
| if (st.status === 'failed') throw new Error(st.error_message || 'Generation failed'); | |
| } | |
| await apiFetch('POST', `/reports/${state.reportId}/generation/cancel`, null).catch(() => {}); | |
| throw new Error('Timed out waiting for generation'); | |
| } | |
| async function generateSectionsBatch(sections) { | |
| const task = state.sections[sections[0].code].aiTask; | |
| const il = getInterferenceLevel(); | |
| const maxMode = il === 'maximum'; | |
| const refs = referenceDocumentIdsForRag(); | |
| const bullets_by_section = {}; | |
| for (const sec of sections) { | |
| const gs = $(`gs-${sec.code}`); | |
| const gpb = $(`gpb-${sec.code}`); | |
| const gpf = $(`gpf-${sec.code}`); | |
| gs.className = 'gen-status gs-working'; | |
| gs.textContent = 'π Batch queuedβ¦' + (maxMode ? ' (max)' : ''); | |
| gpb.classList.remove('hidden'); | |
| gpf.style.width = '15%'; | |
| bullets_by_section[sec.code] = state.sections[sec.code].bullets | |
| .split('\n').map(b => b.replace(/^[-β’*]\s*/, '').trim()).filter(Boolean); | |
| } | |
| const primary = sections[0]; | |
| const genBody = { | |
| template_id: primary.code, | |
| template_ids: sections.slice(1).map(s => s.code), | |
| bullets: bullets_by_section[primary.code] || [], | |
| bullets_by_section, | |
| mode: task, | |
| interference_level: il, | |
| retrieval_level: getRetrievalLevel(), | |
| force_regenerate: !useCacheEnabled(), | |
| strict_uploaded_only: strictUploadsOnlyEnabled(), | |
| }; | |
| if (refs.length) genBody.reference_document_ids = refs; | |
| try { | |
| await apiFetch('POST', `/reports/${state.reportId}/generate`, genBody); | |
| const plan = generationPollPlan(sections.length, il); | |
| plan.sectionCount = sections.length; | |
| const stFinal = await pollReportGenerationDone({ | |
| plan, | |
| sections, | |
| onProgress: (st, elapsedMs) => applyBatchGenerationProgress(st, sections, plan, elapsedMs), | |
| }); | |
| $('gen-overall-progress')?.classList.add('hidden'); | |
| let sectionPayloads = {}; | |
| if (stFinal.status === 'partial') { | |
| const secResp = await apiFetch('GET', `/reports/${state.reportId}/sections`, null); | |
| sectionPayloads = secResp.sections || {}; | |
| } | |
| for (const sec of sections) { | |
| $(`gpf-${sec.code}`).style.width = '100%'; | |
| const gs = $(`gs-${sec.code}`); | |
| const hasText = !!(sectionPayloads[sec.code]?.text || '').trim(); | |
| if (stFinal.status === 'partial' && !hasText) { | |
| gs.className = 'gen-status gs-error'; | |
| gs.textContent = 'β Failed'; | |
| } else if (stFinal.status === 'partial') { | |
| gs.className = 'gen-status gs-done'; | |
| gs.textContent = 'β Done'; | |
| } else { | |
| gs.className = 'gen-status gs-done'; | |
| gs.textContent = 'β Done'; | |
| } | |
| $(`gpb-${sec.code}`).classList.add('hidden'); | |
| } | |
| if (stFinal.status === 'partial' && stFinal.error_message) { | |
| console.warn('[batch]', stFinal.error_message); | |
| } | |
| } catch (err) { | |
| for (const sec of sections) { | |
| $(`gpf-${sec.code}`).style.width = '100%'; | |
| const gs = $(`gs-${sec.code}`); | |
| gs.className = 'gen-status gs-error'; | |
| gs.textContent = 'β ' + (err.message || 'Error'); | |
| } | |
| } | |
| } | |
| async function generateSection(sec) { | |
| const task = state.sections[sec.code].aiTask; | |
| const gs = $(`gs-${sec.code}`); | |
| const gpb = $(`gpb-${sec.code}`); | |
| const gpf = $(`gpf-${sec.code}`); | |
| const modeLabel = { generate:'π Retrievingβ¦', proofread:'π Proofreadingβ¦', enhance:'βοΈ Enhancingβ¦' }[task]; | |
| const il = getInterferenceLevel(); | |
| const maxMode = il === 'maximum' && task === 'generate'; | |
| gs.className = 'gen-status gs-working'; | |
| gs.textContent = modeLabel + (maxMode ? ' (max Β· est. 2β6 min)' : ''); | |
| gpb.classList.remove('hidden'); | |
| gpf.style.width = '25%'; | |
| const bullets = state.sections[sec.code].bullets | |
| .split('\n').map(b => b.replace(/^[-β’*]\s*/, '').trim()).filter(Boolean); | |
| const refs = referenceDocumentIdsForRag(); | |
| const genBody = { | |
| template_id: sec.code, | |
| // If the user provided no notes, send an empty list. The backend will | |
| // persist a blank section so the user can fill it in immediately via the | |
| // inline editor on the results page, rather than generating generic prose. | |
| bullets, | |
| mode: task, | |
| interference_level: getInterferenceLevel(), | |
| retrieval_level: getRetrievalLevel(), | |
| // Backend expects force_regenerate=true to bypass cache. | |
| force_regenerate: !useCacheEnabled(), | |
| strict_uploaded_only: strictUploadsOnlyEnabled(), | |
| }; | |
| if (refs.length) genBody.reference_document_ids = refs; | |
| try { | |
| await apiFetch('POST', `/reports/${state.reportId}/generate`, genBody); | |
| gpf.style.width = '60%'; | |
| gs.textContent = 'β³ Processingβ¦' + (maxMode ? ' Max mode: larger output β typically 2β6 min.' : ''); | |
| const plan = generationPollPlan(1, il); | |
| plan.sectionCount = 1; | |
| const stFinal = await pollReportGenerationDone({ | |
| plan, | |
| sections: [sec], | |
| onProgress: (st, elapsedMs) => { | |
| const sub = $('gen-step-subtitle'); | |
| if (sub) sub.textContent = formatGenerationProgressSubtitle(st, plan, elapsedMs); | |
| const saved = Number(st.sections_saved) || 0; | |
| if (saved >= 1) gpf.style.width = '85%'; | |
| }, | |
| }); | |
| gpf.style.width = '100%'; | |
| if (stFinal.status === 'partial') { | |
| gs.className = 'gen-status gs-error'; | |
| gs.textContent = 'β οΈ ' + (stFinal.error_message || 'Partial completion'); | |
| } else { | |
| gs.className = 'gen-status gs-done'; | |
| gs.textContent = 'β Done'; | |
| } | |
| await sleep(300); | |
| gpb.classList.add('hidden'); | |
| } catch (err) { | |
| gpf.style.width = '100%'; | |
| gs.className = 'gen-status gs-error'; | |
| gs.textContent = 'β ' + (err.message || 'Error'); | |
| } | |
| } | |
| /* ββ STEP 5: Results βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function formatAiModeBadge(result) { | |
| const level = interferenceFromSectionPayload(result) || getInterferenceLevel(); | |
| const m = INTERFERENCE_MODES[level] || INTERFERENCE_MODES.medium; | |
| return `<span class="interference-result-badge" title="${escapeHtml(m.tooltip)}">${escapeHtml(m.badge)}</span>`; | |
| } | |
| // Build a single result card DOM node from current state. Splitting this out | |
| // lets reRunSection() update one card without re-fetching/re-rendering all 35. | |
| function buildResultCard(sec, result, isKept) { | |
| const card = document.createElement('div'); | |
| card.id = 'result-card-' + sec.code; | |
| // Set data-code on every card variant so reRunSection / renderResultCard | |
| // can locate the exact card via .result-card[data-code="${code}"] without | |
| // depending on the (mode-dependent) inner ".code-${mode}" element. See the | |
| // comment in the generated-card branch below for the bug this prevents. | |
| card.setAttribute('data-code', sec.code); | |
| if (isKept) { | |
| card.className = 'result-card'; | |
| card.innerHTML = ` | |
| <div class="result-head mode-keep"> | |
| <span class="code code-keep">${sec.code}</span> | |
| <strong>${sec.title}</strong> | |
| <span class="mode-badge mb-keep">Keep Blank</span> | |
| </div> | |
| <div class="result-body"> | |
| <div class="result-text" style="color:var(--muted);font-style:italic">This section was left blank for manual writing.</div> | |
| </div> | |
| `; | |
| return card; | |
| } | |
| if (!result) { | |
| const aiLvlBadge = formatAiModeBadge(null); | |
| card.className = 'result-card mode-generate'; | |
| card.id = 'result-card-' + sec.code; | |
| card.setAttribute('data-code', sec.code); | |
| card.innerHTML = ` | |
| <div class="result-head mode-generate"> | |
| <span class="code code-generate">${sec.code}</span> | |
| <strong>${sec.title}</strong> | |
| <span class="mode-badge mb-generate">Not generated</span> | |
| ${aiLvlBadge} | |
| <span class="conf-badge low">No AI output yet</span> | |
| <span class="edit-status" id="es-${sec.code}" title="Edit the text below β changes auto-save after you stop typing or press Cmd/Ctrl+S">β Editable</span> | |
| </div> | |
| <div class="result-body"> | |
| <div style="margin:.25rem 0 .85rem;padding:.6rem .8rem;border-radius:8px;border:1px solid #c8ddf5;background:#f0f7ff;color:#1a508f;font-size:.82rem;line-height:1.35"> | |
| <strong>No generated text for this section.</strong> Type below β your text saves automatically β or add notes in Configure and click <strong>Re-Generate</strong>. | |
| </div> | |
| <div | |
| class="result-text" | |
| id="rt-${sec.code}" | |
| contenteditable="plaintext-only" | |
| spellcheck="true" | |
| role="textbox" | |
| aria-multiline="true" | |
| aria-label="Editable text for section ${sec.code}: ${escapeAttr(sec.title)}" | |
| data-section-code="${sec.code}" | |
| ></div> | |
| <div class="result-actions"> | |
| <button class="btn btn-outline btn-sm" onclick="copySection('${sec.code}')">π Copy</button> | |
| <button class="btn btn-primary btn-sm" onclick="reRunSection('${sec.code}','generate')">βοΈ Re-Generate</button> | |
| <button class="btn btn-gold btn-sm" onclick="reRunSection('${sec.code}','proofread')">π Proofread</button> | |
| <button class="btn btn-purple btn-sm" onclick="reRunSection('${sec.code}','enhance')">βοΈ Enhance</button> | |
| </div> | |
| </div> | |
| `; | |
| return card; | |
| } | |
| const mode = result.mode || 'generate'; | |
| const conf = (result.confidence * 100).toFixed(0); | |
| const confClass = result.confidence >= 0.5 ? '' : 'low'; | |
| const cachedBadge = result.cached ? '<span class="cached-badge">β‘ Cached</span>' : ''; | |
| const styleBadge = result.style_profile | |
| ? `<span class="style-badge">π¨ ${escapeHtml(result.style_profile.tone || 'formal')} style</span>` : ''; | |
| const prov = Array.isArray(result.provenance) ? result.provenance : []; | |
| const modeBadges = { generate:'βοΈ Generated', proofread:'π Proofread', enhance:'βοΈ Enhanced' }; | |
| const modeBadgeClass = `mb-${mode}`; | |
| const codeClass = `code-${mode}`; | |
| const headClass = `mode-${mode}`; | |
| const aiLvlBadge = formatAiModeBadge(result); | |
| // Split proofread notes if present | |
| let mainText = result.text; | |
| let notesHtml = ''; | |
| if (mode === 'proofread' && mainText.includes('[Editor notes:')) { | |
| const idx = mainText.indexOf('[Editor notes:'); | |
| const notes = mainText.slice(idx).replace(/^\[Editor notes:\s*/,'').replace(/\]$/,''); | |
| mainText = mainText.slice(0, idx).trim(); | |
| notesHtml = `<div class="editor-notes"><strong>π Editor Notes:</strong>${escapeHtml(notes)}</div>`; | |
| } | |
| const textHtml = highlightVerify(escapeHtml(mainText)); | |
| const userBulletsRaw = String(state.sections?.[sec.code]?.bullets || '').trim(); | |
| const hasUserNotes = userBulletsRaw.length > 0; | |
| const missingNotesBanner = (!isKept && !hasUserNotes) ? ` | |
| <div style="margin:.25rem 0 .85rem;padding:.6rem .8rem;border-radius:8px;border:1px solid #f0d6b4;background:#fff7ea;color:#7a4a00;font-size:.82rem;line-height:1.35"> | |
| <strong>Missing notes for this section.</strong> The system will not invent facts or pull property details from other uploaded documents. | |
| Paste your notes into the editable text box below (or edit directly), then click <strong>Re-Generate</strong>. | |
| </div>` : ''; | |
| const citeChips = buildCitationChips(prov); | |
| const provHtml = prov.length ? ` | |
| <details class="prov-list"> | |
| <summary>π Source details β ${prov.length} retrieved passage(s)</summary> | |
| ${prov.map((p, idx) => ` | |
| <div class="prov-item"> | |
| <span style="font-weight:700;color:var(--navy)">[${idx + 1}]</span> | |
| <span>${escapeHtml(p.filename || 'Your upload')}</span> | |
| <code>relevance ${typeof p.score === 'number' ? p.score.toFixed(3) : p.score}</code> | |
| ${p.section_hint ? `<div style="width:100%;font-size:.76rem;color:var(--muted)">${escapeHtml(p.section_hint)}</div>` : ''} | |
| ${p.snippet_preview ? `<div style="width:100%;font-size:.74rem;font-style:italic;opacity:.9">${escapeHtml(p.snippet_preview)}</div>` : ''} | |
| </div>`).join('')} | |
| </details>` : ''; | |
| const auditHtml = buildCitationAudit(result.citation_audit); | |
| const trx = result.ai_transparency; | |
| const note = trx && (trx.plain_language || trx.composition_note) ? String(trx.plain_language || trx.composition_note) : ''; | |
| const transHtml = note ? ` | |
| <div class="section-transparency"> | |
| <strong>Mapping:</strong> ${escapeHtml(note)} | |
| </div>` : ''; | |
| card.className = `result-card mode-${mode}`; | |
| // data-code is the canonical hook for targeted lookups (e.g. reRunSection). | |
| // Querying by .code-${mode} is unsafe β many cards share the same mode and | |
| // the first match would be returned, blanking the wrong card. Query by | |
| // .result-card[data-code="${code}"] (or, equivalently, #result-card-${code}). | |
| card.setAttribute('data-code', sec.code); | |
| // The status pill walks through β Editable β β³ Savingβ¦ β β Saved / β Save failed. | |
| // The literal copy and ordering are part of the spec β _setEditStatus() | |
| // is the only function that mutates this element. | |
| card.innerHTML = ` | |
| <div class="result-head ${headClass}"> | |
| <span class="code ${codeClass}">${sec.code}</span> | |
| <strong>${sec.title}</strong> | |
| ${cachedBadge} | |
| ${styleBadge} | |
| <span class="mode-badge ${modeBadgeClass}">${modeBadges[mode] || mode}</span> | |
| ${aiLvlBadge} | |
| <span class="conf-badge ${confClass}">Confidence: ${conf}%</span> | |
| <span class="edit-status" id="es-${sec.code}" title="Edit the text below β changes auto-save after you stop typing or press Cmd/Ctrl+S">β Editable</span> | |
| </div> | |
| <div class="result-body"> | |
| ${missingNotesBanner} | |
| <!-- | |
| Inline-editable section body. | |
| contenteditable="plaintext-only" preserves the LLM's line breaks via | |
| white-space: pre-wrap and prevents pasted HTML from breaking layout. | |
| bindResultTextEditing() wires up the debounced PATCH save flow on this | |
| node every time the card is rendered. data-section-code carries the | |
| section identifier for the save handler. | |
| --> | |
| <div | |
| class="result-text" | |
| id="rt-${sec.code}" | |
| contenteditable="plaintext-only" | |
| spellcheck="true" | |
| role="textbox" | |
| aria-multiline="true" | |
| aria-label="Editable text for section ${sec.code}: ${escapeAttr(sec.title)}" | |
| data-section-code="${sec.code}" | |
| >${textHtml}</div> | |
| ${notesHtml} | |
| ${transHtml} | |
| ${auditHtml} | |
| ${citeChips} | |
| ${provHtml} | |
| <div class="result-actions"> | |
| <button class="btn btn-outline btn-sm" onclick="copySection('${sec.code}')">π Copy</button> | |
| <button class="btn btn-primary btn-sm" onclick="reRunSection('${sec.code}','generate')">βοΈ Re-Generate</button> | |
| <button class="btn btn-gold btn-sm" onclick="reRunSection('${sec.code}','proofread')">π Proofread</button> | |
| <button class="btn btn-purple btn-sm" onclick="reRunSection('${sec.code}','enhance')">βοΈ Enhance</button> | |
| </div> | |
| </div> | |
| `; | |
| return card; | |
| } | |
| // Used in the aria-label above so a section title containing quotes does not | |
| // break the attribute. We already use escapeHtml for inner-HTML, but quotes | |
| // inside attributes are a separate concern. | |
| function escapeAttr(s) { | |
| return String(s == null ? '' : s) | |
| .replace(/&/g, '&') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, ''') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| } | |
| // Render or re-render exactly one card in place. Used after re-run / proofread / | |
| // enhance so we don't re-fetch all sections and re-render the entire grid. | |
| // We look the existing card up by data-code (preferred) and fall back to id | |
| // for backward compatibility with any pre-existing card that lacks the | |
| // attribute (e.g. left over from a prior session). | |
| function findResultCard(code) { | |
| return document.querySelector(`.result-card[data-code="${code}"]`) | |
| || document.getElementById('result-card-' + code); | |
| } | |
| function renderResultCard(code) { | |
| const sec = RICS_SECTIONS.find(s => s.code === code); | |
| if (!sec) return; | |
| const result = state.results[code]; | |
| const isKept = state.sections[code]?.action === 'keep'; | |
| const newCard = buildResultCard(sec, result, isKept); | |
| const existing = findResultCard(code); | |
| if (existing && existing.parentNode) { | |
| existing.replaceWith(newCard); | |
| } else { | |
| $('results-grid').appendChild(newCard); | |
| } | |
| // Re-bind inline editing on every render β the previous DOM node has been | |
| // discarded by replaceWith, so its event listeners are gone. | |
| bindResultTextEditing(code); | |
| updateAggregateAiTransparency(state.results); | |
| } | |
| async function loadResults() { | |
| const data = await apiFetch('GET', `/reports/${state.reportId}/sections`, null); | |
| state.results = data.sections || {}; | |
| // Extract style profile from any generated section | |
| const firstResult = Object.values(state.results).find(r => r.style_profile); | |
| if (firstResult?.style_profile) { | |
| showStyleProfile(firstResult.style_profile); | |
| } | |
| const grid = $('results-grid'); | |
| grid.innerHTML = ''; | |
| RICS_SECTIONS.forEach(sec => { | |
| const result = state.results[sec.code]; | |
| const isKept = state.sections[sec.code].action === 'keep'; | |
| grid.appendChild(buildResultCard(sec, result, isKept)); | |
| }); | |
| // Bind editors after every card is in the DOM (idempotent β bindResultTextEditing | |
| // tears down any previous listeners on the same node before attaching). | |
| RICS_SECTIONS.forEach(sec => bindResultTextEditing(sec.code)); | |
| updateAggregateAiTransparency(state.results); | |
| } | |
| /* ββ Inline section editing (debounced auto-save) βββββββββββββββββββββββββββββ | |
| * Each .result-text is contenteditable="plaintext-only". On input, we debounce | |
| * 700 ms then PATCH /reports/{report_id}/sections/{section_code} with the new | |
| * text. blur and Cmd/Ctrl+S flush the debounce immediately. While a save is | |
| * in flight, a fresh keystroke aborts it via AbortController so only the | |
| * latest save wins. The status pill in the card head walks through | |
| * β Editable β β³ Savingβ¦ β β Saved / β Save failed β copy and order are | |
| * part of the spec, do not change. | |
| */ | |
| const _editSaveDebounceMs = 700; | |
| const _editSaveTimers = Object.create(null); // section_code -> setTimeout id | |
| const _editSaveAbortCtrls = Object.create(null); // section_code -> AbortController | |
| function _setEditStatus(code, kind) { | |
| const el = document.getElementById('es-' + code); | |
| if (!el) return; | |
| el.classList.remove('is-saving', 'is-saved', 'is-error'); | |
| if (kind === 'idle') { el.textContent = 'β Editable'; el.classList.remove('is-saving','is-saved','is-error'); } | |
| if (kind === 'saving') { el.textContent = 'β³ Savingβ¦'; el.classList.add('is-saving'); } | |
| if (kind === 'saved') { el.textContent = 'β Saved'; el.classList.add('is-saved'); } | |
| if (kind === 'error') { el.textContent = 'β Save failed'; el.classList.add('is-error'); } | |
| } | |
| async function _saveSectionEdit(code) { | |
| if (!state.reportId) return; | |
| const node = document.getElementById('rt-' + code); | |
| if (!node) return; | |
| // Read current text from the live DOM (innerText preserves visible line | |
| // breaks; textContent would collapse them differently across browsers). | |
| const text = node.innerText; | |
| // Cancel any in-flight save for this section so only the latest wins. | |
| // The aborted call throws and is caught below β we deliberately do not | |
| // surface "AbortError" as a save-failure to the user. | |
| const previous = _editSaveAbortCtrls[code]; | |
| if (previous) { try { previous.abort(); } catch {} } | |
| const ctrl = new AbortController(); | |
| _editSaveAbortCtrls[code] = ctrl; | |
| _setEditStatus(code, 'saving'); | |
| try { | |
| const updated = await apiFetch( | |
| 'PATCH', | |
| `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}`, | |
| { text }, | |
| {}, | |
| { signal: ctrl.signal } | |
| ); | |
| // Success: keep the in-memory state in sync so a subsequent reRunSection | |
| // sends the edited body as draft_paragraph instead of the original LLM text. | |
| if (updated && typeof updated === 'object') { | |
| state.results[code] = updated; | |
| } else { | |
| // Defensive: even if the server returned something unexpected, at least | |
| // update the text field so re-runs use the edit. | |
| state.results[code] = state.results[code] || {}; | |
| state.results[code].text = text; | |
| } | |
| // Visual hint that the section has been touched. | |
| node.classList.add('edited'); | |
| // Only mark "Saved" if this controller is still the latest one β if a | |
| // newer keystroke kicked off another save while we were awaiting, we | |
| // should not stomp its in-progress pill state. | |
| if (_editSaveAbortCtrls[code] === ctrl) { | |
| _setEditStatus(code, 'saved'); | |
| } | |
| } catch (err) { | |
| if (err && (err.name === 'AbortError' || (err.message || '').toLowerCase().includes('aborted'))) { | |
| // Superseded by a newer save β leave the pill alone, the new request | |
| // owns the status now. | |
| return; | |
| } | |
| console.error('[saveSectionEdit]', code, err); | |
| if (_editSaveAbortCtrls[code] === ctrl) { | |
| _setEditStatus(code, 'error'); | |
| // Surface a non-blocking toast so the failure is not silently swallowed. | |
| showToast(`Save failed for section ${code}: ${err.message || 'request failed'}`, 'err'); | |
| } | |
| } | |
| } | |
| function bindResultTextEditing(code) { | |
| const node = document.getElementById('rt-' + code); | |
| if (!node) return; | |
| // The .result-text node is recreated on every render; we never need to | |
| // detach listeners from a previous instance because the previous instance | |
| // is already gone (replaceWith / innerHTML = '' before re-append). | |
| // We do, however, need to reset any timer/abort-controller keyed by code | |
| // so a stale debounce from the prior render does not fire against the | |
| // freshly painted node. | |
| if (_editSaveTimers[code]) { clearTimeout(_editSaveTimers[code]); delete _editSaveTimers[code]; } | |
| if (_editSaveAbortCtrls[code]) { try { _editSaveAbortCtrls[code].abort(); } catch {} delete _editSaveAbortCtrls[code]; } | |
| // Initial pill state. | |
| _setEditStatus(code, 'idle'); | |
| node.addEventListener('input', () => { | |
| // Debounce: every keystroke pushes the save out by 700 ms. If a save is | |
| // already in flight (user is typing fast), abort it so the next save | |
| // carries the latest text. | |
| if (_editSaveTimers[code]) clearTimeout(_editSaveTimers[code]); | |
| if (_editSaveAbortCtrls[code]) { | |
| try { _editSaveAbortCtrls[code].abort(); } catch {} | |
| delete _editSaveAbortCtrls[code]; | |
| } | |
| _setEditStatus(code, 'saving'); // reflect "user is typing β save coming" | |
| _editSaveTimers[code] = setTimeout(() => { | |
| delete _editSaveTimers[code]; | |
| _saveSectionEdit(code); | |
| }, _editSaveDebounceMs); | |
| }); | |
| // Blur flushes the pending debounce immediately. | |
| node.addEventListener('blur', () => { | |
| if (_editSaveTimers[code]) { | |
| clearTimeout(_editSaveTimers[code]); | |
| delete _editSaveTimers[code]; | |
| _saveSectionEdit(code); | |
| } | |
| }); | |
| // Cmd/Ctrl+S: flush + suppress the browser's "save page" dialog. | |
| node.addEventListener('keydown', (ev) => { | |
| const isSave = (ev.key === 's' || ev.key === 'S') && (ev.metaKey || ev.ctrlKey); | |
| if (!isSave) return; | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| if (_editSaveTimers[code]) { | |
| clearTimeout(_editSaveTimers[code]); | |
| delete _editSaveTimers[code]; | |
| } | |
| _saveSectionEdit(code); | |
| }); | |
| } | |
| function buildCitationAudit(audit) { | |
| if (!audit || typeof audit !== 'object') return ''; | |
| const conf = typeof audit.confidence === 'number' ? Math.round(audit.confidence * 100) : null; | |
| const findings = typeof audit.findings === 'number' ? audit.findings : 0; | |
| const contradictions = Array.isArray(audit.contradictions) ? audit.contradictions : []; | |
| const dropped = Array.isArray(audit.dropped_claims) ? audit.dropped_claims : []; | |
| // Nothing meaningful to show (e.g. no API key / no findings) β stay quiet. | |
| if (conf === null && !findings && !contradictions.length && !dropped.length) return ''; | |
| const clean = !contradictions.length && !dropped.length; | |
| const tone = clean | |
| ? 'border:1px solid #bfe3c6;background:#f1faf3;color:#1f6b35' | |
| : 'border:1px solid #f0d6b4;background:#fff7ea;color:#7a4a00'; | |
| const icon = clean ? 'β ' : 'β οΈ'; | |
| const confStr = conf !== null ? `${conf}%` : 'n/a'; | |
| let body = `<strong>${icon} Evidence audit:</strong> ${findings} grounded finding(s), ` | |
| + `confidence ${confStr}.`; | |
| if (contradictions.length) { | |
| body += `<details style="margin-top:.4rem"><summary style="cursor:pointer">` | |
| + `${contradictions.length} contradiction(s) resolved</summary>` | |
| + `<ul style="margin:.35rem 0 0 1.1rem;list-style:disc">` | |
| + contradictions.map(c => `<li>${escapeHtml((c.element || '') + ': ' + (c.detail || ''))}` | |
| + (c.resolution ? ` <em>(${escapeHtml(c.resolution)})</em>` : '') + `</li>`).join('') | |
| + `</ul></details>`; | |
| } | |
| if (dropped.length) { | |
| body += `<details style="margin-top:.4rem"><summary style="cursor:pointer">` | |
| + `${dropped.length} unsupported claim(s) dropped</summary>` | |
| + `<ul style="margin:.35rem 0 0 1.1rem;list-style:disc">` | |
| + dropped.map(d => `<li>${escapeHtml(String(d))}</li>`).join('') | |
| + `</ul></details>`; | |
| } | |
| return `<div class="section-transparency" style="${tone}">${body}</div>`; | |
| } | |
| function buildCitationChips(prov) { | |
| if (!prov || !prov.length) return ''; | |
| const chips = prov.map((p, i) => { | |
| const fn = p.filename || ('Document ' + (p.doc_id || '').slice(0, 8) + 'β¦'); | |
| const tip = [fn, p.section_hint || '', (p.snippet_preview || '').slice(0, 280)].filter(Boolean).join(' β '); | |
| return `<span class="cite-chip" title="${escapeHtml(tip)}"><kbd>${i + 1}</kbd> ${escapeHtml(fn)}</span>`; | |
| }).join(''); | |
| return `<div class="cite-row"><span class="cite-label">Sources</span>${chips}</div>`; | |
| } | |
| function updateAggregateAiTransparency(sections) { | |
| const panel = $('ai-transparency-panel'); | |
| const chipsEl = $('interference-aggregate-chips'); | |
| const sumEl = $('ai-transparency-summary'); | |
| if (!panel || !chipsEl || !sumEl) return; | |
| const vals = Object.values(sections || {}).filter( | |
| (s) => s && (s.ai_transparency || s.interference_level || s.composition_depth), | |
| ); | |
| if (!vals.length) { | |
| panel.classList.add('hidden'); | |
| return; | |
| } | |
| panel.classList.remove('hidden'); | |
| const counts = { minimum: 0, medium: 0, maximum: 0 }; | |
| vals.forEach((s) => { | |
| const il = interferenceFromSectionPayload(s) || 'maximum'; | |
| if (counts[il] !== undefined) counts[il] += 1; | |
| else counts.medium += 1; | |
| }); | |
| const parts = ['minimum', 'medium', 'maximum'].filter((k) => counts[k] > 0).map( | |
| (k) => `<span class="interference-result-badge">${INTERFERENCE_MODES[k].badge}: ${counts[k]}</span>`, | |
| ); | |
| chipsEl.innerHTML = parts.join('') || '<span class="interference-result-badge">Mixed / legacy sections</span>'; | |
| sumEl.innerHTML = | |
| `<p>Sections mapped from your past reports: <strong>${vals.length}</strong>. ` + | |
| `Breakdown by <strong>composition depth</strong> (as stored when each section ran).</p>` + | |
| `<p style="margin-top:.5rem;font-size:.78rem;opacity:.9">Retrieval is tenant-isolated. No third-party or shared βglobalβ document corpus is used.</p>`; | |
| } | |
| function showToast(message, kind) { | |
| const el = $('app-toast'); | |
| if (!el) return; | |
| el.textContent = message; | |
| el.className = 'app-toast toast-' + (kind || 'ok'); | |
| el.classList.remove('hidden'); | |
| clearTimeout(window.__toastTimer); | |
| window.__toastTimer = setTimeout(() => { el.classList.add('hidden'); }, 3200); | |
| } | |
| function escapeHtml(t) { | |
| return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| /* ββ RAG Document Manager ββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function _docMgrResolveTenant() { | |
| if (state.tenantId) return state.tenantId; | |
| const typed = ($('tenant-input') && $('tenant-input').value || '').trim(); | |
| if (typed) { state.tenantId = typed; } | |
| return state.tenantId; | |
| } | |
| function formatBytes(n) { | |
| if (n == null || isNaN(n)) return 'β'; | |
| if (n < 1024) return `${n} B`; | |
| const units = ['KB', 'MB', 'GB']; | |
| let val = n / 1024, i = 0; | |
| while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } | |
| return `${val.toFixed(val >= 10 ? 0 : 1)} ${units[i]}`; | |
| } | |
| function formatDateTime(iso) { | |
| if (iso == null || iso === '') return 'β'; | |
| // v2 backend stores Unix epoch seconds (float); legacy app uses ISO strings. | |
| if (typeof iso === 'number' && Number.isFinite(iso)) { | |
| const d = new Date(iso > 1e12 ? iso : iso * 1000); | |
| if (isNaN(d.getTime())) return 'β'; | |
| return d.toLocaleString(undefined, { | |
| year: 'numeric', month: 'short', day: 'numeric', | |
| hour: '2-digit', minute: '2-digit', | |
| }); | |
| } | |
| let s = String(iso).trim(); | |
| if (/^\d+(\.\d+)?$/.test(s)) { | |
| const n = Number(s); | |
| const d = new Date(n > 1e12 ? n : n * 1000); | |
| if (!isNaN(d.getTime())) { | |
| return d.toLocaleString(undefined, { | |
| year: 'numeric', month: 'short', day: 'numeric', | |
| hour: '2-digit', minute: '2-digit', | |
| }); | |
| } | |
| } | |
| // Server timestamps are UTC; SQLite often omits the offset in JSON. | |
| if (s && !/Z$|[+-]\d{2}:\d{2}$/.test(s)) s += 'Z'; | |
| const d = new Date(s); | |
| if (isNaN(d.getTime())) return 'β'; | |
| return d.toLocaleString(undefined, { | |
| year: 'numeric', | |
| month: 'short', | |
| day: 'numeric', | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| }); | |
| } | |
| const DOC_STATUS_BADGE = { | |
| complete: { cls: 'b-ready', label: 'Ready' }, | |
| processing: { cls: 'b-processing', label: 'Processing' }, | |
| pending: { cls: 'b-pending', label: 'Pending' }, | |
| failed: { cls: 'b-failed', label: 'Failed' }, | |
| }; | |
| function openDocManager() { | |
| const tid = _docMgrResolveTenant(); | |
| $('doc-manager-modal').classList.remove('hidden'); | |
| if (tid) { | |
| $('tenant-badge').textContent = `π ${tid}`; | |
| $('tenant-badge').classList.add('visible'); | |
| loadDocManagerList(); | |
| } else { | |
| $('doc-manager-list').innerHTML = | |
| '<div class="docmgr-empty"><span class="icon">π</span>Enter your Tenant / User ID on the upload screen (or upload a file) to view your documents.</div>'; | |
| $('docmgr-count').textContent = ''; | |
| } | |
| } | |
| function closeDocManager() { | |
| $('doc-manager-modal').classList.add('hidden'); | |
| } | |
| async function reingestAllDocuments() { | |
| const btn = $('docmgr-reingest'); | |
| if (!confirm('Re-ingest every document through the latest parser/chunker? ' | |
| + 'Existing chunks are replaced. Documents with a report currently generating are skipped.')) { | |
| return; | |
| } | |
| const original = btn ? btn.textContent : ''; | |
| if (btn) { btn.disabled = true; btn.textContent = 'β³ Re-ingestingβ¦'; } | |
| try { | |
| const res = await apiFetch('POST', '/documents/reingest', {}); | |
| showToast((res && res.detail) || `Re-queued ${res ? res.queued : 0} document(s).`, 'ok'); | |
| await loadDocManagerList({ silent: true }); | |
| let polls = 0; | |
| if (window.__docmgrPoll) clearInterval(window.__docmgrPoll); | |
| window.__docmgrPoll = setInterval(async () => { | |
| polls += 1; | |
| try { | |
| const data = await loadDocManagerList({ silent: true }); | |
| const processing = (data && data.reingest_progress && data.reingest_progress.processing) || 0; | |
| const running = (data && data.reingest_running) || processing > 0; | |
| if (!running && !processing) { | |
| clearInterval(window.__docmgrPoll); | |
| window.__docmgrPoll = null; | |
| } else if (polls >= 360) { | |
| clearInterval(window.__docmgrPoll); | |
| window.__docmgrPoll = null; | |
| } | |
| } catch (_) { | |
| clearInterval(window.__docmgrPoll); | |
| window.__docmgrPoll = null; | |
| } | |
| }, 2000); | |
| } catch (err) { | |
| showToast('Re-ingest failed: ' + (err && err.message ? err.message : 'request failed'), 'err'); | |
| } finally { | |
| if (btn) { btn.disabled = false; btn.textContent = original; } | |
| } | |
| } | |
| async function loadDocManagerList(options = {}) { | |
| const silent = options.silent === true; | |
| const listEl = $('doc-manager-list'); | |
| const countEl = $('docmgr-count'); | |
| if (!silent) { | |
| listEl.innerHTML = '<div class="docmgr-loading"><span class="docmgr-spinner"></span> Loading your documentsβ¦</div>'; | |
| countEl.textContent = ''; | |
| } | |
| try { | |
| const data = await apiFetch('GET', '/documents?limit=500', null); | |
| const docs = (data && data.documents) || []; | |
| if (!docs.length) { | |
| listEl.innerHTML = | |
| '<div class="docmgr-empty"><span class="icon">π</span>No documents uploaded yet under this tenant.</div>'; | |
| return; | |
| } | |
| const sessionSet = new Set(state.sessionDocIds || []); | |
| _docMgrNames = {}; | |
| const rows = docs.map(d => { | |
| _docMgrNames[d.document_id] = d.filename || 'untitled'; | |
| const badge = DOC_STATUS_BADGE[d.status] || DOC_STATUS_BADGE.pending; | |
| const isSession = sessionSet.has(d.document_id); | |
| const isStyle = d.document_purpose === 'style_corpus'; | |
| const sessionTag = isSession | |
| ? '<span class="docmgr-badge b-session" title="Uploaded during this session">This session</span>' | |
| : ''; | |
| const styleTag = isStyle | |
| ? '<span class="docmgr-badge b-style" title="Past report kept for style learning only">Style</span>' | |
| : ''; | |
| const errAttr = d.error ? ` title="${escapeHtml(String(d.error))}"` : ''; | |
| return `<tr id="docrow-${escapeHtml(d.document_id)}"> | |
| <td> | |
| <div class="docmgr-fname">${escapeHtml(d.filename || 'untitled')}${sessionTag}${styleTag}</div> | |
| <div class="docmgr-id">${escapeHtml(d.document_id)}</div> | |
| </td> | |
| <td class="docmgr-meta">${formatDateTime(d.created_at)}</td> | |
| <td class="docmgr-meta">${formatBytes(d.file_size)}</td> | |
| <td><span class="docmgr-badge ${badge.cls}"${errAttr}>${badge.label}</span></td> | |
| <td style="text-align:right"> | |
| <button type="button" class="docmgr-del" onclick="confirmDocDelete('${escapeHtml(d.document_id)}')">π Delete</button> | |
| </td> | |
| </tr>`; | |
| }).join(''); | |
| listEl.innerHTML = `<table class="docmgr-table"> | |
| <thead><tr><th>Document</th><th>Uploaded</th><th>Size</th><th>Status</th><th></th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| const sessionCount = docs.filter(d => sessionSet.has(d.document_id)).length; | |
| let countText = `${docs.length} document${docs.length === 1 ? '' : 's'}`; | |
| if (sessionCount) countText += ` Β· ${sessionCount} from this session`; | |
| const prog = data.reingest_progress; | |
| if (prog && (data.reingest_running || prog.processing > 0)) { | |
| const done = (prog.complete || 0) + (prog.failed || 0); | |
| countText += ` Β· Re-ingesting ${done}/${prog.total}`; | |
| } | |
| countEl.textContent = countText; | |
| return data; | |
| } catch (err) { | |
| if (!silent) { | |
| listEl.innerHTML = | |
| `<div class="docmgr-empty"><span class="icon">β οΈ</span>Could not load documents: ${escapeHtml(err.message || 'request failed')}</div>`; | |
| } | |
| return null; | |
| } | |
| } | |
| let _pendingDeleteDocId = null; | |
| let _docMgrNames = {}; | |
| function confirmDocDelete(docId) { | |
| _pendingDeleteDocId = docId; | |
| const filename = _docMgrNames[docId] || 'this document'; | |
| $('doc-delete-body').innerHTML = ` | |
| <p style="margin:0 0 .8rem;line-height:1.5"> | |
| Permanently delete <strong>${escapeHtml(filename)}</strong>? | |
| </p> | |
| <p style="margin:0 0 1.1rem;font-size:.82rem;color:var(--muted);line-height:1.5"> | |
| This removes the file, its database record, and all vector embeddings for this document from the search index. | |
| Reports already generated are unaffected. <strong>This cannot be undone.</strong> | |
| </p> | |
| <div style="display:flex;justify-content:flex-end;gap:.6rem"> | |
| <button type="button" class="btn-similar" onclick="cancelDocDelete()">Cancel</button> | |
| <button type="button" class="docmgr-del" id="doc-delete-confirm-btn" onclick="executeDocDelete()">π Delete permanently</button> | |
| </div>`; | |
| $('doc-delete-modal').classList.remove('hidden'); | |
| } | |
| function cancelDocDelete() { | |
| _pendingDeleteDocId = null; | |
| $('doc-delete-modal').classList.add('hidden'); | |
| } | |
| async function executeDocDelete() { | |
| const docId = _pendingDeleteDocId; | |
| if (!docId) return; | |
| const confirmBtn = $('doc-delete-confirm-btn'); | |
| if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.innerHTML = '<span class="docmgr-spinner"></span> Deletingβ¦'; } | |
| const row = $(`docrow-${docId}`); | |
| if (row) row.classList.add('row-deleting'); | |
| try { | |
| const res = await apiFetch('DELETE', `/documents/${encodeURIComponent(docId)}`, null); | |
| cancelDocDelete(); | |
| state.docIds = (state.docIds || []).filter(id => id !== docId); | |
| state.sessionDocIds = (state.sessionDocIds || []).filter(id => id !== docId); | |
| showToast((res && res.detail) || 'Document deleted.', 'ok'); | |
| await loadDocManagerList(); | |
| } catch (err) { | |
| cancelDocDelete(); | |
| if (row) row.classList.remove('row-deleting'); | |
| const msg = err.message || 'Delete failed'; | |
| // 409 = still linked to a report (FK guard) β surface the server's guidance. | |
| showToast(msg.length > 160 ? msg.slice(0, 157) + 'β¦' : msg, 'err'); | |
| } | |
| } | |
| window._lastLibraryMatches = []; | |
| function peerSectionsMap(excludeCode) { | |
| const o = {}; | |
| RICS_SECTIONS.forEach(sec => { | |
| if (sec.code === excludeCode) return; | |
| const b = (state.sections[sec.code]?.bullets || '').trim(); | |
| if (b) o[sec.code] = b; | |
| }); | |
| return o; | |
| } | |
| async function runSimilarityCheck(code) { | |
| if (!state.tenantId) { | |
| showToast('Complete the upload step so your tenant ID is set, then try again.', 'warn'); | |
| return; | |
| } | |
| saveBullets(code); | |
| const ta = $(`bullets-${code}`); | |
| const text = (ta && ta.value || '').trim(); | |
| if (text.length < 8) { | |
| showToast('Add a bit more text first (at least one full line).', 'warn'); | |
| return; | |
| } | |
| $('similar-modal').classList.remove('hidden'); | |
| $('similar-modal-body').innerHTML = '<p class="similar-loading">Searching your indexed uploads and comparing other sectionsβ¦</p>'; | |
| try { | |
| const payload = { | |
| text, | |
| section_code: code, | |
| limit: 8, | |
| min_relevance_percent: 28, | |
| peer_sections: peerSectionsMap(code), | |
| }; | |
| if (state.docId) { | |
| payload.exclude_document_ids = [state.docId]; | |
| } | |
| const data = await apiFetch('POST', '/content/similar', payload); | |
| renderSimilarResults(data, code); | |
| } catch (e) { | |
| $('similar-modal-body').innerHTML = '<p class="similar-err">' + escapeHtml(e.message || String(e)) + '</p>'; | |
| } | |
| } | |
| function closeSimilarityModal() { | |
| $('similar-modal').classList.add('hidden'); | |
| } | |
| function renderSimilarResults(data, code) { | |
| const body = $('similar-modal-body'); | |
| let html = ''; | |
| if (data.message) { | |
| html += '<p class="similar-intro">' + escapeHtml(data.message) + '</p>'; | |
| } else { | |
| html += '<p class="similar-intro">We found material that may overlap or repeat what you are typing. Choose how to keep your notes accurate and avoid outdated or duplicate guidance.</p>'; | |
| if (state.docId) { | |
| html += '<p class="similar-intro" style="margin-top:-.5rem;font-size:.8rem">Library matches skip your <strong>current survey upload</strong> so you mainly see other reference files (guidance, old reports, regulations). Upload those as extra documents to compare against.</p>'; | |
| } | |
| } | |
| const drafts = data.draft_overlaps || []; | |
| const lib = data.library_matches || []; | |
| window._lastLibraryMatches = lib; | |
| window._lastDraftOverlaps = drafts; | |
| if (!drafts.length && !lib.length) { | |
| html += '<p class="similar-empty">No strong matches in other sections or your indexed documents. You can close and continue.</p>'; | |
| body.innerHTML = html; | |
| return; | |
| } | |
| if (drafts.length) { | |
| html += '<h4 class="similar-h4">Overlaps with other section notes</h4>'; | |
| drafts.forEach((d, i) => { | |
| html += '<div class="similar-card draft-card" id="sdraft-' + i + '">'; | |
| html += '<div class="similar-meta">Section <strong>' + escapeHtml(d.other_section_code) + '</strong> Β· ' + escapeHtml(d.overlap_kind) + ' Β· similarity ' + Math.round(d.similarity * 100) + '%</div>'; | |
| html += '<div class="similar-cols"><div><span class="lbl">This section</span><pre class="similar-pre">' + escapeHtml(d.your_preview) + '</pre></div>'; | |
| html += '<div><span class="lbl">Other section</span><pre class="similar-pre">' + escapeHtml(d.other_preview) + '</pre></div></div>'; | |
| html += '<div class="similar-actions">'; | |
| html += '<button type="button" class="btn-sm" onclick="goToPeerSection(\'' + d.other_section_code + '\')">Open other section</button> '; | |
| html += '<button type="button" class="btn-sm" onclick="appendPeerWording(\'' + code + '\',' + i + ')">Add other section as new line</button> '; | |
| html += '<button type="button" class="btn-sm" onclick="replaceNotesWithPeerSection(\'' + code + '\',' + i + ')">Replace notes with other section</button> '; | |
| html += '<button type="button" class="btn-sm btn-muted" onclick="dismissSimilarCard(\'sdraft-' + i + '\')">Keep both</button>'; | |
| html += '</div></div>'; | |
| }); | |
| } | |
| if (lib.length) { | |
| html += '<h4 class="similar-h4">Similar passages in uploaded documents</h4>'; | |
| lib.forEach((m, i) => { | |
| html += '<div class="similar-card lib-card" id="slib-' + i + '">'; | |
| html += '<div class="similar-meta">' + escapeHtml(m.filename || 'Your upload') + ' Β· relevance ' + escapeHtml(String(m.relevance_percent)) + '%</div>'; | |
| html += '<pre class="similar-pre">' + escapeHtml(m.snippet) + '</pre>'; | |
| html += '<div class="similar-actions">'; | |
| html += '<button type="button" class="btn-sm" onclick="appendLibraryExcerpt(\'' + code + '\',' + i + ')">Add excerpt as new line</button> '; | |
| html += '<button type="button" class="btn-sm" onclick="replaceNotesWithExcerpt(\'' + code + '\',' + i + ')">Replace notes with excerpt</button> '; | |
| html += '<button type="button" class="btn-sm btn-muted" onclick="dismissSimilarCard(\'slib-' + i + '\')">Keep my notes</button> '; | |
| html += '<button type="button" class="btn-sm btn-danger" onclick="removeLibraryDocument(\'' + m.document_id + '\')">Remove file from library</button>'; | |
| html += '</div></div>'; | |
| }); | |
| } | |
| body.innerHTML = html; | |
| } | |
| function appendPeerWording(code, idx) { | |
| const d = window._lastDraftOverlaps && window._lastDraftOverlaps[idx]; | |
| if (!d) return; | |
| const ta = $(`bullets-${code}`); | |
| const line = (d.other_preview || '').replace(/\n+/g, ' ').trim(); | |
| const add = line + ' [From section ' + d.other_section_code + ']'; | |
| ta.value = (ta.value.replace(/\s+$/, '') + (ta.value.trim() ? '\n' : '') + add); | |
| saveBullets(code); | |
| showToast('Added wording from section ' + d.other_section_code + '.', 'ok'); | |
| } | |
| function replaceNotesWithPeerSection(code, idx) { | |
| const d = window._lastDraftOverlaps && window._lastDraftOverlaps[idx]; | |
| if (!d) return; | |
| if (!confirm('Replace all notes in this section with the matched text from section ' + d.other_section_code + '?')) return; | |
| $(`bullets-${code}`).value = (d.other_preview || '').trim(); | |
| saveBullets(code); | |
| showToast('Notes replaced from section ' + d.other_section_code + '.', 'ok'); | |
| closeSimilarityModal(); | |
| } | |
| function appendLibraryExcerpt(code, idx) { | |
| const m = window._lastLibraryMatches && window._lastLibraryMatches[idx]; | |
| if (!m) return; | |
| const ta = $(`bullets-${code}`); | |
| const cite = (m.filename || 'upload').replace(/\s+/g, ' '); | |
| const line = (m.snippet || '').replace(/\n+/g, ' ').trim(); | |
| const add = line + ' [Ref: ' + cite + ']'; | |
| ta.value = (ta.value.replace(/\s+$/, '') + (ta.value.trim() ? '\n' : '') + add); | |
| saveBullets(code); | |
| showToast('Added a referenced line from your library.', 'ok'); | |
| } | |
| function replaceNotesWithExcerpt(code, idx) { | |
| const m = window._lastLibraryMatches && window._lastLibraryMatches[idx]; | |
| if (!m) return; | |
| if (!confirm('Replace all notes in this section with the library excerpt?')) return; | |
| $(`bullets-${code}`).value = (m.snippet || '').trim(); | |
| saveBullets(code); | |
| showToast('Notes replaced with library excerpt.', 'ok'); | |
| closeSimilarityModal(); | |
| } | |
| async function removeLibraryDocument(docId) { | |
| if (!docId) return; | |
| if (!confirm('Remove this file from your library? Indexed passages are deleted. This is blocked if a report still uses the file.')) return; | |
| try { | |
| await apiFetch('DELETE', '/documents/' + encodeURIComponent(docId), null); | |
| showToast('Document removed from library.', 'ok'); | |
| closeSimilarityModal(); | |
| } catch (e) { | |
| showToast(e.message || String(e), 'err'); | |
| } | |
| } | |
| function dismissSimilarCard(id) { | |
| const el = document.getElementById(id); | |
| if (el) el.classList.add('hidden'); | |
| } | |
| function goToPeerSection(code) { | |
| closeSimilarityModal(); | |
| const card = $(`sc-${code}`); | |
| if (card) { | |
| $(`sb-${code}`).classList.add('open'); | |
| card.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| } | |
| } | |
| function highlightVerify(text) { | |
| return text.replace(/\[VERIFY[^\]]*\]/g, m => `<span class="verify-tag">${m}</span>`); | |
| } | |
| async function copySection(code) { | |
| const el = $(`rt-${code}`); | |
| if (!el) return; | |
| try { | |
| await navigator.clipboard.writeText(el.innerText); | |
| const btns = el.closest('.result-body').querySelectorAll('button'); | |
| const btn = btns[0]; | |
| const orig = btn.textContent; | |
| btn.textContent = 'β Copied!'; | |
| setTimeout(() => { btn.textContent = orig; }, 1500); | |
| } catch {} | |
| } | |
| async function reRunSection(code, mode) { | |
| const sec = RICS_SECTIONS.find(s => s.code === code); | |
| if (!sec) { | |
| console.warn('[reRunSection] unknown section code', code); | |
| return; | |
| } | |
| if (!state.reportId) { | |
| alert('No report in progress.'); | |
| return; | |
| } | |
| // β Correct & intentional: bullets are read fresh from state.sections[code] | |
| // every time the user re-runs a section. If the user edits the bullets in | |
| // step 3 (configure) and then comes back to the results page and re-runs, | |
| // those updated bullets are picked up here. Do NOT cache bullets at card | |
| // build time β that would make this read stale. (frontend audit, Issue 4.) | |
| const bullets = (state.sections[code]?.bullets || '') | |
| .split('\n').map(b => b.replace(/^[-β’*]\s*/, '').trim()).filter(Boolean); | |
| const modeLabels = { generate:'βοΈ Generatingβ¦', proofread:'π Proofreadingβ¦', enhance:'βοΈ Enhancingβ¦' }; | |
| // Look the card up by data-code (preferred) with an id fallback for | |
| // backward compatibility. Querying via .result-card .code-${mode} would | |
| // return the FIRST card sharing this mode and produce a wrong-card-blank | |
| // flash when several sections share a mode. (frontend audit, Issue 2.) | |
| const card = findResultCard(code); | |
| if (!card) { | |
| console.warn('[reRunSection] result card not found β section may not be generated yet', code); | |
| alert('This section is not in the results list yet. Generate the report first, then use Proofread / Enhance.'); | |
| return; | |
| } | |
| const actions = card.querySelector('.result-actions'); | |
| const btns = actions?.querySelectorAll('button'); | |
| if (btns) btns.forEach(b => { b.disabled = true; }); | |
| const statusEl = document.createElement('div'); | |
| statusEl.className = 'alert alert-info'; | |
| statusEl.style.marginTop = '.6rem'; | |
| statusEl.textContent = modeLabels[mode] || 'Processingβ¦'; | |
| card.querySelector('.result-body').appendChild(statusEl); | |
| const refs = referenceDocumentIdsForRag(); | |
| const rerunBody = { | |
| template_id: code, | |
| bullets: bullets.length ? bullets : [`[Section ${code}: ${sec.title}]`], | |
| mode: mode, | |
| interference_level: getInterferenceLevel(), | |
| retrieval_level: getRetrievalLevel(), | |
| // Respect the user's cache toggle. When cache is enabled, re-run can still | |
| // hit cache if inputs are identical (useful for quick restores). Turn cache | |
| // off to guarantee a fresh run. | |
| force_regenerate: !useCacheEnabled(), | |
| strict_uploaded_only: strictUploadsOnlyEnabled(), | |
| }; | |
| if (refs.length) rerunBody.reference_document_ids = refs; | |
| if (mode === 'generate') { | |
| const prev = state.results[code]; | |
| if (prev && prev.text) { | |
| const bodyOnly = String(prev.text).split('\n\n[Editor notes:')[0].trim(); | |
| if (bodyOnly) rerunBody.draft_paragraph = bodyOnly; | |
| } | |
| } | |
| try { | |
| await apiFetch('POST', `/reports/${state.reportId}/generate`, rerunBody); | |
| let done = false; | |
| for (let i = 0; i < 40; i++) { | |
| await sleep(2000); | |
| const st = await apiFetch('GET', `/reports/${state.reportId}/status`, null); | |
| if (isReportJobDone(st.status)) { done = true; break; } | |
| if (st.status === 'failed') throw new Error(st.error_message || 'Generation failed on server'); | |
| } | |
| if (!done) throw new Error('Timed out waiting for completion'); | |
| const data = await apiFetch('GET', `/reports/${state.reportId}/sections`, null); | |
| const result = (data.sections || {})[code]; | |
| if (result) { | |
| state.results[code] = result; | |
| if (result.style_profile) showStyleProfile(result.style_profile); | |
| } | |
| statusEl.remove(); | |
| // Targeted single-card update (frontend audit, Issue 3). loadResults() | |
| // would otherwise re-fetch and re-render all sections (35+ cards on a | |
| // Level 3 report) just to refresh one card. renderResultCard() builds a | |
| // single replacement node from state.results[code] β same visual output | |
| // as loadResults() for that one card β and swaps it in place via | |
| // findResultCard(code).replaceWith(...). The other cards are untouched. | |
| renderResultCard(code); | |
| const doneLabel = { generate: 'Re-generated', proofread: 'Proofread', enhance: 'Enhanced' }; | |
| showToast((doneLabel[mode] || 'Updated') + ': section ' + code + ' is ready.', 'ok'); | |
| } catch (err) { | |
| console.error('[reRunSection]', mode, code, err); | |
| statusEl.className = 'alert alert-error'; | |
| statusEl.textContent = 'β ' + (err.message || 'Request failed'); | |
| showToast(err.message || 'Request failed', 'err'); | |
| if (btns) btns.forEach(b => { b.disabled = false; }); | |
| } | |
| } | |
| /* ββ Style Profile Display βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function showStyleProfile(profile) { | |
| state.styleProfile = profile; | |
| const panel = $('style-profile-panel'); | |
| panel.classList.remove('hidden'); | |
| const chips = [ | |
| { label: 'Tone', value: profile.tone || 'formal' }, | |
| { label: 'Formality', value: profile.formality_level || 'professional' }, | |
| { label: 'Sentence Style', value: profile.avg_sentence_complexity || 'moderate' }, | |
| { label: 'Vocabulary', value: profile.vocabulary_level || 'technical' }, | |
| ]; | |
| $('style-chips').innerHTML = chips.map(c => ` | |
| <div class="style-chip"> | |
| <div class="sc-label">${c.label}</div> | |
| <div class="sc-value">${c.value}</div> | |
| </div>`).join(''); | |
| $('style-summary').textContent = profile.writing_style_summary || ''; | |
| const phrases = profile.common_phrases || []; | |
| $('style-phrases').innerHTML = phrases.length | |
| ? `<div style="font-size:.72rem;opacity:.8;margin-right:.3rem">Common phrases:</div>` + | |
| phrases.slice(0, 6).map(p => `<span class="style-phrase">"${p}"</span>`).join('') | |
| : ''; | |
| } | |
| /* ββ Download Report βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* ββ Download DOCX βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $('btn-download-docx').addEventListener('click', async () => { | |
| if (!state.reportId) { alert('No report available yet. Generate sections first.'); return; } | |
| const btn = $('btn-download-docx'); | |
| btn.disabled = true; | |
| btn.textContent = 'β³ Buildingβ¦'; | |
| try { | |
| if (!state.token) { alert('Log in to download the report.'); return; } | |
| const res = await fetch(apiUrl(`/reports/${state.reportId}/export?format=docx`), { | |
| method: 'GET', | |
| headers: buildAuthHeaders(), | |
| }); | |
| if (res.status === 401) { handleAuthExpired(); throw new Error('Your session expired. Please log in again.'); } | |
| if (!res.ok) { | |
| const data = await res.json().catch(() => ({})); | |
| const detail = data && data.detail; | |
| throw new Error(typeof detail === 'string' ? detail : `HTTP ${res.status}`); | |
| } | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| const cd = res.headers.get('Content-Disposition') || ''; | |
| const match = cd.match(/filename="([^"]+)"/); | |
| a.download = match ? match[1] : `RICS_Report_${Date.now()}.docx`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } catch (err) { | |
| alert('Download failed: ' + err.message); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = 'β¬ Download DOCX'; | |
| } | |
| }); | |
| $('btn-download').addEventListener('click', () => { | |
| const lines = []; | |
| lines.push('RICS SURVEY REPORT'); | |
| lines.push('Generated by Report Genius AI'); | |
| if (state.styleProfile) { | |
| lines.push(`Writing Style: ${state.styleProfile.tone} | ${state.styleProfile.formality_level}`); | |
| } | |
| lines.push('='.repeat(50)); | |
| lines.push(''); | |
| RICS_SECTIONS.forEach(sec => { | |
| const result = state.results[sec.code]; | |
| lines.push(`${sec.code}. ${sec.title}`); | |
| lines.push('-'.repeat(40)); | |
| if (state.sections[sec.code]?.action === 'keep') { | |
| lines.push('[LEFT BLANK FOR MANUAL WRITING]'); | |
| } else if (result) { | |
| lines.push(result.text); | |
| if (result.mode && result.mode !== 'generate') lines.push(`[AI Mode: ${result.mode}]`); | |
| } else { | |
| lines.push('[NOT GENERATED]'); | |
| } | |
| lines.push(''); | |
| }); | |
| const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `RICS_Report_${Date.now()}.txt`; | |
| a.click(); | |
| }); | |
| /* ββ Restart βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| $('btn-restart').addEventListener('click', () => { | |
| // Preserve the authenticated identity (token + tenant) across a new report. | |
| Object.assign(state, { docId:null, docIds:[], reportId:null, file:null, files:[], sections:{}, results:{}, styleProfile:null }); | |
| fileInput.value = ''; | |
| $('file-display').classList.add('hidden'); | |
| zone.classList.remove('has-file','drag-over'); | |
| $('btn-upload').disabled = true; | |
| $('style-profile-panel').classList.add('hidden'); | |
| hideAlert('upload-alert'); | |
| if (state.tenantId) { | |
| const ti = $('tenant-input'); | |
| if (ti) { ti.value = state.tenantId; ti.readOnly = true; } | |
| } | |
| showStep(1); | |
| }); | |
| showStep(1); | |
| /* ββ Theme Toggle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| (function initTheme() { | |
| const saved = localStorage.getItem('rg-theme'); | |
| const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| if (saved === 'dark' || (!saved && prefersDark)) applyTheme('dark'); | |
| else applyTheme('light'); | |
| })(); | |
| function applyTheme(mode) { | |
| const html = document.documentElement; | |
| const icon = document.getElementById('theme-icon'); | |
| const label = document.getElementById('theme-label'); | |
| if (mode === 'dark') { | |
| html.classList.add('dark'); | |
| if (icon) icon.textContent = 'π§οΈ'; | |
| if (label) label.textContent = 'Dark'; | |
| } else { | |
| html.classList.remove('dark'); | |
| if (icon) icon.textContent = 'βοΈ'; | |
| if (label) label.textContent = 'Light'; | |
| } | |
| localStorage.setItem('rg-theme', mode); | |
| } | |
| function toggleTheme() { | |
| const isDark = document.documentElement.classList.contains('dark'); | |
| applyTheme(isDark ? 'light' : 'dark'); | |
| } | |
| </script> | |
| </body> | |
| </html> | |