Spaces:
Sleeping
Sleeping
| <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; } | |
| .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; } | |
| .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 ; } | |
| /* AI Interference Level (3 qualitative modes) */ | |
| .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> | |
| <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="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> | |
| <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> | |
| <!-- AI Interference Level (3 qualitative modes) --> | |
| <div class="ai-interference-panel"> | |
| <div class="ai-interference-title">AI Interference Level</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="AI interference level"> | |
| <button type="button" class="interference-pill" data-level="minimum" aria-pressed="false" title="Strict formatter: map your notes onto the standard structure. No invented facts; gaps stay blank or marked."> | |
| <span class="pill-icon" aria-hidden="true">π</span> | |
| <span class="pill-label">Minimum AI Interference</span> | |
| <span class="pill-tagline">Notes mapped literally onto standard paragraphs; uploads only for terminology.</span> | |
| </button> | |
| <button type="button" class="interference-pill active" data-level="medium" aria-pressed="true" title="Professional editor: light cleanup, short bridges, only unambiguous inferences from your notes."> | |
| <span class="pill-icon" aria-hidden="true">βοΈ</span> | |
| <span class="pill-label">Medium AI Interference</span> | |
| <span class="pill-tagline">Clear prose and light transitions; every claim traceable to your notes.</span> | |
| </button> | |
| <button type="button" class="interference-pill" data-level="maximum" aria-pressed="false" title="Expert drafting: rich structure and narrative; still no fabricated facts; messy note wording not surfaced verbatim."> | |
| <span class="pill-icon" aria-hidden="true">π</span> | |
| <span class="pill-label">Maximum AI Interference</span> | |
| <span class="pill-tagline">Publication-style report within strict anti-hallucination rules; longest runs.</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 (full report target: under 10 minutes)</p> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <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>AI interference (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>AI Interference Level</strong> stored per section when it was generated (minimum, medium, or maximum). Retrieval uses passages from <em>your uploaded documents</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> | |
| <!-- ββ 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, | |
| docId: null, | |
| docIds: [], // all accepted document UUIDs from last batch upload | |
| 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: 'medium', | |
| reportStillGenerating: false, | |
| }; | |
| 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 = [ | |
| // ββ A-D: Overview ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'A', group:'A', title:'Introduction to the report', hint:'client name Β· report reference Β· surveyor name Β· company Β· inspection date Β· related party disclosure' }, | |
| { code:'B', group:'B', title:'About the inspection', hint:'full address Β· weather conditions Β· property status (occupied/vacant) Β· access limitations Β· floor covering restrictions' }, | |
| { code:'C', group:'C', title:'Overall assessment and summary of condition ratings', hint:'overall opinion Β· Cat 3 urgent items Β· Cat 2 items Β· Cat 1 items Β· further investigations recommended' }, | |
| { code:'D', group:'D', title:'About the property', hint:'property type Β· storeys Β· orientation Β· build year Β· extension Β· loft conversion Β· accommodation schedule Β· roof/wall/floor construction Β· means of escape Β· security Β· services (gas/elec/water) Β· heating type Β· EPC rating Β· grounds Β· location Β· facilities Β· local environment' }, | |
| // ββ E: Outside βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'E1', group:'E', title:'Chimney stacks', hint:'number of stacks Β· brick/stone construction Β· pots Β· flaunchings Β· flashings Β· TV aerial fixings Β· lean or movement Β· condition rating' }, | |
| { code:'E2', group:'E', title:'Roof coverings', hint:'main roof structure (hip/gable/flat) Β· covering material (slate/tile/felt/GRP) Β· slipped tiles Β· sagging Β· moss/lichen Β· valley gutters Β· flat roof covering Β· dormer roof Β· condition rating' }, | |
| { code:'E3', group:'E', title:'Rainwater pipes and gutters', hint:'material (plastic/cast iron) Β· gutter type Β· staining/leakage evidence Β· blockage Β· joints Β· downpipe discharge Β· condition rating' }, | |
| { code:'E4', group:'E', title:'Main walls', hint:'construction (solid/cavity brick) Β· wall thickness Β· DPC type and height Β· cracking/movement Β· damp penetration Β· pointing condition Β· render condition Β· parapet walls Β· condition rating' }, | |
| { code:'E5', group:'E', title:'Windows', hint:'type (uPVC/timber/aluminium) Β· glazing (single/double) Β· FENSA certificate Β· condensation between panes Β· frame decay Β· seal condition Β· safety glass Β· condition rating' }, | |
| { code:'E6', group:'E', title:'Outside doors (including patio doors)', hint:'entrance door material Β· patio door type Β· glazing Β· safety glass Β· seal condition Β· security locks Β· frame condition Β· condition rating' }, | |
| { code:'E7', group:'E', title:'Conservatory and porches', hint:'present or absent Β· construction type Β· glazing Β· condensation/mould Β· structural movement Β· condition rating' }, | |
| { code:'E8', group:'E', title:'Other joinery and finishes', hint:'fascia/soffit material Β· rot or decay Β· asbestos in soffits Β· decorative features Β· paintwork condition Β· condition rating' }, | |
| { code:'E9', group:'E', title:'Other (external elements)', hint:'balconies Β· terraces Β· external stairways Β· loft conversion note Β· made ground Β· contaminated land Β· condition rating' }, | |
| // ββ F: Inside ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'F1', group:'F', title:'Roof structure', hint:'access available Β· structure type (trussed rafter/cut rafter) Β· insulation type/depth Β· ventilation adequacy Β· wood boring insects Β· rot/damp Β· birds/bats/vermin Β· loft converted Β· condition rating' }, | |
| { code:'F2', group:'F', title:'Ceilings', hint:'ceiling type (plasterboard/lath & plaster) Β· cracking Β· damp staining Β· asbestos risk Β· decorative condition Β· condition rating' }, | |
| { code:'F3', group:'F', title:'Walls and partitions', hint:'construction type Β· damp meter readings Β· rising/penetrating damp Β· cracking Β· structural alterations Β· RSJ/lintel Β· dry lining Β· asbestos Β· condition rating' }, | |
| { code:'F4', group:'F', title:'Floors', hint:'ground floor type (solid/suspended timber) Β· upper floor type Β· sub-floor ventilation Β· wood boring insects Β· damp below floor Β· springiness Β· condition rating' }, | |
| { code:'F5', group:'F', title:'Fireplaces, chimney breasts and flues', hint:'fireplaces present Β· type (open/blocked/removed) Β· chimney breast removed Β· building regs approval Β· flue liner Β· solid fuel advice Β· condition rating' }, | |
| { code:'F6', group:'F', title:'Built-in fittings', hint:'fitted wardrobes Β· built-in storage Β· kitchen units Β· damp or insects affecting fittings Β· condition rating' }, | |
| { code:'F7', group:'F', title:'Woodwork (staircase and joinery)', hint:'staircase construction Β· staircase condition Β· internal doors Β· skirtings/architraves Β· wood boring beetle Β· damp signs Β· decoration standard Β· condition rating' }, | |
| { code:'F8', group:'F', title:'Bathroom and kitchen fittings', hint:'bathroom suite type Β· sanitary ware condition Β· kitchen units Β· tile condition Β· ventilation Β· condensation/damp Β· visible defects Β· condition rating' }, | |
| { code:'F9', group:'F', title:'Other (integral garages, cellars, loft conversions)', hint:'cellar/basement Β· integral garage Β· loft conversion adequacy Β· asbestos risk Β· damp/flooding Β· condition rating' }, | |
| // ββ G: Services ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'G1', group:'G', title:'Electricity', hint:'supply type Β· consumer unit location/type Β· wiring age/type Β· earthing Β· RCD protection Β· test certificate date Β· electrician recommendation Β· condition rating' }, | |
| { code:'G2', group:'G', title:'Gas/oil', hint:'gas supply type Β· meter location Β· pipework material/condition Β· Gas Safe certificate Β· boiler make Β· condition rating' }, | |
| { code:'G3', group:'G', title:'Water', hint:'supply source (mains/private) Β· internal stopcock location Β· external stopcock Β· pipework material Β· leakage evidence Β· shared supply Β· condition rating' }, | |
| { code:'G4', group:'G', title:'Heating', hint:'heating type (gas CH/electric/oil) Β· boiler make/model Β· boiler age/location Β· last service date Β· controls type Β· radiator condition Β· Gas Safe recommendation Β· condition rating' }, | |
| { code:'G5', group:'G', title:'Water heating', hint:'hot water system type (combi/cylinder) Β· cylinder condition Β· immersion heater Β· solar thermal Β· condition rating' }, | |
| { code:'G6', group:'G', title:'Drainage', hint:'drain covers lifted Β· drain condition Β· soil vent pipe Β· shared drainage Β· blockage evidence Β· septic tank Β· condition rating' }, | |
| { code:'G7', group:'G', title:'Common services (flats only)', hint:'entry phone Β· lift Β· CCTV Β· fire alarm Β· emergency lighting Β· condition rating (NI if house)' }, | |
| { code:'G8', group:'G', title:'Other services/features', hint:'solar PV panels Β· solar thermal Β· wind turbine Β· heat pump Β· feed-in tariff Β· condition rating' }, | |
| // ββ H: Grounds βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'H1', group:'H', title:'Garages', hint:'garage present Β· type (attached/detached) Β· construction Β· asbestos roof Β· up-and-over door Β· condition rating' }, | |
| { code:'H2', group:'H', title:'Permanent outbuildings and other structures', hint:'outbuildings present Β· type Β· construction Β· condition Β· condition rating' }, | |
| { code:'H3', group:'H', title:'Other (grounds)', hint:'garden description Β· boundary walls/fences Β· ownership Β· retaining walls Β· significant trees Β· paths/drives Β· parking Β· shared areas for flats' }, | |
| // ββ I: Legal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'I1', group:'I', title:'Regulations', hint:'planning permission for extension/loft Β· building regulations compliance Β· listed building Β· conservation area Β· rights of way Β· permitted development' }, | |
| { code:'I2', group:'I', title:'Guarantees', hint:'damp proofing guarantee Β· FENSA window certificate Β· NHBC new build Β· structural warranty Β· other guarantees' }, | |
| { code:'I3', group:'I', title:'Other matters for legal advisers', hint:'tenure (freehold/leasehold) Β· lease term remaining Β· service charge Β· ground rent Β· boundary disputes Β· chancel repair Β· other matters' }, | |
| // ββ J: Risks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'J1', group:'J', title:'Risks to the building', hint:'Cat 3 items β structural movement Β· subsidence/heave Β· dampness type Β· timber decay/beetles Β· non-traditional construction' }, | |
| { code:'J2', group:'J', title:'Risks to the grounds', hint:'flood zone Β· radon area Β· mining area Β· Japanese knotweed Β· tree root proximity Β· shrinkable subsoil (clay)' }, | |
| { code:'J3', group:'J', title:'Risks to people', hint:'asbestos risk Β· absence of safety glass Β· lead water pipes Β· lead paint Β· fire escape adequacy Β· gas leaks Β· garden ponds/drops' }, | |
| { code:'J4', group:'J', title:'Other risks or hazards', hint:'flight path/noise Β· nearby planning proposals Β· intrusive noise/smells Β· other hazards' }, | |
| // ββ K: Energy ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'K1', group:'K', title:'Insulation', hint:'loft insulation depth Β· wall insulation type Β· floor insulation Β· insulation recommendations' }, | |
| { code:'K2', group:'K', title:'Heating (energy)', hint:'heating system type Β· boiler efficiency Β· thermostat/zone controls Β· recommendations' }, | |
| { code:'K3', group:'K', title:'Lighting', hint:'natural lighting adequacy Β· LED/low energy lighting Β· artificial lighting recommendations' }, | |
| { code:'K4', group:'K', title:'Ventilation', hint:'bathroom/kitchen ventilation Β· trickle vents Β· whole-house ventilation Β· recommendations' }, | |
| { code:'K5', group:'K', title:'Energy efficiency β general', hint:'EPC current/potential rating Β· solar panels Β· heat pump Β· general energy improvement recommendations' }, | |
| // ββ L: Declaration βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'L', group:'L', title:"Surveyor's declaration", hint:'surveyor name Β· RICS number Β· qualifications Β· company name and address Β· inspection date' }, | |
| ]; | |
| let RICS_SECTIONS = STATIC_L3_SECTIONS.slice(); | |
| let PHOTO_POLICY = {}; // section_code -> { policy, reason } | |
| 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 }; }); | |
| } | |
| 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(); | |
| } | |
| async function apiFetch(method, path, body, headers = {}, options = {}) { | |
| const opts = { method, headers: { 'X-Tenant-ID': state.tenantId, ...headers } }; | |
| 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 === 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; | |
| } | |
| /* ββ 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; } | |
| let tid = $('tenant-input').value.trim(); | |
| if (!tid) { tid = 'tenant_' + Math.random().toString(36).slice(2, 10); $('tenant-input').value = tid; } | |
| state.tenantId = tid; | |
| $('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'); | |
| $('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); | |
| 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 β Introduction to the report', | |
| B:'B β About the inspection', | |
| C:'C β Overall assessment', | |
| D:'D β About the property', | |
| E:'E β Outside the property', | |
| F:'F β Inside the property', | |
| G:'G β Services', | |
| H:'H β Grounds', | |
| I:'I β Issues for your legal advisers', | |
| J:'J β Risks', | |
| K:'K β Energy efficiency', | |
| L:'L β Surveyor\'s declaration', | |
| }; | |
| 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> | |
| <div class="photo-thumbs" id="photo-thumbs-${code}" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px"></div> | |
| </div> | |
| `; | |
| refreshSectionPhotos(code); | |
| } | |
| async function refreshSectionPhotos(code) { | |
| if (!state.reportId) return; | |
| const thumbs = $(`photo-thumbs-${code}`); | |
| if (!thumbs) return; | |
| try { | |
| const res = await apiFetch('GET', `/reports/${encodeURIComponent(state.reportId)}/sections/${encodeURIComponent(code)}/photos`, null); | |
| const photos = res.photos || []; | |
| thumbs.innerHTML = ''; | |
| photos.forEach(p => { | |
| const a = document.createElement('a'); | |
| a.href = p.url; | |
| a.target = '_blank'; | |
| a.style.textDecoration = 'none'; | |
| const img = document.createElement('img'); | |
| img.src = p.url; | |
| img.alt = p.original_filename || 'photo'; | |
| img.style.cssText = 'width:86px;height:64px;object-fit:cover;border-radius:10px;border:1px solid #d8deea'; | |
| a.appendChild(img); | |
| thumbs.appendChild(a); | |
| }); | |
| } catch (e) { | |
| thumbs.innerHTML = ''; | |
| } | |
| } | |
| async function uploadSectionPhotos(code) { | |
| const inp = document.getElementById(`photo-input-${code}`); | |
| if (!inp || !inp.files || inp.files.length === 0) 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(); | |
| } | |
| /** | |
| * Keyword router: assign each extracted line to the best-matching RICS section. | |
| * Uses the canonical RICS Level 3 section codes (E1-E9, F1-F9 etc.). | |
| * Returns { code β [lines] }. | |
| */ | |
| function routeLinesToSections(lines) { | |
| // Order matters: more specific patterns first, more general last. | |
| const routes = [ | |
| // ββ D: Property description ββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'D', rx:/\bsemi.?det|\bdet(ached)?\b|terraced|flat\b|maisonette|postcode|nw\d|sw\d|se\d|e\d|n\d|w\d|19[0-9]{2}|20[0-9]{2}|sqm|sq\.?m\b|floor area|accommodation|storey|storey|storey|tenure|leasehold|freehold|type of prop|orientation|faces|epc|energy perf/i }, | |
| // ββ E: Outside βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'E1', rx:/chimney\s*stack|chimney\s*pot|flaunching|chimney\s*lean|chimney\s*crack|chimney\s*aeri/i }, | |
| { code:'E2', rx:/roof\s*cover|slate|tile\s*roof|felt\s*roof|grp\s*roof|flat\s*roof|hip\s*roof|gable|ridge\s*tile|valley\s*gutter|roof\s*light|dormer\s*roof|slipp|roof\s*deflect|moss.*roof|lichen.*roof/i }, | |
| { code:'E3', rx:/rainwater|gutter|downpipe|down\s*pipe|fascia\s*gutter|gutter\s*block|gutter\s*leak|gutter\s*joint|black\s*mould.*gutter|stain.*gutter/i }, | |
| { code:'E4', rx:/main\s*wall|brick\s*wall|cavity\s*wall|solid\s*wall|parapet|dpc\b|damp\s*proof\s*course|wall\s*crack|render|pointing|spall|movement.*wall|wall.*movement|wall.*damp|wall.*stain|wall\s*thickness/i }, | |
| { code:'E5', rx:/window\s*frame|upvc\s*window|timber\s*window|double\s*glaz|fensa|glazing\s*unit|window\s*seal|window\s*cond|sash\s*window|bay\s*window/i }, | |
| { code:'E6', rx:/front\s*door|entrance\s*door|patio\s*door|external\s*door|back\s*door|door\s*frame.*ext|ext.*door\s*frame/i }, | |
| { code:'E7', rx:/conservatory|porch\b/i }, | |
| { code:'E8', rx:/fascia\b|soffit\b|eaves.*timber|timber.*eaves|ext.*joinery|joinery.*ext|rot.*ext|ext.*rot|asbestos.*soffit/i }, | |
| { code:'E9', rx:/balcony|terrace.*ext|ext.*stairway|loft\s*conv|made\s*ground|contaminated\s*land/i }, | |
| // ββ F: Inside ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'F1', rx:/roof\s*struct|loft\s*space|roof\s*void|roof\s*timb|truss|rafter|insul.*loft|loft.*insul|roof\s*access|purlin|joist.*roof|wood.*boring.*roof|rot.*roof/i }, | |
| { code:'F2', rx:/ceil\b|ceiling/i }, | |
| { code:'F3', rx:/internal\s*wall|partition|plaster.*wall|wall.*plaster|damp\s*meter|rising\s*damp|penetrat.*damp|wall.*crack.*int|structural\s*alt|rsj\b|steel\s*beam|dry\s*lin/i }, | |
| { code:'F4', rx:/floor\s*board|suspended\s*floor|solid\s*floor|sub.?floor|floor.*damp|floor.*insect|floor.*spring|under.?floor/i }, | |
| { code:'F5', rx:/fireplace|chimney\s*breast|flue|fire\s*surround|blocked\s*fire|chimney\s*breast\s*remov/i }, | |
| { code:'F6', rx:/built.?in|fitted\s*ward|fitted\s*cupboard/i }, | |
| { code:'F7', rx:/staircase|stair\b|balustrade|internal\s*door|skirting|architrave|internal\s*joinery|wood.*boring.*insect|beetle/i }, | |
| { code:'F8', rx:/bathroom|bath\b|shower|toilet|wc\b|sanitary|kitchen\s*unit|worktop|tile.*bath|tile.*kitchen|kitchen.*tile/i }, | |
| { code:'F9', rx:/cellar|basement|integral\s*garage|loft\s*conv.*int/i }, | |
| // ββ G: Services ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'G1', rx:/electric|consumer\s*unit|fuse\s*board|wiring|earthing|rcd\b|niceic|napit|elec.*test/i }, | |
| { code:'G2', rx:/gas\b|oil\s*tank|oil\s*boil|gas\s*meter|gas\s*pipe|gas\s*safe|ofgas|gas\s*supply/i }, | |
| { code:'G3', rx:/water\s*supply|stopcock|water\s*main|water\s*pipe|copper\s*pipe|leakag|water.*pressure|mains\s*water/i }, | |
| { code:'G4', rx:/boiler|central\s*heat|ch\b|radiator|heating\s*system|vaillant|worcester|ideal\s*boil|heat.*eng|gas\s*safe.*heat/i }, | |
| { code:'G5', rx:/hot\s*water|cylinder|immersion|unvented|solar\s*hot\s*water|solar\s*thermal/i }, | |
| { code:'G6', rx:/drain|sewer|manhole|inspection\s*chamber|soil\s*pipe|vent\s*pipe|svp\b|septic\s*tank|cesspool/i }, | |
| { code:'G7', rx:/entry\s*phone|intercom|lift\b|cctv|fire\s*alarm|emergency\s*light|common\s*service/i }, | |
| { code:'G8', rx:/solar\s*pv|solar\s*panel|photovolt|wind\s*turbin|heat\s*pump|feed.?in\s*tariff|renewable/i }, | |
| // ββ H: Grounds βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'H1', rx:/\bgarage\b/i }, | |
| { code:'H2', rx:/outbuilding|shed\b|store\b|workshop/i }, | |
| { code:'H3', rx:/garden|boundary|fence\b|wall.*bound|bound.*wall|retaining\s*wall|tree\b|palm|drive\b|driveway|path\b|parking|shared\s*area|communal\s*area/i }, | |
| // ββ I: Legal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'I1', rx:/planning\s*permiss|building\s*reg|listed\s*build|conservation\s*area|right\s*of\s*way|easement|permitted\s*dev/i }, | |
| { code:'I2', rx:/guarantee\b|warranty\b|fensa\s*certif|nhbc\b|indemnit/i }, | |
| { code:'I3', rx:/leasehold|freehold|lease\s*term|service\s*charge|ground\s*rent|chancel|legal\s*adviser|conveyancing/i }, | |
| // ββ J: Risks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'J1', rx:/structural\s*eng|subsid|heave\b|movement.*found|found.*movement|timber\s*decay|dry\s*rot|wet\s*rot|non.?traditional\s*construct/i }, | |
| { code:'J2', rx:/flood\s*(risk|zone|plain)|radon|mining|knotweed|shrinkable\s*subsoil|clay\s*subsoil/i }, | |
| { code:'J3', rx:/asbestos|safety\s*glass|lead\s*(pipe|paint|water)|fire\s*escape|means\s*of\s*escape|carbon\s*monoxide|pond\b/i }, | |
| { code:'J4', rx:/flight\s*path|noise\s*(pollution|nuisance)|planning\s*proposal|hazard/i }, | |
| // ββ K: Energy ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { code:'K1', rx:/insulation|loft\s*insul|wall\s*insul|cavity\s*insul|floor\s*insul|draught/i }, | |
| { code:'K2', rx:/heating\s*effic|boiler\s*effic|thermostat|zone\s*control|smart\s*meter/i }, | |
| { code:'K3', rx:/lighting\b|led\b|natural\s*light/i }, | |
| { code:'K4', rx:/ventilation|trickle\s*vent|mhrv\b|extractor\s*fan|mvhr\b/i }, | |
| { code:'K5', rx:/epc\b|energy\s*perf\s*certif|energy\s*efficien|carbon|green\s*deal/i }, | |
| // ββ C: Summary (only if no other match) ββββββββββββββββββββββββββββββββββ | |
| { code:'C', rx:/cat(egory)?\s*[123]|condition\s*rat|urgent\s*repair|further\s*invest|overall\s*opinion/i }, | |
| // ββ A/B: intro/inspection details ββββββββββββββββββββββββββββββββββββββββ | |
| { code:'A', rx:/client\s*name|report\s*ref|related\s*party|terms.*engage|confi?dential/i }, | |
| { code:'B', rx:/weather\s*cond|dry\s*at.*inspection|occupied.*inspect|inspect.*occupied|access.*hatch|floor\s*covering.*precl/i }, | |
| { code:'L', rx:/surveyor.*declar|i\s+confirm.*inspect/i }, | |
| ]; | |
| // Stage 1 β route on the FULL Level-3 universe so every regex hit lands on a real key. | |
| // STATIC_L3_SECTIONS always contains all 41 codes regardless of the report's active level, | |
| // so this stage cannot throw "Cannot read properties of undefined (reading 'push')". | |
| const l3Routed = Object.fromEntries(STATIC_L3_SECTIONS.map(s => [s.code, []])); | |
| const unmatched = []; | |
| for (const line of lines) { | |
| const t = line.trim(); | |
| if (!t || t.length < 3) continue; | |
| let placed = false; | |
| for (const { code, rx } of routes) { | |
| if (rx.test(t)) { | |
| // Safe push β l3Routed is keyed by every L3 code by construction. | |
| l3Routed[code].push(t); | |
| placed = true; | |
| break; | |
| } | |
| } | |
| if (!placed) unmatched.push(t); | |
| } | |
| // Default-bucket unmatched text into E4 (Main walls) β the most general inspection | |
| // section that exists on Level 3. Stage 2 will fold/redirect this for L1/L2. | |
| if (unmatched.length) l3Routed['E4'].push(...unmatched); | |
| // Stage 2 β collapse L3 codes to the closest active code on the current survey level. | |
| // Without this, on Level 1 (sections A, B, C, D, E, F, G, H, I, J, K, L) an L3-style | |
| // hit like E1/F2/K3 would be silently dropped or misrouted. The collapse rules: | |
| // 1. Direct match: E2 stays as E2 if the active level has E2. | |
| // 2. Group letter: E2 β E if the active level uses single-letter element codes (L1). | |
| // 3. Highest active subsection in the same group: on L2, E6/E7/E8/E9 fold to E5 | |
| // (the last existing E-section), F7/F8/F9 fold to F6, etc. β content is preserved | |
| // and lands in the same area of the report rather than disappearing. | |
| // 4. Otherwise the line is truly orphaned and falls back to the first active section. | |
| 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: {}, // l3_code -> active_code | |
| droppedToFallback: 0, | |
| fallbackCode: null, | |
| }; | |
| 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; // null when nothing in the group is active | |
| } | |
| 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 { | |
| // No code in this group is active (e.g. K-anything on a hypothetical pack with no | |
| // K section). Spill into the first active section so content is never silently lost. | |
| const fb = RICS_SECTIONS[0]?.code || null; | |
| if (fb && result[fb]) { | |
| result[fb].push(...lineList); | |
| stats.fallbackCode = fb; | |
| stats.droppedToFallback += lineList.length; | |
| } | |
| } | |
| } | |
| 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); | |
| const res = await fetch(apiUrl('/extract-notes'), { | |
| method: 'POST', | |
| headers: { 'X-Tenant-ID': state.tenantId || 'default' }, | |
| body: fd, | |
| }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) throw new Error(data.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; | |
| } | |
| } | |
| const { routed, stats } = 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 = ''; | |
| } | |
| } | |
| /* ββ AI Interference Level (minimum | medium | maximum) βββββββββββββββββββ */ | |
| const INTERFERENCE_MODES = { | |
| minimum: { | |
| label: 'Minimum AI Interference', | |
| badge: 'Minimum interference', | |
| oneLine: 'Strict formatter: your notes are mapped as literally as possible onto the standard structure; uploads clarify terminology only.', | |
| tooltip: 'No invented facts. Gaps stay blank or marked. Output density follows your notes.', | |
| }, | |
| medium: { | |
| label: 'Medium AI Interference', | |
| badge: 'Medium interference', | |
| oneLine: 'Professional editor: light cleanup, minimal bridges, only clear inferences from your notes β still fully traceable.', | |
| tooltip: 'Typically modestly longer than minimum for the same notes, without adding new claims.', | |
| }, | |
| maximum: { | |
| label: 'Maximum AI Interference', | |
| badge: 'Maximum interference', | |
| oneLine: 'Expert-grade structure and prose within strict rules; may take several minutes per section.', | |
| tooltip: 'Longest outputs; never fabricate; do not surface rough note phrasing verbatim; uploads are background context only.', | |
| }, | |
| }; | |
| 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 || 'medium'; | |
| } | |
| 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) || 'medium'; | |
| const cur = getInterferenceLevel(); | |
| if (next === cur) return true; | |
| if (!skipConfirm && hasGeneratedReportContent()) { | |
| const ok = confirm('Changing the AI Interference level 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}`; | |
| const emptyNotes = toAI.filter(s => !(state.sections[s.code].bullets || '').trim()).length; | |
| if (emptyNotes > 0 && emptyNotes < toAI.length) { | |
| console.warn( | |
| `[generate] ${emptyNotes}/${toAI.length} AI sections have no inspection notes β those will stay blank unless you add bullets.` | |
| ); | |
| } | |
| showStep(4); | |
| state.reportStillGenerating = false; | |
| if (toAI.length > 1 && canBatchSections(toAI)) { | |
| await generateSectionsBatch(toAI); | |
| } else { | |
| for (const sec of toAI) { | |
| await generateSection(sec); | |
| } | |
| } | |
| await loadResults(); | |
| showStep(5); | |
| if (state.reportStillGenerating) { | |
| showToast( | |
| 'Still generating on server (Introduction often finishes first). Wait, then refresh or Re-Generate other sections.', | |
| 'warn' | |
| ); | |
| } | |
| } | |
| 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'; | |
| } | |
| /** Product SLA: full report should complete within 10 minutes (server parallel sections). */ | |
| const GENERATION_SLA_MS = 10 * 60 * 1000; | |
| /** Poll budget for full-report batch jobs β always wait at least the 10m SLA before giving up. */ | |
| function generationPollConfig(sectionCount, task, interferenceLevel) { | |
| const maxMode = interferenceLevel === 'maximum' && task === 'generate'; | |
| const pollMs = maxMode ? 3000 : 2000; | |
| const perSection = maxMode ? 45 : 32; | |
| const minPolls = Math.ceil(GENERATION_SLA_MS / pollMs) + 30; | |
| const maxPolls = Math.min(1500, Math.max(minPolls, sectionCount * perSection)); | |
| return { pollMs, maxPolls, slaMs: GENERATION_SLA_MS }; | |
| } | |
| async function pollReportGenerationStatus(pollMs, maxPolls) { | |
| let last = null; | |
| for (let i = 0; i < maxPolls; i++) { | |
| await sleep(pollMs); | |
| last = await apiFetch('GET', `/reports/${state.reportId}/status`, null); | |
| if (isReportJobDone(last.status)) return last; | |
| if (last.status === 'failed') { | |
| throw new Error(last.error_message || 'Generation failed'); | |
| } | |
| } | |
| if (!last) last = await apiFetch('GET', `/reports/${state.reportId}/status`, null); | |
| if (isReportJobDone(last.status)) return last; | |
| if (last.status === 'failed') throw new Error(last.error_message || 'Generation failed'); | |
| return last; | |
| } | |
| 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); | |
| for (const sec of sections) { | |
| $(`gpf-${sec.code}`).style.width = '40%'; | |
| $(`gs-${sec.code}`).textContent = 'β³ Processingβ¦'; | |
| } | |
| const pollCfg = generationPollConfig(sections.length, task, il); | |
| let stFinal = await pollReportGenerationStatus(pollCfg.pollMs, pollCfg.maxPolls); | |
| if (stFinal.status === 'generating') { | |
| const extraPolls = Math.min(180, sections.length * 15); | |
| stFinal = await pollReportGenerationStatus(5000, extraPolls); | |
| } | |
| if (stFinal.status === 'generating') { | |
| state.reportStillGenerating = true; | |
| throw new Error( | |
| 'Server is still generating sections (often A first, then the rest). ' + | |
| 'Do not refresh away β open Results and use Re-Generate per section, or wait and refresh. ' + | |
| 'Cancelling was disabled so the backend job can continue.' | |
| ); | |
| } | |
| state.reportStillGenerating = false; | |
| let sectionPayloads = {}; | |
| 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 pollCfg = generationPollConfig(1, task, il); | |
| let stFinal = await pollReportGenerationStatus(pollCfg.pollMs, pollCfg.maxPolls); | |
| if (stFinal.status === 'generating') { | |
| stFinal = await pollReportGenerationStatus(5000, maxMode ? 60 : 30); | |
| } | |
| if (stFinal.status === 'generating') { | |
| throw new Error( | |
| 'Section still generating on the server. Wait a minute and refresh results, or Re-Generate.' | |
| ); | |
| } | |
| 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 trx = result.ai_transparency; | |
| const req = trx && typeof trx.requested_ai_involvement_percent === 'number' ? trx.requested_ai_involvement_percent : null; | |
| const meas = trx && typeof trx.measured_ai_involvement_percent === 'number' ? trx.measured_ai_involvement_percent : null; | |
| const headline = trx && typeof trx.ai_involvement_percent === 'number' ? trx.ai_involvement_percent : null; | |
| const transHtml = trx && typeof headline === 'number' ? ` | |
| <div class="section-transparency"> | |
| <strong>AI involvement:</strong> ${headline}%` + | |
| (req !== null && meas !== null ? ` <span style="opacity:.8">(requested ${req}%, measured ${meas}%)</span>` : '') + ` | |
| ${trx.plain_language ? ' β ' + escapeHtml(trx.plain_language) : ''} | |
| ${trx.transformations && trx.transformations.length ? '<details style="margin-top:.4rem"><summary style="cursor:pointer">What changed</summary><ul style="margin:.35rem 0 0 1.1rem;list-style:disc">' + trx.transformations.map(t => '<li>' + escapeHtml(t) + '</li>').join('') + '</ul></details>' : ''} | |
| </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} | |
| ${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 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 || typeof s.ai_percent === 'number' || typeof s.ai_level === 'number'), | |
| ); | |
| 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) || 'medium'; | |
| 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 with AI-generated content: <strong>${vals.length}</strong>. ` + | |
| `Breakdown by <strong>AI Interference Level</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,'"'); | |
| } | |
| 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 { | |
| const res = await fetch(apiUrl(`/reports/${state.reportId}/export?format=docx`), { | |
| method: 'GET', | |
| headers: { 'X-Tenant-ID': state.tenantId || 'default' }, | |
| }); | |
| if (!res.ok) { | |
| const data = await res.json().catch(() => ({})); | |
| throw new Error(data.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', () => { | |
| Object.assign(state, { tenantId:null, docId:null, docIds:[], reportId:null, file:null, files:[], sections:{}, results:{}, styleProfile:null }); | |
| fileInput.value = ''; | |
| $('tenant-input').value = ''; | |
| $('file-display').classList.add('hidden'); | |
| zone.classList.remove('has-file','drag-over'); | |
| $('btn-upload').disabled = true; | |
| $('tenant-badge').classList.remove('visible'); | |
| $('style-profile-panel').classList.add('hidden'); | |
| hideAlert('upload-alert'); | |
| 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> | |