RandomZ / frontend /index.html
StormShadow308's picture
Ship personalised RAG, 10m SLA, parallel generate, and AI phase docs for HF pilot.
be9fd4a
Raw
History Blame Contribute Delete
176 kB
<!DOCTYPE html>
<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 !important; }
/* 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) !important; }
.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 !important; }
.nus-err { background: #fdeaea; color: var(--danger); display: block !important; }
.nus-busy { background: #e3f0ff; color: var(--navy-lt); display: block !important; }
.nus-warn { background: #fff3cd; color: #856404; display: block !important; }
/* 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 !important; }
.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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>