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