StormShadow308 Cursor commited on
Commit
865bc90
Β·
1 Parent(s): 2e3ecc8

Ship production RAG hardening: citation extraction, full-library retrieval, auth.

Browse files

- Citation-grounded extraction pipeline with contradiction audit and OCR/table-aware ingest
- Tenant auth and RAG document manager (list/delete/re-ingest)
- Full-tenant library retrieval (old + new report_source docs) enabled by default
- Frontend: auth gate, citation audit display, document manager
- Regression tests for extraction, ingest normalization, and library widening
- Stop tracking local SQLite WAL files; extend .gitignore

Co-authored-by: Cursor <cursoragent@cursor.com>

.gitignore CHANGED
@@ -41,6 +41,8 @@ htmlcov/
41
 
42
  # Databases
43
  *.db
 
 
44
  *.sqlite
45
  *.sqlite3
46
  # DB backups (e.g. dev.db.bak-20260424-142846) β€” must NEVER be committed.
 
41
 
42
  # Databases
43
  *.db
44
+ *.db-shm
45
+ *.db-wal
46
  *.sqlite
47
  *.sqlite3
48
  # DB backups (e.g. dev.db.bak-20260424-142846) β€” must NEVER be committed.
app/api/auth.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tenant authentication: passphrase login + HMAC-signed bearer tokens.
2
+
3
+ The token's subject (``sub``) is the tenant_id. Because the token is signed with
4
+ a server-side secret, a client cannot forge a token for an arbitrary tenant, so
5
+ ``request.state.tenant_id`` derived from a verified token is trustworthy β€” this
6
+ is what enforces user isolation. Stdlib only (``hashlib``, ``hmac``, ``secrets``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import logging
16
+ import secrets
17
+ import time
18
+
19
+ from fastapi import APIRouter, Depends, HTTPException, Request
20
+ from sqlalchemy import select
21
+ from sqlalchemy.ext.asyncio import AsyncSession
22
+
23
+ from app.config import settings
24
+ from app.db.database import get_db
25
+ from app.db.models import Tenant
26
+ from app.models.schemas import AuthRequest, AuthTokenResponse, AuthWhoAmIResponse
27
+
28
+ logger = logging.getLogger(__name__)
29
+ router = APIRouter()
30
+
31
+ _PBKDF2_ITERATIONS = 200_000
32
+ _DEFAULT_SECRETS = frozenset({"", "dev-secret-change-me", "change-me-in-production"})
33
+
34
+ # Process-ephemeral fallback secret, generated once if the operator shipped a
35
+ # sentinel TENANT_SECRET_KEY in production. Tokens signed with it are
36
+ # unforgeable but do not survive a restart (every restart forces re-login).
37
+ _EPHEMERAL_SECRET: str | None = None
38
+
39
+
40
+ def _signing_key() -> bytes:
41
+ global _EPHEMERAL_SECRET
42
+ configured = (settings.tenant_secret_key or "").strip()
43
+ if configured and configured not in _DEFAULT_SECRETS:
44
+ return configured.encode("utf-8")
45
+ if settings.dev_mode:
46
+ # Stable across the dev session; fine for local use.
47
+ return (configured or "dev-secret-change-me").encode("utf-8")
48
+ if _EPHEMERAL_SECRET is None:
49
+ _EPHEMERAL_SECRET = secrets.token_urlsafe(48)
50
+ logger.warning(
51
+ "TENANT_SECRET_KEY is unset/default in production β€” using an "
52
+ "ephemeral signing key. Set TENANT_SECRET_KEY so issued tokens "
53
+ "survive restarts."
54
+ )
55
+ return _EPHEMERAL_SECRET.encode("utf-8")
56
+
57
+
58
+ # ── Password hashing ──────────────────────────────────────────────────────────
59
+
60
+ def hash_password(password: str, *, salt: str | None = None, iterations: int = _PBKDF2_ITERATIONS) -> tuple[str, str, int]:
61
+ """Return ``(hash_hex, salt_hex, iterations)`` for ``password``."""
62
+ salt_bytes = bytes.fromhex(salt) if salt else secrets.token_bytes(16)
63
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, iterations)
64
+ return dk.hex(), salt_bytes.hex(), iterations
65
+
66
+
67
+ def verify_password(password: str, *, hash_hex: str, salt_hex: str, iterations: int) -> bool:
68
+ candidate, _, _ = hash_password(password, salt=salt_hex, iterations=iterations)
69
+ return hmac.compare_digest(candidate, hash_hex)
70
+
71
+
72
+ # ── Token mint / verify ─────────────────────────────────────────────────────────
73
+
74
+ def _b64u_encode(raw: bytes) -> str:
75
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
76
+
77
+
78
+ def _b64u_decode(data: str) -> bytes:
79
+ pad = "=" * (-len(data) % 4)
80
+ return base64.urlsafe_b64decode(data + pad)
81
+
82
+
83
+ def mint_token(tenant_id: str, *, ttl_seconds: int | None = None) -> tuple[str, int]:
84
+ """Create a signed token for ``tenant_id``; returns ``(token, expires_at)``."""
85
+ now = int(time.time())
86
+ exp = now + int(ttl_seconds if ttl_seconds is not None else settings.auth_token_ttl_seconds)
87
+ payload = _b64u_encode(json.dumps({"sub": tenant_id, "iat": now, "exp": exp}, separators=(",", ":")).encode("utf-8"))
88
+ sig = _b64u_encode(hmac.new(_signing_key(), payload.encode("ascii"), hashlib.sha256).digest())
89
+ return f"{payload}.{sig}", exp
90
+
91
+
92
+ def verify_token(token: str) -> str | None:
93
+ """Return the tenant_id if ``token`` is valid and unexpired, else ``None``."""
94
+ if not token or "." not in token:
95
+ return None
96
+ payload_b64, _, sig_b64 = token.partition(".")
97
+ expected = _b64u_encode(hmac.new(_signing_key(), payload_b64.encode("ascii"), hashlib.sha256).digest())
98
+ if not hmac.compare_digest(sig_b64, expected):
99
+ return None
100
+ try:
101
+ payload = json.loads(_b64u_decode(payload_b64))
102
+ except (ValueError, json.JSONDecodeError):
103
+ return None
104
+ sub = payload.get("sub")
105
+ exp = payload.get("exp")
106
+ if not isinstance(sub, str) or not isinstance(exp, int):
107
+ return None
108
+ if exp < int(time.time()):
109
+ return None
110
+ return sub
111
+
112
+
113
+ # ── Validation ──────────────────────────────────────────────────────────────────
114
+
115
+ def _normalise_tenant_id(raw: str) -> str:
116
+ tid = (raw or "").strip()
117
+ if not (3 <= len(tid) <= 128):
118
+ raise HTTPException(status_code=422, detail="Tenant/User ID must be 3–128 characters.")
119
+ if not all(c.isalnum() or c in "._-" for c in tid):
120
+ raise HTTPException(
121
+ status_code=422,
122
+ detail="Tenant/User ID may only contain letters, digits, '.', '_' and '-'.",
123
+ )
124
+ return tid
125
+
126
+
127
+ def _validate_passphrase(pw: str) -> None:
128
+ if len(pw or "") < 8:
129
+ raise HTTPException(status_code=422, detail="Passphrase must be at least 8 characters.")
130
+
131
+
132
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
133
+
134
+ @router.post("/auth/register", response_model=AuthTokenResponse, status_code=201)
135
+ async def register(body: AuthRequest, db: AsyncSession = Depends(get_db)) -> AuthTokenResponse:
136
+ """Create a new tenant and return a signed access token."""
137
+ tenant_id = _normalise_tenant_id(body.tenant_id)
138
+ _validate_passphrase(body.passphrase)
139
+
140
+ existing = await db.get(Tenant, tenant_id)
141
+ if existing is not None:
142
+ raise HTTPException(status_code=409, detail="That Tenant/User ID is already taken. Log in instead.")
143
+
144
+ h, salt, iters = hash_password(body.passphrase)
145
+ db.add(Tenant(id=tenant_id, password_hash=h, password_salt=salt, pbkdf2_iterations=iters))
146
+ await db.commit()
147
+
148
+ token, exp = mint_token(tenant_id)
149
+ logger.info("Registered tenant=%s", tenant_id)
150
+ return AuthTokenResponse(tenant_id=tenant_id, access_token=token, expires_at=exp)
151
+
152
+
153
+ @router.post("/auth/login", response_model=AuthTokenResponse)
154
+ async def login(body: AuthRequest, db: AsyncSession = Depends(get_db)) -> AuthTokenResponse:
155
+ """Verify a tenant passphrase and return a signed access token."""
156
+ tenant_id = (body.tenant_id or "").strip()
157
+ tenant = await db.get(Tenant, tenant_id) if tenant_id else None
158
+ # Run a dummy verify even when the tenant is unknown to keep timing uniform.
159
+ if tenant is None:
160
+ hash_password(body.passphrase or "x")
161
+ raise HTTPException(status_code=401, detail="Invalid Tenant/User ID or passphrase.")
162
+
163
+ ok = verify_password(
164
+ body.passphrase or "",
165
+ hash_hex=tenant.password_hash,
166
+ salt_hex=tenant.password_salt,
167
+ iterations=tenant.pbkdf2_iterations,
168
+ )
169
+ if not ok:
170
+ raise HTTPException(status_code=401, detail="Invalid Tenant/User ID or passphrase.")
171
+
172
+ from datetime import UTC, datetime
173
+
174
+ tenant.last_login_at = datetime.now(UTC)
175
+ await db.commit()
176
+
177
+ token, exp = mint_token(tenant_id)
178
+ return AuthTokenResponse(tenant_id=tenant_id, access_token=token, expires_at=exp)
179
+
180
+
181
+ @router.get("/auth/me", response_model=AuthWhoAmIResponse)
182
+ async def whoami(request: Request) -> AuthWhoAmIResponse:
183
+ """Return the tenant resolved from the current request's verified token/header."""
184
+ return AuthWhoAmIResponse(tenant_id=request.state.tenant_id)
app/api/generate.py CHANGED
@@ -523,6 +523,7 @@ async def get_sections(
523
  interference_level=_meta_interference_level(meta),
524
  word_count=_meta_int(meta, "word_count"),
525
  generated_at=meta.get("generated_at") if isinstance(meta.get("generated_at"), str) else None,
 
526
  )
527
 
528
  return SectionsResponse(report_id=report_id, sections=sections)
 
523
  interference_level=_meta_interference_level(meta),
524
  word_count=_meta_int(meta, "word_count"),
525
  generated_at=meta.get("generated_at") if isinstance(meta.get("generated_at"), str) else None,
526
+ citation_audit=meta.get("citation_audit") if isinstance(meta.get("citation_audit"), dict) else None,
527
  )
528
 
529
  return SectionsResponse(report_id=report_id, sections=sections)
app/api/middleware.py CHANGED
@@ -1,4 +1,13 @@
1
- """Middleware that enforces a non-empty X-Tenant-ID header on every request."""
 
 
 
 
 
 
 
 
 
2
 
3
  from collections.abc import Awaitable, Callable
4
 
@@ -6,24 +15,34 @@ from starlette.middleware.base import BaseHTTPMiddleware
6
  from starlette.requests import Request
7
  from starlette.responses import JSONResponse, Response
8
 
9
- # Paths that do not require a tenant identity (exact match)
 
 
 
10
  _EXEMPT: frozenset[str] = frozenset(
11
- {"/", "/health", "/docs", "/openapi.json", "/redoc", "/favicon.ico"}
 
 
 
 
 
 
 
 
 
12
  )
13
 
14
- # Path prefixes that do not require a tenant identity
15
  _EXEMPT_PREFIXES: tuple[str, ...] = ("/static/",)
16
 
17
 
18
  class TenantAuthMiddleware(BaseHTTPMiddleware):
19
- """Reject requests that carry no ``X-Tenant-ID`` header.
20
-
21
- On success the header value is stored on ``request.state.tenant_id``
22
- so downstream handlers can read it without re-parsing headers.
23
 
24
  Example::
25
 
26
- curl -H "X-Tenant-ID: tenant_abc" http://localhost:8000/upload ...
 
27
  """
28
 
29
  async def dispatch(
@@ -35,12 +54,31 @@ class TenantAuthMiddleware(BaseHTTPMiddleware):
35
  if path in _EXEMPT or path.startswith(_EXEMPT_PREFIXES):
36
  return await call_next(request)
37
 
38
- tenant_id: str | None = request.headers.get("X-Tenant-ID")
39
- if not tenant_id or not tenant_id.strip():
40
  return JSONResponse(
41
  status_code=401,
42
- content={"detail": "Missing or empty X-Tenant-ID header"},
43
  )
44
 
45
- request.state.tenant_id = tenant_id.strip()
46
  return await call_next(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Middleware that authenticates every request and pins ``request.state.tenant_id``.
2
+
3
+ Identity is resolved from a signed bearer token (``Authorization: Bearer <token>``)
4
+ whose subject is the tenant_id. Because the token is HMAC-signed server-side, a
5
+ client cannot claim another tenant's data β€” this is what enforces isolation.
6
+
7
+ For local development (``DEV_MODE=true``) a raw ``X-Tenant-ID`` header is still
8
+ accepted as a fallback so existing tooling and tests keep working. In production
9
+ a valid token is mandatory.
10
+ """
11
 
12
  from collections.abc import Awaitable, Callable
13
 
 
15
  from starlette.requests import Request
16
  from starlette.responses import JSONResponse, Response
17
 
18
+ from app.api.auth import verify_token
19
+ from app.config import settings
20
+
21
+ # Paths that do not require authentication (exact match)
22
  _EXEMPT: frozenset[str] = frozenset(
23
+ {
24
+ "/",
25
+ "/health",
26
+ "/docs",
27
+ "/openapi.json",
28
+ "/redoc",
29
+ "/favicon.ico",
30
+ "/auth/login",
31
+ "/auth/register",
32
+ }
33
  )
34
 
35
+ # Path prefixes that do not require authentication
36
  _EXEMPT_PREFIXES: tuple[str, ...] = ("/static/",)
37
 
38
 
39
  class TenantAuthMiddleware(BaseHTTPMiddleware):
40
+ """Authenticate the caller and expose the verified tenant on request state.
 
 
 
41
 
42
  Example::
43
 
44
+ # Obtain a token, then call protected endpoints with it:
45
+ curl -H "Authorization: Bearer <token>" http://localhost:8000/documents
46
  """
47
 
48
  async def dispatch(
 
54
  if path in _EXEMPT or path.startswith(_EXEMPT_PREFIXES):
55
  return await call_next(request)
56
 
57
+ tenant_id = self._resolve_tenant(request)
58
+ if tenant_id is None:
59
  return JSONResponse(
60
  status_code=401,
61
+ content={"detail": "Authentication required. Log in to obtain a token."},
62
  )
63
 
64
+ request.state.tenant_id = tenant_id
65
  return await call_next(request)
66
+
67
+ @staticmethod
68
+ def _resolve_tenant(request: Request) -> str | None:
69
+ auth = request.headers.get("Authorization", "")
70
+ if auth.lower().startswith("bearer "):
71
+ token = auth[7:].strip()
72
+ sub = verify_token(token)
73
+ if sub:
74
+ return sub
75
+ # A present-but-invalid token must never silently fall through to
76
+ # the dev header path.
77
+ return None
78
+
79
+ if settings.dev_mode:
80
+ raw = request.headers.get("X-Tenant-ID")
81
+ if raw and raw.strip():
82
+ return raw.strip()
83
+
84
+ return None
app/api/status.py CHANGED
@@ -23,6 +23,22 @@ from app.models.schemas import (
23
  router = APIRouter()
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def _utc_age_seconds(now_utc: datetime, then: datetime | None) -> float:
27
  """Compute age in seconds, tolerant of naive DB datetimes.
28
 
@@ -68,7 +84,7 @@ async def document_info(
68
  "filename": doc.filename,
69
  "status": doc.status.value,
70
  "chunk_count": chunk_count,
71
- "created_at": doc.created_at.isoformat(),
72
  "survey_level": doc.survey_level,
73
  }
74
 
@@ -130,6 +146,14 @@ async def list_documents(
130
  .offset(offset)
131
  )
132
  rows = result.scalars().all()
 
 
 
 
 
 
 
 
133
  return {
134
  "tenant_id": tenant_id,
135
  "limit": limit,
@@ -139,8 +163,15 @@ async def list_documents(
139
  "document_id": d.id,
140
  "filename": d.filename,
141
  "status": d.status.value,
142
- "created_at": d.created_at.isoformat(),
 
143
  "survey_level": d.survey_level,
 
 
 
 
 
 
144
  "error": d.error_message,
145
  }
146
  for d in rows
 
23
  router = APIRouter()
24
 
25
 
26
+ def _iso_utc(dt: datetime | None) -> str | None:
27
+ """Serialize a DB timestamp as UTC ISO-8601 with a ``Z`` suffix.
28
+
29
+ SQLite returns naive datetimes; we treat those as UTC because ORM
30
+ defaults use ``datetime.now(UTC)``. Without ``Z``, browsers parse the
31
+ value as local time and display the wrong clock time.
32
+ """
33
+ if dt is None:
34
+ return None
35
+ if dt.tzinfo is None:
36
+ dt = dt.replace(tzinfo=UTC)
37
+ else:
38
+ dt = dt.astimezone(UTC)
39
+ return dt.isoformat().replace("+00:00", "Z")
40
+
41
+
42
  def _utc_age_seconds(now_utc: datetime, then: datetime | None) -> float:
43
  """Compute age in seconds, tolerant of naive DB datetimes.
44
 
 
84
  "filename": doc.filename,
85
  "status": doc.status.value,
86
  "chunk_count": chunk_count,
87
+ "created_at": _iso_utc(doc.created_at),
88
  "survey_level": doc.survey_level,
89
  }
90
 
 
146
  .offset(offset)
147
  )
148
  rows = result.scalars().all()
149
+
150
+ def _file_size(path_str: str) -> int | None:
151
+ try:
152
+ p = Path(path_str)
153
+ return p.stat().st_size if p.is_file() else None
154
+ except OSError:
155
+ return None
156
+
157
  return {
158
  "tenant_id": tenant_id,
159
  "limit": limit,
 
163
  "document_id": d.id,
164
  "filename": d.filename,
165
  "status": d.status.value,
166
+ "created_at": _iso_utc(d.created_at),
167
+ "updated_at": _iso_utc(d.updated_at),
168
  "survey_level": d.survey_level,
169
+ "document_purpose": (
170
+ d.document_purpose.value
171
+ if hasattr(d.document_purpose, "value")
172
+ else d.document_purpose
173
+ ),
174
+ "file_size": _file_size(d.file_path),
175
  "error": d.error_message,
176
  }
177
  for d in rows
app/api/upload.py CHANGED
@@ -10,13 +10,13 @@ from pathlib import Path
10
  from typing import Annotated
11
 
12
  from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
13
- from sqlalchemy import delete, func, select
14
  from sqlalchemy.ext.asyncio import AsyncSession
15
 
16
  from app.api.rate_limit import check_read
17
  from app.config import settings
18
  from app.db.database import get_db
19
- from app.db.models import Document, IngestStatus, Report
20
  from app.ingest.schedule import schedule_ingest
21
  from app.ingest.zip_extract import extract_reference_documents
22
  from app.services.photo_policy_corpus import invalidate_tenant_photo_policy_cache
@@ -24,6 +24,7 @@ from app.models.schemas import (
24
  BatchUploadItem,
25
  BulkUploadResponse,
26
  DocumentDeleteResponse,
 
27
  DocumentSurveyLevelResponse,
28
  DocumentSurveyLevelUpdate,
29
  UploadResponse,
@@ -98,11 +99,17 @@ def _parse_optional_survey_level(raw: str | None) -> int | None:
98
  async def upload_file(
99
  request: Request,
100
  file: UploadFile = File(...),
101
- tenant_id: str = Form(...),
102
  survey_level: Annotated[str | None, Form()] = None,
103
  db: AsyncSession = Depends(get_db),
104
  ) -> UploadResponse:
105
- """Accept a single document upload and schedule async ingestion."""
 
 
 
 
 
 
106
  suffix = Path(file.filename or "").suffix.lower()
107
  if suffix not in _ALLOWED_SUFFIXES:
108
  raise HTTPException(
@@ -137,7 +144,7 @@ async def upload_file(
137
  async def upload_batch(
138
  request: Request,
139
  files: list[UploadFile] = File(...),
140
- tenant_id: str = Form(...),
141
  survey_level: Annotated[str | None, Form()] = None,
142
  db: AsyncSession = Depends(get_db),
143
  ) -> BulkUploadResponse:
@@ -155,6 +162,7 @@ async def upload_batch(
155
  For very large libraries (millions of files), split work across multiple
156
  batch requests and/or several ZIPs β€” each stays within ``max_upload_batch_files``.
157
  """
 
158
  if not files:
159
  raise HTTPException(status_code=422, detail="No files uploaded")
160
 
@@ -300,6 +308,130 @@ async def patch_document_survey_level(
300
  return DocumentSurveyLevelResponse(document_id=document_id, survey_level=doc.survey_level)
301
 
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  @router.delete(
304
  "/documents/{document_id}",
305
  response_model=DocumentDeleteResponse,
@@ -313,25 +445,39 @@ async def delete_uploaded_document(
313
  ) -> DocumentDeleteResponse:
314
  """Delete an uploaded file, its DB row, and all vector-index chunks for this document.
315
 
316
- Blocked with HTTP 409 when any report still references the document (FK integrity).
 
 
 
 
317
  """
318
  tenant_id: str = request.state.tenant_id
319
  doc = await db.get(Document, document_id)
320
  if doc is None or doc.tenant_id != tenant_id:
321
  raise HTTPException(status_code=404, detail="Document not found")
322
 
323
- cnt = await db.execute(
324
- select(func.count()).select_from(Report).where(Report.document_id == document_id)
 
 
 
325
  )
326
- if int(cnt.scalar_one() or 0) > 0:
327
  raise HTTPException(
328
  status_code=409,
329
  detail=(
330
- "This file is still linked to one or more reports. "
331
- "Finish or abandon those jobs first, or upload replacements under a new document."
332
  ),
333
  )
334
 
 
 
 
 
 
 
 
335
  try:
336
  from app.vectorstore.factory import get_vectorstore
337
 
 
10
  from typing import Annotated
11
 
12
  from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
13
+ from sqlalchemy import delete, select, update
14
  from sqlalchemy.ext.asyncio import AsyncSession
15
 
16
  from app.api.rate_limit import check_read
17
  from app.config import settings
18
  from app.db.database import get_db
19
+ from app.db.models import Document, IngestStatus, Report, ReportStatus
20
  from app.ingest.schedule import schedule_ingest
21
  from app.ingest.zip_extract import extract_reference_documents
22
  from app.services.photo_policy_corpus import invalidate_tenant_photo_policy_cache
 
24
  BatchUploadItem,
25
  BulkUploadResponse,
26
  DocumentDeleteResponse,
27
+ DocumentReingestResponse,
28
  DocumentSurveyLevelResponse,
29
  DocumentSurveyLevelUpdate,
30
  UploadResponse,
 
99
  async def upload_file(
100
  request: Request,
101
  file: UploadFile = File(...),
102
+ tenant_id: Annotated[str | None, Form()] = None,
103
  survey_level: Annotated[str | None, Form()] = None,
104
  db: AsyncSession = Depends(get_db),
105
  ) -> UploadResponse:
106
+ """Accept a single document upload and schedule async ingestion.
107
+
108
+ The owning tenant is taken from the authenticated request, never from the
109
+ client-supplied form field β€” uploading into another tenant's library is not
110
+ possible.
111
+ """
112
+ tenant_id = request.state.tenant_id
113
  suffix = Path(file.filename or "").suffix.lower()
114
  if suffix not in _ALLOWED_SUFFIXES:
115
  raise HTTPException(
 
144
  async def upload_batch(
145
  request: Request,
146
  files: list[UploadFile] = File(...),
147
+ tenant_id: Annotated[str | None, Form()] = None,
148
  survey_level: Annotated[str | None, Form()] = None,
149
  db: AsyncSession = Depends(get_db),
150
  ) -> BulkUploadResponse:
 
162
  For very large libraries (millions of files), split work across multiple
163
  batch requests and/or several ZIPs β€” each stays within ``max_upload_batch_files``.
164
  """
165
+ tenant_id = request.state.tenant_id
166
  if not files:
167
  raise HTTPException(status_code=422, detail="No files uploaded")
168
 
 
308
  return DocumentSurveyLevelResponse(document_id=document_id, survey_level=doc.survey_level)
309
 
310
 
311
+ async def _requeue_document(db: AsyncSession, doc: Document) -> bool:
312
+ """Delete a document's stale chunks and re-queue it through the new pipeline.
313
+
314
+ Returns True when queued, False when the source file is missing on disk.
315
+ Clearing the old vectors first prevents duplicate chunks (old flattened +
316
+ new table-aware) from coexisting in the index.
317
+ """
318
+ path = Path(doc.file_path)
319
+ if not path.is_file():
320
+ return False
321
+ try:
322
+ from app.vectorstore.factory import get_vectorstore
323
+
324
+ get_vectorstore().delete_document(doc.id)
325
+ except Exception as exc: # noqa: BLE001 β€” stale-chunk cleanup is best-effort
326
+ logger.warning("Vector store delete failed during reingest doc=%s: %s", doc.id, exc)
327
+ doc.status = IngestStatus.pending
328
+ doc.error_message = None
329
+ schedule_ingest(doc_id=doc.id, file_path=path)
330
+ return True
331
+
332
+
333
+ async def _active_report_doc_ids(db: AsyncSession, document_ids: list[str]) -> set[str]:
334
+ """Document ids that currently have a report pending/generating against them."""
335
+ if not document_ids:
336
+ return set()
337
+ res = await db.execute(
338
+ select(Report.document_id)
339
+ .where(Report.document_id.in_(document_ids))
340
+ .where(Report.status.in_((ReportStatus.pending, ReportStatus.generating)))
341
+ )
342
+ return {row[0] for row in res.all() if row[0]}
343
+
344
+
345
+ @router.post(
346
+ "/documents/{document_id}/reingest",
347
+ response_model=DocumentReingestResponse,
348
+ summary="Re-ingest one document through the current parser/chunker",
349
+ )
350
+ async def reingest_document(
351
+ document_id: str,
352
+ request: Request,
353
+ db: AsyncSession = Depends(get_db),
354
+ _: None = Depends(check_read),
355
+ ) -> DocumentReingestResponse:
356
+ """Re-process a single uploaded document through the latest ingestion pipeline.
357
+
358
+ Use this after pipeline upgrades (e.g. table-aware parsing / chunking) so the
359
+ document's chunks reflect the new logic. Blocked with HTTP 409 while a report
360
+ is actively generating from this document.
361
+ """
362
+ tenant_id: str = request.state.tenant_id
363
+ doc = await db.get(Document, document_id)
364
+ if doc is None or doc.tenant_id != tenant_id:
365
+ raise HTTPException(status_code=404, detail="Document not found")
366
+
367
+ if await _active_report_doc_ids(db, [document_id]):
368
+ raise HTTPException(
369
+ status_code=409,
370
+ detail="A report is still generating from this document. Wait for it to finish before re-ingesting.",
371
+ )
372
+
373
+ queued = await _requeue_document(db, doc)
374
+ await db.commit()
375
+ if not queued:
376
+ return DocumentReingestResponse(
377
+ queued=0,
378
+ skipped_missing_file=1,
379
+ detail="Source file is no longer on disk; cannot re-ingest.",
380
+ )
381
+ return DocumentReingestResponse(
382
+ queued=1,
383
+ document_ids=[document_id],
384
+ detail="Document re-queued for ingestion through the current pipeline.",
385
+ )
386
+
387
+
388
+ @router.post(
389
+ "/documents/reingest",
390
+ response_model=DocumentReingestResponse,
391
+ summary="Re-ingest all of the tenant's documents through the current pipeline",
392
+ )
393
+ async def reingest_all_documents(
394
+ request: Request,
395
+ db: AsyncSession = Depends(get_db),
396
+ _: None = Depends(check_read),
397
+ ) -> DocumentReingestResponse:
398
+ """Re-process every uploaded document for the tenant through the latest pipeline.
399
+
400
+ Documents with a report actively generating against them are skipped (not
401
+ blocking the whole batch). This is the one-shot "activate the new parser/
402
+ chunker on my existing library" action.
403
+ """
404
+ tenant_id: str = request.state.tenant_id
405
+ res = await db.execute(select(Document).where(Document.tenant_id == tenant_id))
406
+ docs = list(res.scalars().all())
407
+ if not docs:
408
+ return DocumentReingestResponse(queued=0, detail="No documents to re-ingest.")
409
+
410
+ active = await _active_report_doc_ids(db, [d.id for d in docs])
411
+ queued_ids: list[str] = []
412
+ skipped_active = 0
413
+ skipped_missing = 0
414
+ for doc in docs:
415
+ if doc.id in active:
416
+ skipped_active += 1
417
+ continue
418
+ if await _requeue_document(db, doc):
419
+ queued_ids.append(doc.id)
420
+ else:
421
+ skipped_missing += 1
422
+ await db.commit()
423
+ return DocumentReingestResponse(
424
+ queued=len(queued_ids),
425
+ document_ids=queued_ids,
426
+ skipped_active=skipped_active,
427
+ skipped_missing_file=skipped_missing,
428
+ detail=(
429
+ f"Re-queued {len(queued_ids)} document(s) through the current pipeline; "
430
+ f"skipped {skipped_active} actively-generating and {skipped_missing} missing-file."
431
+ ),
432
+ )
433
+
434
+
435
  @router.delete(
436
  "/documents/{document_id}",
437
  response_model=DocumentDeleteResponse,
 
445
  ) -> DocumentDeleteResponse:
446
  """Delete an uploaded file, its DB row, and all vector-index chunks for this document.
447
 
448
+ Finished reports that used this document are **detached** (their
449
+ ``document_id`` is set to NULL) so their generated content is preserved while
450
+ the source file is removed. Deletion is blocked with HTTP 409 only when a
451
+ report is still actively generating against this document β€” pulling the
452
+ source mid-job would corrupt the run.
453
  """
454
  tenant_id: str = request.state.tenant_id
455
  doc = await db.get(Document, document_id)
456
  if doc is None or doc.tenant_id != tenant_id:
457
  raise HTTPException(status_code=404, detail="Document not found")
458
 
459
+ active = await db.execute(
460
+ select(Report.id)
461
+ .where(Report.document_id == document_id)
462
+ .where(Report.status.in_((ReportStatus.pending, ReportStatus.generating)))
463
+ .limit(1)
464
  )
465
+ if active.first() is not None:
466
  raise HTTPException(
467
  status_code=409,
468
  detail=(
469
+ "A report is still generating from this document. "
470
+ "Wait for it to finish (or abandon it) before deleting the source file."
471
  ),
472
  )
473
 
474
+ # Detach finished/failed reports so they survive without the source file.
475
+ await db.execute(
476
+ update(Report)
477
+ .where(Report.document_id == document_id)
478
+ .values(document_id=None)
479
+ )
480
+
481
  try:
482
  from app.vectorstore.factory import get_vectorstore
483
 
app/chunking/splitter.py CHANGED
@@ -15,18 +15,31 @@ Compared to master branch:
15
  """
16
 
17
  import logging
 
18
 
19
  import tiktoken
20
  from langchain_core.documents import Document
21
  from langchain_text_splitters import RecursiveCharacterTextSplitter
22
 
23
  from app.config import settings
 
24
 
25
  logger = logging.getLogger(__name__)
26
 
27
  # Shared tiktoken encoding used for both splitting and prompt token counting
28
  _ENCODING_NAME = "cl100k_base"
29
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def count_tokens(text: str) -> int:
32
  """Count the number of tiktoken tokens in ``text``.
@@ -70,7 +83,7 @@ def build_splitter(
70
  encoding_name=_ENCODING_NAME,
71
  chunk_size=chunk_size or settings.chunk_size,
72
  chunk_overlap=chunk_overlap or settings.chunk_overlap,
73
- separators=["\n\n", "\n", ". ", " ", ""],
74
  )
75
 
76
 
@@ -101,7 +114,9 @@ def split_documents(
101
  # Each chunk has page_content ≀ settings.chunk_size tokens
102
  """
103
  splitter = build_splitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
104
- chunks = splitter.split_documents(documents)
 
 
105
  logger.debug(
106
  "Split %d document(s) into %d chunks (size=%d, overlap=%d)",
107
  len(documents),
@@ -110,3 +125,37 @@ def split_documents(
110
  chunk_overlap or settings.chunk_overlap,
111
  )
112
  return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  """
16
 
17
  import logging
18
+ import re
19
 
20
  import tiktoken
21
  from langchain_core.documents import Document
22
  from langchain_text_splitters import RecursiveCharacterTextSplitter
23
 
24
  from app.config import settings
25
+ from app.ingest.ocr_normalize import TABLE_CLOSE, TABLE_OPEN
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
  # Shared tiktoken encoding used for both splitting and prompt token counting
30
  _ENCODING_NAME = "cl100k_base"
31
 
32
+ # Sentence-aware separator ladder: the recursive splitter tries these in order,
33
+ # so it prefers paragraph -> sentence boundaries and only falls back to word /
34
+ # character splits when a single sentence exceeds the token budget. This is what
35
+ # keeps chunks from being cut mid-sentence.
36
+ _SEPARATORS = ["\n\n", "\n", ". ", "? ", "! ", "; ", ", ", " ", ""]
37
+
38
+ _TABLE_BLOCK_RE = re.compile(
39
+ re.escape(TABLE_OPEN) + r".*?" + re.escape(TABLE_CLOSE),
40
+ re.DOTALL,
41
+ )
42
+
43
 
44
  def count_tokens(text: str) -> int:
45
  """Count the number of tiktoken tokens in ``text``.
 
83
  encoding_name=_ENCODING_NAME,
84
  chunk_size=chunk_size or settings.chunk_size,
85
  chunk_overlap=chunk_overlap or settings.chunk_overlap,
86
+ separators=_SEPARATORS,
87
  )
88
 
89
 
 
114
  # Each chunk has page_content ≀ settings.chunk_size tokens
115
  """
116
  splitter = build_splitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
117
+ chunks: list[Document] = []
118
+ for doc in documents:
119
+ chunks.extend(_split_one(doc, splitter))
120
  logger.debug(
121
  "Split %d document(s) into %d chunks (size=%d, overlap=%d)",
122
  len(documents),
 
125
  chunk_overlap or settings.chunk_overlap,
126
  )
127
  return chunks
128
+
129
+
130
+ def _split_one(
131
+ doc: Document, splitter: RecursiveCharacterTextSplitter
132
+ ) -> list[Document]:
133
+ """Split one document, keeping each ``[TABLE]…[/TABLE]`` block as one chunk.
134
+
135
+ Tables carry row/column meaning that must never be cut across chunk
136
+ boundaries, so they are emitted intact (tagged ``section_type='table'``)
137
+ while the surrounding prose is split sentence-aware. Document order is
138
+ preserved.
139
+ """
140
+ content = doc.page_content or ""
141
+ if TABLE_OPEN not in content:
142
+ return splitter.split_documents([doc])
143
+
144
+ out: list[Document] = []
145
+ cursor = 0
146
+ for m in _TABLE_BLOCK_RE.finditer(content):
147
+ prose = content[cursor:m.start()].strip()
148
+ if prose:
149
+ out.extend(
150
+ splitter.split_documents([Document(page_content=prose, metadata=dict(doc.metadata))])
151
+ )
152
+ table_meta = dict(doc.metadata)
153
+ table_meta["section_type"] = "table"
154
+ out.append(Document(page_content=m.group(0).strip(), metadata=table_meta))
155
+ cursor = m.end()
156
+ tail = content[cursor:].strip()
157
+ if tail:
158
+ out.extend(
159
+ splitter.split_documents([Document(page_content=tail, metadata=dict(doc.metadata))])
160
+ )
161
+ return out
app/config.py CHANGED
@@ -316,6 +316,35 @@ class Settings(BaseSettings):
316
  description="TTL for cached survey-tier corpus profiles built from knowledge_base_dirs (seconds).",
317
  )
318
  chunk_size: int = Field(default=500, ge=100, le=2000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  max_bullets_per_section: int = Field(
320
  default=800,
321
  ge=20,
@@ -665,6 +694,12 @@ class Settings(BaseSettings):
665
 
666
  # ── Security ─────────────────────────────────────────────────────────────
667
  tenant_secret_key: str = Field(default="dev-secret-change-me")
 
 
 
 
 
 
668
  dev_mode: bool = Field(default=False)
669
  # CORS: list specific origins in production, e.g. ["https://app.example.com"]
670
  # The wildcard ["*"] is safe here because allow_credentials=False in main.py
 
316
  description="TTL for cached survey-tier corpus profiles built from knowledge_base_dirs (seconds).",
317
  )
318
  chunk_size: int = Field(default=500, ge=100, le=2000)
319
+
320
+ # ── Citation-grounded extraction (anti-hallucination layer) ──────────────
321
+ enable_citation_extraction: bool = Field(
322
+ default=True,
323
+ description=(
324
+ "Run schema-constrained, citation-grounded extraction + contradiction "
325
+ "audit alongside generation. Findings unsupported by retrieved source "
326
+ "spans are dropped and surfaced as a per-section audit (confidence, "
327
+ "contradictions, dropped claims). Adds one deterministic LLM call per "
328
+ "section; degrades to a no-op when no OpenAI key is configured."
329
+ ),
330
+ )
331
+ extraction_max_tokens: int = Field(
332
+ default=1500,
333
+ ge=256,
334
+ le=8000,
335
+ description="Max output tokens for the deterministic extraction call.",
336
+ )
337
+ rag_use_full_tenant_library: bool = Field(
338
+ default=True,
339
+ description=(
340
+ "When true, report generation retrieves from ALL of the tenant's "
341
+ "ingested report-source documents (old + new uploads), not only the "
342
+ "file attached when the report was created. The report's own upload "
343
+ "is still prioritised. style_corpus (past completed reports) remain "
344
+ "excluded from factual retrieval, and tenant isolation is preserved. "
345
+ "Set false to restore strict per-report document isolation."
346
+ ),
347
+ )
348
  max_bullets_per_section: int = Field(
349
  default=800,
350
  ge=20,
 
694
 
695
  # ── Security ─────────────────────────────────────────────────────────────
696
  tenant_secret_key: str = Field(default="dev-secret-change-me")
697
+ auth_token_ttl_seconds: int = Field(
698
+ default=7 * 24 * 3600,
699
+ ge=300,
700
+ le=90 * 24 * 3600,
701
+ description="Lifetime of an issued tenant bearer token, in seconds (default 7 days).",
702
+ )
703
  dev_mode: bool = Field(default=False)
704
  # CORS: list specific origins in production, e.g. ["https://app.example.com"]
705
  # The wildcard ["*"] is safe here because allow_credentials=False in main.py
app/db/database.py CHANGED
@@ -42,8 +42,21 @@ def parallel_section_writes_safe() -> bool:
42
 
43
 
44
  def multi_section_parallel_enabled() -> bool:
45
- """Parallel multi-section jobs (in-process or Temporal) when async pipeline is on."""
46
- return bool(settings.enable_async_pipeline and parallel_section_writes_safe())
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  def effective_section_concurrency() -> int:
@@ -198,12 +211,71 @@ def _postgres_add_column_if_missing(
198
  connection.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {ddl_suffix}"))
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  async def migrate_schema() -> None:
202
  """Apply lightweight schema upgrades when models gain nullable columns."""
203
  engine = get_engine()
204
 
205
  def _upgrade(sync_conn: object) -> None:
206
  if is_sqlite_database():
 
207
  _sqlite_add_column_if_missing(
208
  sync_conn, "documents", "survey_level", "survey_level INTEGER"
209
  )
@@ -232,6 +304,7 @@ async def migrate_schema() -> None:
232
  "document_purpose VARCHAR(32) NOT NULL DEFAULT 'report_source'",
233
  )
234
  else:
 
235
  _postgres_add_column_if_missing(
236
  sync_conn,
237
  "reports",
 
42
 
43
 
44
  def multi_section_parallel_enabled() -> bool:
45
+ """Parallel multi-section jobs (in-process or Temporal).
46
+
47
+ Enabled by either the async pipeline OR the dedicated
48
+ ``enable_parallel_section_generation`` flag, and only when concurrent
49
+ section writes are safe for the active DB backend (SQLite requires
50
+ ``allow_sqlite_parallel_sections``). This only governs the Temporal
51
+ workflow request's ``parallel_sections`` field; the default (non-Temporal)
52
+ job path is unaffected.
53
+ """
54
+ if not parallel_section_writes_safe():
55
+ return False
56
+ return bool(
57
+ settings.enable_async_pipeline
58
+ or settings.enable_parallel_section_generation
59
+ )
60
 
61
 
62
  def effective_section_concurrency() -> int:
 
211
  connection.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {ddl_suffix}"))
212
 
213
 
214
+ def _sqlite_drop_not_null(connection: object, table: str, column: str) -> None:
215
+ """Relax a ``NOT NULL`` constraint on one SQLite column via a table rebuild.
216
+
217
+ SQLite cannot ``ALTER COLUMN``; the standard recipe is to recreate the table
218
+ with the relaxed definition and copy rows. Idempotent: no-op when the column
219
+ is already nullable or absent. FK declarations are intentionally dropped on
220
+ rebuild (we want lenient deletes), and the ``ix_<table>_tenant_id`` helper
221
+ index is recreated.
222
+ """
223
+ from sqlalchemy import text
224
+
225
+ info = connection.execute(text(f"PRAGMA table_info({table})")).fetchall() # type: ignore[attr-defined]
226
+ if not info:
227
+ return
228
+ target = next((row for row in info if row[1] == column), None)
229
+ if target is None or int(target[3]) == 0:
230
+ # column missing, or already nullable
231
+ return
232
+
233
+ col_defs: list[str] = []
234
+ for _cid, name, ctype, notnull, dflt, pk in info:
235
+ piece = f'"{name}" {ctype or ""}'.rstrip()
236
+ if int(pk):
237
+ piece += " PRIMARY KEY"
238
+ if int(notnull) and name != column:
239
+ piece += " NOT NULL"
240
+ if dflt is not None:
241
+ piece += f" DEFAULT {dflt}"
242
+ col_defs.append(piece)
243
+ col_names = ", ".join(f'"{row[1]}"' for row in info)
244
+ tmp = f"{table}__migrate_tmp"
245
+
246
+ connection.execute(text("PRAGMA foreign_keys=OFF")) # type: ignore[attr-defined]
247
+ connection.execute(text(f'DROP TABLE IF EXISTS "{tmp}"')) # type: ignore[attr-defined]
248
+ connection.execute(text(f'CREATE TABLE "{tmp}" ({", ".join(col_defs)})')) # type: ignore[attr-defined]
249
+ connection.execute( # type: ignore[attr-defined]
250
+ text(f'INSERT INTO "{tmp}" ({col_names}) SELECT {col_names} FROM "{table}"')
251
+ )
252
+ connection.execute(text(f'DROP TABLE "{table}"')) # type: ignore[attr-defined]
253
+ connection.execute(text(f'ALTER TABLE "{tmp}" RENAME TO "{table}"')) # type: ignore[attr-defined]
254
+ connection.execute( # type: ignore[attr-defined]
255
+ text(f'CREATE INDEX IF NOT EXISTS "ix_{table}_tenant_id" ON "{table}" (tenant_id)')
256
+ )
257
+
258
+
259
+ def _postgres_drop_not_null(connection: object, table: str, column: str) -> None:
260
+ """Relax a ``NOT NULL`` constraint on PostgreSQL (idempotent)."""
261
+ from sqlalchemy import inspect, text
262
+
263
+ insp = inspect(connection)
264
+ if not insp.has_table(table):
265
+ return
266
+ col = next((c for c in insp.get_columns(table) if c["name"] == column), None)
267
+ if col is None or col.get("nullable", True):
268
+ return
269
+ connection.execute(text(f'ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL'))
270
+
271
+
272
  async def migrate_schema() -> None:
273
  """Apply lightweight schema upgrades when models gain nullable columns."""
274
  engine = get_engine()
275
 
276
  def _upgrade(sync_conn: object) -> None:
277
  if is_sqlite_database():
278
+ _sqlite_drop_not_null(sync_conn, "reports", "document_id")
279
  _sqlite_add_column_if_missing(
280
  sync_conn, "documents", "survey_level", "survey_level INTEGER"
281
  )
 
304
  "document_purpose VARCHAR(32) NOT NULL DEFAULT 'report_source'",
305
  )
306
  else:
307
+ _postgres_drop_not_null(sync_conn, "reports", "document_id")
308
  _postgres_add_column_if_missing(
309
  sync_conn,
310
  "reports",
app/db/models.py CHANGED
@@ -101,8 +101,11 @@ class Report(Base):
101
  String(36), primary_key=True, default=lambda: str(uuid.uuid4())
102
  )
103
  tenant_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
104
- document_id: Mapped[str] = mapped_column(
105
- String(36), ForeignKey("documents.id"), nullable=False
 
 
 
106
  )
107
  status: Mapped[ReportStatus] = mapped_column(
108
  Enum(ReportStatus), default=ReportStatus.pending
@@ -178,3 +181,23 @@ class ReportSectionPhoto(Base):
178
  content_type: Mapped[str] = mapped_column(String(128), nullable=False)
179
 
180
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  String(36), primary_key=True, default=lambda: str(uuid.uuid4())
102
  )
103
  tenant_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
104
+ # Nullable so a finished report's source document can be deleted: on delete
105
+ # the referencing terminal reports are detached (document_id -> NULL) rather
106
+ # than blocking removal. Active jobs (pending/generating) still block deletion.
107
+ document_id: Mapped[str | None] = mapped_column(
108
+ String(36), ForeignKey("documents.id"), nullable=True
109
  )
110
  status: Mapped[ReportStatus] = mapped_column(
111
  Enum(ReportStatus), default=ReportStatus.pending
 
181
  content_type: Mapped[str] = mapped_column(String(128), nullable=False)
182
 
183
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
184
+
185
+
186
+ class Tenant(Base):
187
+ """An authenticated tenant (user/workspace) for access control + isolation.
188
+
189
+ The ``id`` is the tenant identifier carried on every request as the
190
+ cryptographically-verified subject of a signed bearer token. The passphrase
191
+ is never stored β€” only a PBKDF2-HMAC-SHA256 hash and its per-tenant salt.
192
+ """
193
+
194
+ __tablename__ = "tenants"
195
+
196
+ id: Mapped[str] = mapped_column(String(128), primary_key=True)
197
+ password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
198
+ password_salt: Mapped[str] = mapped_column(String(64), nullable=False)
199
+ pbkdf2_iterations: Mapped[int] = mapped_column(Integer, nullable=False, default=200_000)
200
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
201
+ last_login_at: Mapped[datetime | None] = mapped_column(
202
+ DateTime(timezone=True), nullable=True, default=None
203
+ )
app/extraction/__init__.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Citation-grounded extraction & verification core.
2
+
3
+ This package turns retrieved source spans into schema-constrained, evidence-backed
4
+ survey findings and audits them for hallucination and contradiction. It is the
5
+ deterministic "no invented facts" layer that sits between retrieval and rendering.
6
+
7
+ Design tenets (do not relax these without a regression test):
8
+
9
+ * Extraction over summarization β€” findings are copied/derived from cited spans.
10
+ * Grounding over fluency β€” every claim must map to a literal source span.
11
+ * Determinism over creativity β€” temperature=0, top_p=1, fixed seed.
12
+ * Citation validation over freeform generation β€” unsupported claims are dropped.
13
+
14
+ The pure-Python validation/contradiction layers (``citation_validator`` and
15
+ ``contradiction``) have NO network dependency and are fully unit-testable
16
+ offline. Only :mod:`app.extraction.extractor` calls the LLM.
17
+ """
18
+
19
+ from app.extraction.schemas import (
20
+ AtomicClaim,
21
+ ClaimEvidence,
22
+ ClaimType,
23
+ ClaimVerification,
24
+ ConditionRating,
25
+ Contradiction,
26
+ ContradictionKind,
27
+ EvidenceSpan,
28
+ GroundedClaim,
29
+ SectionExtraction,
30
+ SupportLevel,
31
+ SurveyFinding,
32
+ )
33
+
34
+ __all__ = [
35
+ "AtomicClaim",
36
+ "ClaimEvidence",
37
+ "ClaimType",
38
+ "ClaimVerification",
39
+ "ConditionRating",
40
+ "Contradiction",
41
+ "ContradictionKind",
42
+ "EvidenceSpan",
43
+ "GroundedClaim",
44
+ "SectionExtraction",
45
+ "SupportLevel",
46
+ "SurveyFinding",
47
+ ]
app/extraction/citation_validator.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic citation & source-span validation.
2
+
3
+ This module enforces the core guarantee: **every claim must map to a literal
4
+ source span.** It is pure Python with no network dependency, so it can be unit
5
+ tested offline and run as a hard gate on any LLM output.
6
+
7
+ Validation policy (audit-safe β€” bias toward dropping, never inventing):
8
+
9
+ * A cited ``chunk_id`` must exist in the retrieved evidence pool. Fabricated
10
+ citations invalidate the span.
11
+ * An evidence span's text must actually appear in its cited chunk (verbatim
12
+ after normalization, or via high token-containment to tolerate OCR drift).
13
+ * Every NUMBER, MONETARY AMOUNT, and flagged PROPER NOUN in the finding text
14
+ must be present in the cited evidence. This blocks altered ratings, fabricated
15
+ totals, and entity substitution ("London plane tree" -> "cedar tree").
16
+ * Escalation/severity words (catastrophic, deadly, unsafe, ...) may only appear
17
+ if present verbatim in the source.
18
+
19
+ Anything failing a *critical* check is forced to ``NOT_FOUND`` and dropped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import unicodedata
26
+ from collections.abc import Iterable, Mapping
27
+
28
+ from app.extraction.schemas import SupportLevel, SurveyFinding
29
+
30
+ # Severity / escalation vocabulary that must never be introduced by the model.
31
+ # These may ONLY survive validation if present verbatim in the cited evidence.
32
+ BANNED_SEVERITY_TERMS: frozenset[str] = frozenset({
33
+ "catastrophic", "deadly", "lethal", "unsafe", "dangerous", "hazardous",
34
+ "severe health", "health consequences", "environmental disaster", "disaster",
35
+ "life-threatening", "life threatening", "toxic", "fatal", "collapse imminent",
36
+ })
37
+
38
+ # Containment thresholds for whole-finding grounding (token overlap with evidence).
39
+ _SUPPORTED_CONTAINMENT = 0.85
40
+ _PARTIAL_CONTAINMENT = 0.55
41
+ # Span-level containment for tolerating OCR/quote drift when a verbatim
42
+ # substring match fails.
43
+ _SPAN_CONTAINMENT = 0.90
44
+
45
+ _STOPWORDS: frozenset[str] = frozenset({
46
+ "the", "a", "an", "and", "or", "of", "to", "in", "on", "at", "is", "are",
47
+ "was", "were", "be", "been", "being", "this", "that", "these", "those",
48
+ "with", "for", "as", "by", "from", "it", "its", "has", "have", "had",
49
+ "which", "but", "not", "no", "there", "their", "they", "we", "you", "i",
50
+ "will", "would", "should", "could", "may", "can", "also", "some", "any",
51
+ })
52
+
53
+ _WORD_RE = re.compile(r"[A-Za-z0-9Β£%.\-/]+")
54
+ # Numbers, percentages, money, and ratings like "CR2"/"1"/"Β£12,500"/"3.5m".
55
+ _NUMBER_RE = re.compile(r"Β£?\d[\d,]*(?:\.\d+)?%?")
56
+ # TitleCase proper-noun runs of >=2 words, or a single word with >=2 capitals.
57
+ _PROPER_NOUN_RE = re.compile(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b")
58
+
59
+
60
+ def normalize_for_match(text: str) -> str:
61
+ """Lowercase, fold accents, normalize quotes/dashes, collapse whitespace."""
62
+ if not text:
63
+ return ""
64
+ text = unicodedata.normalize("NFKD", text)
65
+ text = "".join(c for c in text if not unicodedata.combining(c))
66
+ text = (
67
+ text.replace("\u2019", "'").replace("\u2018", "'")
68
+ .replace("\u201c", '"').replace("\u201d", '"')
69
+ .replace("\u2013", "-").replace("\u2014", "-")
70
+ .replace("\u00a0", " ")
71
+ )
72
+ text = text.lower()
73
+ text = re.sub(r"\s+", " ", text)
74
+ return text.strip()
75
+
76
+
77
+ def content_tokens(text: str) -> list[str]:
78
+ """Tokens used for containment scoring (stopwords removed)."""
79
+ norm = normalize_for_match(text)
80
+ return [t for t in _WORD_RE.findall(norm) if t not in _STOPWORDS and len(t) > 1]
81
+
82
+
83
+ def _containment(needle_tokens: Iterable[str], haystack_tokens: set[str]) -> float:
84
+ needle = list(needle_tokens)
85
+ if not needle:
86
+ return 1.0
87
+ hits = sum(1 for t in needle if t in haystack_tokens)
88
+ return hits / len(needle)
89
+
90
+
91
+ def _coerce_pool(pool: Mapping[str, str] | Iterable[object]) -> dict[str, str]:
92
+ """Accept a ``{chunk_id: text}`` map or an iterable of SearchResult-like rows."""
93
+ if isinstance(pool, Mapping):
94
+ return {str(k): str(v) for k, v in pool.items()}
95
+ out: dict[str, str] = {}
96
+ for row in pool:
97
+ cid = getattr(row, "chunk_id", None)
98
+ txt = getattr(row, "text", None)
99
+ if cid is not None and txt is not None:
100
+ out[str(cid)] = str(txt)
101
+ return out
102
+
103
+
104
+ def span_in_chunk(span_text: str, chunk_text: str) -> bool:
105
+ """True when ``span_text`` is grounded in ``chunk_text``.
106
+
107
+ Verbatim (normalized) substring match first; falls back to high
108
+ token-containment to tolerate OCR/whitespace/quote drift while still
109
+ rejecting genuinely absent content.
110
+ """
111
+ if not span_text or not chunk_text:
112
+ return False
113
+ nspan = normalize_for_match(span_text)
114
+ nchunk = normalize_for_match(chunk_text)
115
+ if not nspan:
116
+ return False
117
+ if nspan in nchunk:
118
+ return True
119
+ chunk_tok = set(_WORD_RE.findall(nchunk))
120
+ span_tok = _WORD_RE.findall(nspan)
121
+ return _containment(span_tok, chunk_tok) >= _SPAN_CONTAINMENT
122
+
123
+
124
+ def _numbers(text: str) -> set[str]:
125
+ return {m.replace(",", "") for m in _NUMBER_RE.findall(text or "")}
126
+
127
+
128
+ def _proper_nouns(text: str) -> set[str]:
129
+ return {normalize_for_match(m) for m in _PROPER_NOUN_RE.findall(text or "")}
130
+
131
+
132
+ def _banned_terms_present(text: str) -> set[str]:
133
+ norm = normalize_for_match(text)
134
+ return {term for term in BANNED_SEVERITY_TERMS if term in norm}
135
+
136
+
137
+ def validate_finding(
138
+ finding: SurveyFinding,
139
+ pool: Mapping[str, str] | Iterable[object],
140
+ ) -> tuple[SupportLevel, list[str]]:
141
+ """Validate one finding against the retrieved evidence pool.
142
+
143
+ Returns the computed :class:`SupportLevel` and a list of human-readable
144
+ violation reasons (empty when fully supported). Mutates nothing.
145
+ """
146
+ pool_map = _coerce_pool(pool)
147
+ violations: list[str] = []
148
+
149
+ # 1. Spans must cite real chunks and actually appear in them.
150
+ valid_spans = []
151
+ for span in finding.evidence:
152
+ chunk_text = pool_map.get(span.chunk_id)
153
+ if chunk_text is None:
154
+ violations.append(f"citation to unknown chunk_id={span.chunk_id!r}")
155
+ continue
156
+ if not span_in_chunk(span.text, chunk_text):
157
+ violations.append(f"span not found in chunk {span.chunk_id!r}: {span.text[:60]!r}")
158
+ continue
159
+ valid_spans.append((span, chunk_text))
160
+
161
+ if not valid_spans:
162
+ return SupportLevel.NOT_FOUND, violations or ["no valid supporting spans"]
163
+
164
+ # Build the union of grounded evidence text from spans that validated.
165
+ evidence_text = " \n ".join(s.text for s, _ in valid_spans)
166
+ # Also allow grounding against the FULL cited chunk text (the span is a
167
+ # window into it; numbers/entities elsewhere in the same chunk still count).
168
+ evidence_text += " \n " + " \n ".join(ct for _, ct in valid_spans)
169
+ evidence_norm = normalize_for_match(evidence_text)
170
+ evidence_tok = set(_WORD_RE.findall(evidence_norm))
171
+ evidence_numbers = _numbers(evidence_text)
172
+ evidence_nouns = _proper_nouns(evidence_text)
173
+
174
+ # 2. CRITICAL: every number in the finding must be in the evidence.
175
+ for num in _numbers(finding.finding):
176
+ if num not in evidence_numbers:
177
+ violations.append(f"unsupported number/amount {num!r} not in evidence")
178
+
179
+ # 3. CRITICAL: severity escalation must exist in the source.
180
+ finding_banned = _banned_terms_present(finding.finding)
181
+ evidence_banned = _banned_terms_present(evidence_text)
182
+ for term in finding_banned - evidence_banned:
183
+ violations.append(f"unsupported severity term {term!r} not in evidence")
184
+
185
+ # 4. CRITICAL: proper nouns (materials, species, places) must not be substituted.
186
+ for noun in _proper_nouns(finding.finding):
187
+ if noun not in evidence_nouns and noun not in evidence_norm:
188
+ violations.append(f"unsupported/altered entity {noun!r} not in evidence")
189
+
190
+ has_critical = bool(violations)
191
+
192
+ # 5. Whole-finding token containment (catches paraphrased fabrication).
193
+ containment = _containment(content_tokens(finding.finding), evidence_tok)
194
+
195
+ if has_critical:
196
+ return SupportLevel.NOT_FOUND, violations
197
+ if containment >= _SUPPORTED_CONTAINMENT:
198
+ return SupportLevel.SUPPORTED, []
199
+ if containment >= _PARTIAL_CONTAINMENT:
200
+ return SupportLevel.PARTIAL, [f"partial grounding (containment={containment:.2f})"]
201
+ return SupportLevel.NOT_FOUND, [f"insufficient grounding (containment={containment:.2f})"]
202
+
203
+
204
+ def validate_findings(
205
+ findings: list[SurveyFinding],
206
+ pool: Mapping[str, str] | Iterable[object],
207
+ *,
208
+ drop_partial: bool = False,
209
+ ) -> tuple[list[SurveyFinding], list[str]]:
210
+ """Validate every finding; return (kept_findings, dropped_claim_descriptions).
211
+
212
+ ``NOT_FOUND`` findings are always dropped. ``PARTIAL`` findings are kept
213
+ (with their downgraded support level) unless ``drop_partial`` is set, in
214
+ which case only fully ``SUPPORTED`` findings survive β€” the strictest,
215
+ audit-safe mode.
216
+ """
217
+ kept: list[SurveyFinding] = []
218
+ dropped: list[str] = []
219
+ for f in findings:
220
+ support, reasons = validate_finding(f, pool)
221
+ f.support = support
222
+ if support == SupportLevel.NOT_FOUND:
223
+ dropped.append(f"{f.element}: {'; '.join(reasons)}")
224
+ continue
225
+ if support == SupportLevel.PARTIAL and drop_partial:
226
+ dropped.append(f"{f.element}: partial grounding dropped under strict mode")
227
+ continue
228
+ kept.append(f)
229
+ return kept, dropped
app/extraction/contradiction.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic contradiction & duplicate audit (STEP 7).
2
+
3
+ Pure-Python verifier that compares extracted findings and flags logical
4
+ inconsistencies, tracing each back to the findings (and therefore source spans)
5
+ that produced it. No network dependency β€” fully unit-testable.
6
+
7
+ Detected classes:
8
+
9
+ * ``RATING_CONFLICT`` β€” same element given two different condition ratings.
10
+ * ``CONDITION`` β€” same element described both satisfactory and defective.
11
+ * ``OPERATIONAL`` β€” same element described both operational and non-operational.
12
+ * ``DUPLICATE`` β€” near-identical findings for the same element.
13
+ * ``MUTUALLY_EXCLUSIVE`` β€” explicit antonym pairs on the same element.
14
+
15
+ Resolution policy: the verifier never fabricates a merged truth. It keeps the
16
+ finding with the stronger evidence support and records the conflict for audit.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from collections import defaultdict
23
+
24
+ from app.extraction.citation_validator import normalize_for_match
25
+ from app.extraction.schemas import (
26
+ Contradiction,
27
+ ContradictionKind,
28
+ ConditionRating,
29
+ SupportLevel,
30
+ SurveyFinding,
31
+ )
32
+
33
+ # Lexical polarity cues. Kept conservative to avoid false positives.
34
+ _DEFECTIVE_CUES = (
35
+ "defective", "defect", "failed", "failing", "broken", "cracked", "leak",
36
+ "leaking", "damp", "rot", "rotten", "corroded", "deteriorated", "missing",
37
+ "damaged", "unsafe", "not satisfactory", "poor condition", "in need of repair",
38
+ )
39
+ _SATISFACTORY_CUES = (
40
+ "satisfactory", "good condition", "sound", "no defect", "no defects",
41
+ "well maintained", "intact", "serviceable", "in good order", "no repair",
42
+ )
43
+ _NON_OPERATIONAL_CUES = (
44
+ "not operational", "non-operational", "inoperable", "not working",
45
+ "not functioning", "out of order", "does not work", "non operational",
46
+ "not in working order",
47
+ )
48
+ _OPERATIONAL_CUES = (
49
+ "operational", "working", "functioning", "in working order", "operates correctly",
50
+ "fully functional", "in good working order",
51
+ )
52
+
53
+ _WORD_RE = re.compile(r"[a-z0-9]+")
54
+
55
+
56
+ def _element_key(f: SurveyFinding) -> str:
57
+ """Normalized (section, element) key for grouping findings about one thing."""
58
+ return f"{normalize_for_match(f.section)}::{normalize_for_match(f.element)}"
59
+
60
+
61
+ def _has_any(text: str, cues: tuple[str, ...]) -> bool:
62
+ norm = normalize_for_match(text)
63
+ return any(cue in norm for cue in cues)
64
+
65
+
66
+ def _polarity(text: str) -> tuple[bool, bool, bool, bool]:
67
+ """Return (defective, satisfactory, operational, non_operational) cue presence."""
68
+ return (
69
+ _has_any(text, _DEFECTIVE_CUES),
70
+ _has_any(text, _SATISFACTORY_CUES),
71
+ _has_any(text, _OPERATIONAL_CUES),
72
+ _has_any(text, _NON_OPERATIONAL_CUES),
73
+ )
74
+
75
+
76
+ def _jaccard(a: str, b: str) -> float:
77
+ ta = set(_WORD_RE.findall(normalize_for_match(a)))
78
+ tb = set(_WORD_RE.findall(normalize_for_match(b)))
79
+ if not ta or not tb:
80
+ return 0.0
81
+ return len(ta & tb) / len(ta | tb)
82
+
83
+
84
+ def _support_rank(level: SupportLevel) -> int:
85
+ return {SupportLevel.SUPPORTED: 2, SupportLevel.PARTIAL: 1, SupportLevel.NOT_FOUND: 0}[level]
86
+
87
+
88
+ def _keep_stronger(a: tuple[int, SurveyFinding], b: tuple[int, SurveyFinding]) -> tuple[int, int]:
89
+ """Return (keep_index, drop_index) preferring stronger evidence support."""
90
+ (ia, fa), (ib, fb) = a, b
91
+ if _support_rank(fa.support) >= _support_rank(fb.support):
92
+ return ia, ib
93
+ return ib, ia
94
+
95
+
96
+ def audit_contradictions(
97
+ findings: list[SurveyFinding],
98
+ *,
99
+ duplicate_threshold: float = 0.9,
100
+ ) -> tuple[list[SurveyFinding], list[Contradiction]]:
101
+ """Detect contradictions/duplicates and return (resolved_findings, reports).
102
+
103
+ Resolution keeps the evidence-stronger finding for hard contradictions and
104
+ collapses near-duplicates. Every action is recorded in the returned
105
+ :class:`Contradiction` list for the audit trail. The input list is not
106
+ mutated; a filtered copy is returned.
107
+ """
108
+ contradictions: list[Contradiction] = []
109
+ drop: set[int] = set()
110
+
111
+ groups: dict[str, list[int]] = defaultdict(list)
112
+ for i, f in enumerate(findings):
113
+ groups[_element_key(f)].append(i)
114
+
115
+ for _key, idxs in groups.items():
116
+ for pos_a in range(len(idxs)):
117
+ for pos_b in range(pos_a + 1, len(idxs)):
118
+ ia, ib = idxs[pos_a], idxs[pos_b]
119
+ if ia in drop or ib in drop:
120
+ continue
121
+ fa, fb = findings[ia], findings[ib]
122
+
123
+ # Rating conflict (both real ratings, different values).
124
+ real = {ConditionRating.CR1, ConditionRating.CR2, ConditionRating.CR3}
125
+ if (
126
+ fa.condition_rating in real
127
+ and fb.condition_rating in real
128
+ and fa.condition_rating != fb.condition_rating
129
+ ):
130
+ keep, dropped = _keep_stronger((ia, fa), (ib, fb))
131
+ drop.add(dropped)
132
+ contradictions.append(Contradiction(
133
+ kind=ContradictionKind.RATING_CONFLICT,
134
+ element=fa.element,
135
+ detail=(
136
+ f"Condition rating {fa.condition_rating.value} vs "
137
+ f"{fb.condition_rating.value} for the same element."
138
+ ),
139
+ finding_indices=[ia, ib],
140
+ resolution=f"kept finding #{keep} (stronger evidence), dropped #{dropped}",
141
+ ))
142
+ continue
143
+
144
+ da, sa, oa, na = _polarity(fa.finding)
145
+ db, sb, ob, nb = _polarity(fb.finding)
146
+
147
+ # Satisfactory vs defective.
148
+ if (da and sb) or (sa and db):
149
+ keep, dropped = _keep_stronger((ia, fa), (ib, fb))
150
+ drop.add(dropped)
151
+ contradictions.append(Contradiction(
152
+ kind=ContradictionKind.CONDITION,
153
+ element=fa.element,
154
+ detail="One finding describes the element as defective, the other as satisfactory.",
155
+ finding_indices=[ia, ib],
156
+ resolution=f"kept finding #{keep} (stronger evidence), dropped #{dropped}",
157
+ ))
158
+ continue
159
+
160
+ # Operational vs non-operational.
161
+ if (oa and nb) or (na and ob):
162
+ keep, dropped = _keep_stronger((ia, fa), (ib, fb))
163
+ drop.add(dropped)
164
+ contradictions.append(Contradiction(
165
+ kind=ContradictionKind.OPERATIONAL,
166
+ element=fa.element,
167
+ detail="One finding states operational, the other non-operational.",
168
+ finding_indices=[ia, ib],
169
+ resolution=f"kept finding #{keep} (stronger evidence), dropped #{dropped}",
170
+ ))
171
+ continue
172
+
173
+ # Near-duplicate.
174
+ if _jaccard(fa.finding, fb.finding) >= duplicate_threshold:
175
+ keep, dropped = _keep_stronger((ia, fa), (ib, fb))
176
+ drop.add(dropped)
177
+ contradictions.append(Contradiction(
178
+ kind=ContradictionKind.DUPLICATE,
179
+ element=fa.element,
180
+ detail="Near-identical findings for the same element.",
181
+ finding_indices=[ia, ib],
182
+ resolution=f"collapsed to finding #{keep}, dropped #{dropped}",
183
+ ))
184
+
185
+ resolved = [f for i, f in enumerate(findings) if i not in drop]
186
+ return resolved, contradictions
app/extraction/domain_scope.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Section-domain scoping to prevent cross-section contamination (STEP 6).
2
+
3
+ Roofing prompts must only see roofing evidence; drainage only drainage, etc.
4
+ This module is a pure, deterministic keyword classifier β€” no embeddings, no
5
+ network β€” so the scoping decision is reproducible and unit-testable.
6
+
7
+ Usage::
8
+
9
+ domain = classify_section("Roofing", template_id="E1")
10
+ roofing_only = scope_chunks(domain, retrieved_chunks)
11
+
12
+ The classifier is intentionally conservative: a chunk with no clear domain
13
+ signal is treated as ``GENERAL`` and is admissible to any section (so we never
14
+ starve a section of context), but a chunk that clearly belongs to a *different*
15
+ domain is excluded.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from app.extraction.citation_validator import normalize_for_match
21
+
22
+ GENERAL = "general"
23
+
24
+ # Domain β†’ indicative terms. Order matters for deterministic tie-breaking
25
+ # (earlier domains win ties). Terms are matched as normalized substrings, so
26
+ # multi-word phrases are supported.
27
+ DOMAIN_LEXICON: dict[str, frozenset[str]] = {
28
+ "roofing": frozenset({
29
+ "roof", "roofing", "slate", "tile", "tiling", "ridge", "hip", "valley",
30
+ "verge", "eaves", "soffit", "fascia", "felt", "flashing", "parapet",
31
+ "covering", "rafter", "purlin", "truss", "flat roof", "pitched roof",
32
+ }),
33
+ "chimney": frozenset({
34
+ "chimney", "chimney stack", "flaunching", "pot", "cowl", "flue",
35
+ "breast", "corbel", "pointing to the stack",
36
+ }),
37
+ "rainwater": frozenset({
38
+ "gutter", "guttering", "downpipe", "rainwater", "hopper", "rwp",
39
+ "rainwater goods", "fall pipe",
40
+ }),
41
+ "drainage": frozenset({
42
+ "drain", "drainage", "sewer", "manhole", "inspection chamber", "gully",
43
+ "soil pipe", "foul", "surface water", "septic", "soakaway", "below ground",
44
+ }),
45
+ "walls": frozenset({
46
+ "wall", "masonry", "brickwork", "blockwork", "render", "rendering",
47
+ "pointing", "cavity", "spalling", "cracking", "lintel", "dpc",
48
+ "damp proof course", "external wall", "load bearing",
49
+ }),
50
+ "dampness": frozenset({
51
+ "damp", "dampness", "moisture", "rising damp", "penetrating damp",
52
+ "condensation", "mould", "mold", "hygroscopic", "salts", "tide mark",
53
+ }),
54
+ "timber": frozenset({
55
+ "timber", "woodworm", "beetle", "wet rot", "dry rot", "joist", "decay",
56
+ "fungal", "infestation", "rot to the",
57
+ }),
58
+ "windows_doors": frozenset({
59
+ "window", "windows", "door", "doors", "glazing", "double glazing",
60
+ "frame", "casement", "sash", "joinery", "fenestration", "sill",
61
+ }),
62
+ "ceilings_floors": frozenset({
63
+ "ceiling", "floor", "flooring", "floorboard", "screed", "plaster",
64
+ "lath", "cornice", "skirting", "subfloor",
65
+ }),
66
+ "electrical": frozenset({
67
+ "electric", "electrical", "wiring", "consumer unit", "fuse box",
68
+ "rcd", "socket", "circuit", "earthing", "eicr", "fixed wiring",
69
+ "distribution board",
70
+ }),
71
+ "heating": frozenset({
72
+ "boiler", "heating", "radiator", "central heating", "flue", "gas",
73
+ "thermostat", "hot water", "cylinder", "underfloor heating", "combi",
74
+ }),
75
+ "plumbing": frozenset({
76
+ "plumbing", "pipework", "water supply", "stopcock", "waste pipe",
77
+ "mains water", "lead pipe", "tank", "overflow", "sanitary",
78
+ }),
79
+ "insulation_energy": frozenset({
80
+ "insulation", "epc", "energy performance", "sap", "u-value",
81
+ "thermal", "loft insulation", "cavity insulation", "energy efficiency",
82
+ }),
83
+ "grounds": frozenset({
84
+ "garden", "boundary", "fence", "fencing", "patio", "driveway", "path",
85
+ "tree", "hedge", "retaining wall", "outbuilding", "grounds",
86
+ }),
87
+ "services_other": frozenset({
88
+ "ventilation", "extractor", "smoke alarm", "carbon monoxide",
89
+ "asbestos", "fire", "security",
90
+ }),
91
+ }
92
+
93
+ # Map common RICS section titles / template hints to a canonical domain.
94
+ _SECTION_ALIASES: dict[str, str] = {
95
+ "roof": "roofing", "roof coverings": "roofing", "main roof": "roofing",
96
+ "chimney stacks": "chimney", "chimneys": "chimney",
97
+ "rainwater pipes and gutters": "rainwater", "gutters": "rainwater",
98
+ "drainage": "drainage",
99
+ "main walls": "walls", "external walls": "walls", "walls": "walls",
100
+ "dampness": "dampness", "damp": "dampness",
101
+ "windows": "windows_doors", "doors": "windows_doors",
102
+ "ceilings": "ceilings_floors", "floors": "ceilings_floors",
103
+ "electricity": "electrical", "electrical": "electrical",
104
+ "heating": "heating", "gas": "heating",
105
+ "water": "plumbing", "plumbing": "plumbing",
106
+ "insulation": "insulation_energy", "energy efficiency": "insulation_energy",
107
+ "grounds": "grounds", "gardens": "grounds", "boundaries": "grounds",
108
+ }
109
+
110
+
111
+ def _score_domains(text: str) -> dict[str, int]:
112
+ norm = normalize_for_match(text)
113
+ if not norm:
114
+ return {}
115
+ scores: dict[str, int] = {}
116
+ for domain, terms in DOMAIN_LEXICON.items():
117
+ hits = 0
118
+ for term in terms:
119
+ # Count occurrences; multi-word terms matched as substrings.
120
+ if " " in term:
121
+ hits += norm.count(term)
122
+ else:
123
+ # word-ish boundary check to avoid 'gas' in 'gasket' etc.
124
+ hits += _count_word(norm, term)
125
+ if hits:
126
+ scores[domain] = hits
127
+ return scores
128
+
129
+
130
+ def _count_word(haystack: str, word: str) -> int:
131
+ count = 0
132
+ start = 0
133
+ n = len(word)
134
+ while True:
135
+ idx = haystack.find(word, start)
136
+ if idx == -1:
137
+ break
138
+ before = haystack[idx - 1] if idx > 0 else " "
139
+ after = haystack[idx + n] if idx + n < len(haystack) else " "
140
+ if not before.isalnum() and not after.isalnum():
141
+ count += 1
142
+ start = idx + n
143
+ return count
144
+
145
+
146
+ def classify_text(text: str) -> str:
147
+ """Return the dominant domain for ``text`` or :data:`GENERAL`."""
148
+ scores = _score_domains(text)
149
+ if not scores:
150
+ return GENERAL
151
+ # Highest score wins; ties broken by lexicon insertion order (stable).
152
+ best = max(DOMAIN_LEXICON.keys(), key=lambda d: scores.get(d, 0))
153
+ return best if scores.get(best, 0) > 0 else GENERAL
154
+
155
+
156
+ def classify_section(section_name: str | None, template_id: str | None = None) -> str:
157
+ """Resolve a section title/template into a canonical domain.
158
+
159
+ Falls back to keyword classification of the section name, then
160
+ :data:`GENERAL`.
161
+ """
162
+ name = normalize_for_match(section_name or "")
163
+ if name in _SECTION_ALIASES:
164
+ return _SECTION_ALIASES[name]
165
+ for alias, domain in _SECTION_ALIASES.items():
166
+ if alias in name:
167
+ return domain
168
+ guessed = classify_text(section_name or "")
169
+ return guessed
170
+
171
+
172
+ def scope_chunks(
173
+ section_domain: str,
174
+ chunks: list[object],
175
+ *,
176
+ strict: bool = True,
177
+ ) -> list[object]:
178
+ """Filter retrieved chunks to those admissible for ``section_domain``.
179
+
180
+ A chunk is admissible when its dominant domain equals ``section_domain`` or
181
+ is :data:`GENERAL`. Chunks clearly belonging to a *different* domain are
182
+ excluded. If filtering would remove everything (e.g. weak classification),
183
+ the original list is returned to avoid starving extraction β€” unless
184
+ ``strict`` is False, in which case the filtered (possibly empty) list is
185
+ always returned.
186
+
187
+ ``chunks`` are SearchResult-like objects exposing ``.text``.
188
+ """
189
+ if section_domain == GENERAL or not chunks:
190
+ return chunks
191
+ kept: list[object] = []
192
+ for c in chunks:
193
+ text = getattr(c, "text", "") or ""
194
+ cdom = classify_text(text)
195
+ if cdom == section_domain or cdom == GENERAL:
196
+ kept.append(c)
197
+ if not kept and strict:
198
+ # Never starve the extractor on a misclassification; better to extract
199
+ # from broader context than to silently drop the whole section.
200
+ return chunks
201
+ return kept
app/extraction/extractor.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Citation-grounded, schema-constrained section extraction (STEP 3/4/8).
2
+
3
+ Pipeline for one section:
4
+
5
+ section-scoped chunks
6
+ -> deterministic LLM extraction (temperature=0, top_p=1, seed, JSON mode)
7
+ -> Pydantic schema validation (closed enums, mandatory evidence)
8
+ -> citation/source-span validation (drop unsupported claims)
9
+ -> contradiction audit (resolve conflicts, collapse duplicates)
10
+ -> SectionExtraction (with confidence + dropped-claim audit trail)
11
+
12
+ The LLM is the only non-deterministic component, and it is pinned as hard as
13
+ the API allows. Everything after it is pure, deterministic Python.
14
+
15
+ This module is additive: it does not alter the existing generation path. Callers
16
+ opt in (see ``settings.enable_citation_extraction``). It degrades safely to an
17
+ empty extraction when no API key is configured, so import and unit tests never
18
+ require network access.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+
26
+ from app.config import settings
27
+ from app.extraction.citation_validator import validate_findings
28
+ from app.extraction.contradiction import audit_contradictions
29
+ from app.extraction.domain_scope import GENERAL, classify_section, scope_chunks
30
+ from app.extraction.prompts import (
31
+ EXTRACTION_SYSTEM_PROMPT,
32
+ EXTRACTION_USER_TEMPLATE,
33
+ build_chunks_block,
34
+ )
35
+ from app.extraction.output_validator import should_abstain
36
+ from app.extraction.schemas import (
37
+ AtomicClaim,
38
+ ClaimEvidence,
39
+ ClaimType,
40
+ ClaimVerification,
41
+ ConditionRating,
42
+ SectionExtraction,
43
+ SupportLevel,
44
+ SurveyFinding,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # Fixed seed for reproducible decoding across identical inputs.
50
+ _EXTRACTION_SEED = 7
51
+
52
+
53
+ def _chunk_tuples(chunks: list[object]) -> list[tuple[str, str, str | None]]:
54
+ """Adapt SearchResult-like rows into (chunk_id, text, label) tuples."""
55
+ out: list[tuple[str, str, str | None]] = []
56
+ for c in chunks:
57
+ cid = getattr(c, "chunk_id", None)
58
+ text = getattr(c, "text", None)
59
+ if not cid or not text:
60
+ continue
61
+ label = getattr(c, "section_title", None)
62
+ out.append((str(cid), str(text), label))
63
+ return out
64
+
65
+
66
+ def _parse_findings(raw_json: str, section: str) -> list[SurveyFinding]:
67
+ """Parse model JSON into validated SurveyFinding models, skipping malformed rows."""
68
+ try:
69
+ data = json.loads(raw_json)
70
+ except (json.JSONDecodeError, TypeError):
71
+ logger.warning("extractor: model returned non-JSON for section=%s", section)
72
+ return []
73
+ rows = data.get("findings", []) if isinstance(data, dict) else []
74
+ findings: list[SurveyFinding] = []
75
+ for row in rows:
76
+ if not isinstance(row, dict):
77
+ continue
78
+ row.setdefault("section", section)
79
+ try:
80
+ findings.append(SurveyFinding.model_validate(row))
81
+ except Exception as exc: # malformed row -> skip, never fabricate
82
+ logger.debug("extractor: skipped malformed finding: %s", exc)
83
+ return findings
84
+
85
+
86
+ async def extract_section(
87
+ *,
88
+ section: str,
89
+ chunks: list[object],
90
+ tenant_id: str | None = None,
91
+ drop_partial: bool = False,
92
+ domain: str | None = None,
93
+ model: str | None = None,
94
+ ) -> SectionExtraction:
95
+ """Extract verified, contradiction-free findings for one section.
96
+
97
+ When ``domain`` is provided (STEP 6), chunks clearly belonging to a
98
+ different domain are excluded before extraction as defense-in-depth β€” the
99
+ caller should still scope retrieval, but this guarantees the extractor never
100
+ sees cross-domain evidence. The supplied (scoped) pool is the ONLY
101
+ admissible evidence.
102
+
103
+ Returns a :class:`SectionExtraction`. With no API key (or no chunks), returns
104
+ an empty extraction rather than raising.
105
+ """
106
+ if domain and domain != GENERAL:
107
+ chunks = scope_chunks(domain, chunks)
108
+ pool = _chunk_tuples(chunks)
109
+ if not pool:
110
+ return SectionExtraction(section=section)
111
+ if not (settings.openai_api_key or "").strip():
112
+ logger.info("extractor: no API key; returning empty extraction for %s", section)
113
+ return SectionExtraction(section=section)
114
+
115
+ user_prompt = EXTRACTION_USER_TEMPLATE.format(
116
+ section=section,
117
+ chunks_block=build_chunks_block(pool),
118
+ )
119
+
120
+ from app.llm.openai_chat import chat_completions_create
121
+
122
+ raw = await chat_completions_create(
123
+ messages=[
124
+ {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
125
+ {"role": "user", "content": user_prompt},
126
+ ],
127
+ model=model or settings.chat_model,
128
+ max_tokens=settings.extraction_max_tokens,
129
+ temperature=0.0,
130
+ top_p=1.0,
131
+ seed=_EXTRACTION_SEED,
132
+ response_format={"type": "json_object"},
133
+ phase="extraction",
134
+ section_id=section,
135
+ tenant_id=tenant_id,
136
+ )
137
+
138
+ findings = _parse_findings(raw, section)
139
+
140
+ # Hard citation gate: drop everything not grounded in the supplied pool.
141
+ kept, dropped = validate_findings(findings, pool, drop_partial=drop_partial)
142
+
143
+ # Contradiction/duplicate audit on the survivors.
144
+ resolved, contradictions = audit_contradictions(kept)
145
+
146
+ logger.info(
147
+ "extract_section=%s extracted=%d kept=%d dropped=%d contradictions=%d",
148
+ section, len(findings), len(resolved), len(dropped), len(contradictions),
149
+ )
150
+
151
+ return SectionExtraction(
152
+ section=section,
153
+ findings=resolved,
154
+ contradictions=contradictions,
155
+ dropped_claims=dropped,
156
+ )
157
+
158
+
159
+ _SUPPORT_CONFIDENCE: dict[SupportLevel, float] = {
160
+ SupportLevel.SUPPORTED: 1.0,
161
+ SupportLevel.PARTIAL: 0.5,
162
+ SupportLevel.NOT_FOUND: 0.0,
163
+ }
164
+
165
+
166
+ def findings_to_atomic_claims(
167
+ findings: list[SurveyFinding],
168
+ *,
169
+ min_confidence: float = 1.0,
170
+ ) -> list[AtomicClaim]:
171
+ """Decompose grounded findings into atomic, evidence-bound claim records.
172
+
173
+ Each finding yields at most two atomic claims: a ``condition_rating`` claim
174
+ (only when the source stated a real CR1/CR2/CR3) and an ``observation``
175
+ claim for the finding text. Every claim is bound to the finding's first
176
+ valid evidence span and then passed through the abstention gate
177
+ (:func:`should_abstain`) β€” any claim that introduces forbidden/fabricated
178
+ language or falls below ``min_confidence`` is dropped (RETURN NOTHING).
179
+
180
+ Pure and deterministic: no network, no synthesis. Findings are assumed to
181
+ have already passed citation validation.
182
+ """
183
+ claims: list[AtomicClaim] = []
184
+ real_ratings = {ConditionRating.CR1, ConditionRating.CR2, ConditionRating.CR3}
185
+ for f in findings:
186
+ if not f.evidence:
187
+ continue
188
+ span = f.evidence[0]
189
+ evidence_text = " \n ".join(e.text for e in f.evidence)
190
+ confidence = _SUPPORT_CONFIDENCE.get(f.support, 0.0)
191
+ page = span.page if span.page is not None else 0
192
+ ev = ClaimEvidence(
193
+ text=span.text,
194
+ page=page,
195
+ section=span.section_label or "",
196
+ chunk_id=span.chunk_id,
197
+ )
198
+
199
+ if f.condition_rating in real_ratings:
200
+ rating_claim = f"{f.element} condition rating is {f.condition_rating.value}"
201
+ if not should_abstain(
202
+ rating_claim, evidence_text,
203
+ min_confidence=min_confidence, confidence=confidence,
204
+ ):
205
+ claims.append(AtomicClaim(
206
+ claim=rating_claim,
207
+ claim_type=ClaimType.CONDITION_RATING,
208
+ evidence=ev,
209
+ verification=ClaimVerification(
210
+ supported=True, contradiction_detected=False, confidence=confidence,
211
+ ),
212
+ ))
213
+
214
+ if not should_abstain(
215
+ f.finding, evidence_text,
216
+ min_confidence=min_confidence, confidence=confidence,
217
+ ):
218
+ claims.append(AtomicClaim(
219
+ claim=f.finding,
220
+ claim_type=ClaimType.OBSERVATION,
221
+ evidence=ev,
222
+ verification=ClaimVerification(
223
+ supported=True, contradiction_detected=False, confidence=confidence,
224
+ ),
225
+ ))
226
+ return claims
227
+
228
+
229
+ async def audit_section_grounding(
230
+ *,
231
+ section_name: str,
232
+ template_id: str | None,
233
+ chunks: list[object],
234
+ tenant_id: str | None = None,
235
+ ) -> SectionExtraction:
236
+ """High-level, non-destructive grounding audit for a generated section.
237
+
238
+ Resolves the section's domain, scopes the retrieved evidence to that domain,
239
+ and runs citation-grounded extraction + contradiction audit. Intended to be
240
+ called alongside generation (behind ``settings.enable_citation_extraction``)
241
+ to produce a traceability artifact β€” it never mutates generated prose.
242
+ """
243
+ domain = classify_section(section_name, template_id)
244
+ return await extract_section(
245
+ section=section_name or (template_id or "section"),
246
+ chunks=chunks,
247
+ tenant_id=tenant_id,
248
+ domain=domain,
249
+ )
app/extraction/output_validator.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-generation output validator & abstention layer.
2
+
3
+ Deterministic, pure-Python gate that runs AFTER extraction/generation and
4
+ REJECTS any output that smuggles in non-evidence-grounded language:
5
+
6
+ * forbidden hedging / synthesis phrases (``appears to``, ``likely``, ``overall``…),
7
+ * fabricated metrics (percentages, confidence/authenticity/consistency scores),
8
+ * unsupported severity escalation.
9
+
10
+ A forbidden token only survives if it is present verbatim in the cited evidence
11
+ (STEP: ABSOLUTE GROUNDING POLICY). The layer never rewrites text β€” it reports
12
+ violations and the caller abstains (drops the record). No network, fully
13
+ unit-testable.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from app.extraction.citation_validator import (
21
+ BANNED_SEVERITY_TERMS,
22
+ normalize_for_match,
23
+ )
24
+
25
+ # Hedging / synthesis / evaluation vocabulary that must never be introduced.
26
+ FORBIDDEN_PHRASES: frozenset[str] = frozenset({
27
+ "appears to", "seems to", "likely", "suggests", "indicates",
28
+ "approximately reliable", "overall", "authenticity", "consistency score",
29
+ "confidence score", "reliability score", "assessment", "evaluation",
30
+ "in summary", "in conclusion", "executive summary", "we recommend",
31
+ "it is recommended", "concerning", "potentially hazardous",
32
+ })
33
+
34
+ # Fabricated-metric patterns: percentages and explicit scoring statements.
35
+ _PERCENT_RE = re.compile(r"\b\d{1,3}(?:\.\d+)?\s?%")
36
+ _SCORE_RE = re.compile(
37
+ r"\b(?:authenticity|reliability|consistency|confidence|quality)\s+"
38
+ r"(?:score|rating|level|index)\b",
39
+ re.IGNORECASE,
40
+ )
41
+
42
+
43
+ def _present_in_evidence(term: str, evidence_norm: str) -> bool:
44
+ return term in evidence_norm
45
+
46
+
47
+ def find_violations(text: str, evidence: str = "") -> list[str]:
48
+ """Return a list of violation descriptions for ``text``.
49
+
50
+ A forbidden phrase / severity term is only a violation when it is NOT
51
+ present verbatim in ``evidence``. Percentages and scoring statements are
52
+ violations unless the identical token appears in evidence.
53
+ """
54
+ if not text:
55
+ return []
56
+ text_norm = normalize_for_match(text)
57
+ evidence_norm = normalize_for_match(evidence or "")
58
+ violations: list[str] = []
59
+
60
+ for phrase in FORBIDDEN_PHRASES:
61
+ if phrase in text_norm and not _present_in_evidence(phrase, evidence_norm):
62
+ violations.append(f"forbidden phrase {phrase!r}")
63
+
64
+ for term in BANNED_SEVERITY_TERMS:
65
+ if term in text_norm and not _present_in_evidence(term, evidence_norm):
66
+ violations.append(f"unsupported severity term {term!r}")
67
+
68
+ for m in _PERCENT_RE.findall(text):
69
+ token = normalize_for_match(m)
70
+ if token not in evidence_norm:
71
+ violations.append(f"fabricated percentage {m.strip()!r}")
72
+
73
+ for m in _SCORE_RE.findall(text):
74
+ if normalize_for_match(m) not in evidence_norm:
75
+ violations.append(f"fabricated metric {m.strip()!r}")
76
+
77
+ return violations
78
+
79
+
80
+ def is_clean(text: str, evidence: str = "") -> bool:
81
+ """True when ``text`` introduces no forbidden/fabricated content."""
82
+ return not find_violations(text, evidence)
83
+
84
+
85
+ def should_abstain(
86
+ text: str,
87
+ evidence: str = "",
88
+ *,
89
+ min_confidence: float = 1.0,
90
+ confidence: float = 1.0,
91
+ evidence_aligned: bool = True,
92
+ source_attributed: bool = True,
93
+ ) -> bool:
94
+ """Abstention logic (STEP: ABSTENTION LOGIC).
95
+
96
+ Returns True (drop the record / RETURN NOTHING) when any of:
97
+
98
+ * confidence is below ``min_confidence``,
99
+ * evidence alignment failed,
100
+ * source attribution failed,
101
+ * the text contains forbidden/fabricated content not grounded in evidence.
102
+ """
103
+ if confidence < min_confidence:
104
+ return True
105
+ if not evidence_aligned:
106
+ return True
107
+ if not source_attributed:
108
+ return True
109
+ return not is_clean(text, evidence)
app/extraction/prompts.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hardened anti-hallucination prompts for citation-grounded extraction.
2
+
3
+ These prompts implement STEP 5 (anti-hallucination rules) and STEP 4
4
+ (schema-constrained extraction). They instruct the model to behave as a
5
+ deterministic evidence extractor, never a writer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ EXTRACTION_SYSTEM_PROMPT = """\
11
+ You are a deterministic evidence-extraction engine for RICS property survey \
12
+ reports. You are NOT a writer, summariser, or assistant. You extract structured \
13
+ findings ONLY from the SOURCE CHUNKS provided. The PDF source is the single \
14
+ authority.
15
+
16
+ ABSOLUTE RULES (violating any one is a critical failure):
17
+ - Do NOT infer, speculate, generalise, or extrapolate.
18
+ - Do NOT add, upgrade, or downgrade severity. Copy the condition exactly.
19
+ - Do NOT invent risks, remediation actions, materials, locations, or entities.
20
+ - Do NOT substitute proper nouns. If the source says "London plane tree", you \
21
+ write "London plane tree" β€” never "cedar tree", "mature tree", or "vegetation".
22
+ - Do NOT add safety/health/environmental implications unless they appear \
23
+ verbatim in the source.
24
+ - Do NOT synthesise conclusions that are not directly stated in a cited span.
25
+ - Every finding MUST be backed by at least one verbatim evidence span copied \
26
+ from a source chunk, with that chunk's id.
27
+ - If the source does not support a field, OMIT it or use null. NEVER fill gaps.
28
+
29
+ CONDITION RATINGS: use ONLY one of "1", "2", "3", "NI" (not inspected), or "NA" \
30
+ (no rating stated). Never invent a rating. If the source gives no rating, use "NA".
31
+
32
+ OUTPUT: a single JSON object matching the provided schema. No prose outside JSON.
33
+ """
34
+
35
+ EXTRACTION_USER_TEMPLATE = """\
36
+ SECTION DOMAIN: {section}
37
+
38
+ You may ONLY use the SOURCE CHUNKS below. Do not use any outside knowledge.
39
+ Each chunk has an id you MUST cite in the "chunk_id" of every evidence span.
40
+ Evidence span text MUST be copied verbatim from the chunk it cites.
41
+
42
+ SOURCE CHUNKS:
43
+ {chunks_block}
44
+
45
+ Return JSON with this exact shape:
46
+ {{
47
+ "findings": [
48
+ {{
49
+ "section": "{section}",
50
+ "element": "<specific element named in the source>",
51
+ "condition_rating": "1" | "2" | "3" | "NI" | "NA",
52
+ "finding": "<description using only words/facts from the cited spans>",
53
+ "evidence": [
54
+ {{"chunk_id": "<id from a source chunk>", "text": "<verbatim span>"}}
55
+ ],
56
+ "page_refs": [<int page numbers if present in chunk labels>]
57
+ }}
58
+ ]
59
+ }}
60
+
61
+ If a chunk contains no extractable survey finding, do not invent one. Return an \
62
+ empty "findings" list rather than fabricating content.
63
+ """
64
+
65
+
66
+ def build_chunks_block(chunks: list[tuple[str, str, str | None]]) -> str:
67
+ """Render ``(chunk_id, text, label)`` tuples into a numbered prompt block."""
68
+ lines: list[str] = []
69
+ for cid, text, label in chunks:
70
+ header = f"[chunk_id={cid}"
71
+ if label:
72
+ header += f" | {label}"
73
+ header += "]"
74
+ lines.append(f"{header}\n{text.strip()}")
75
+ return "\n\n---\n\n".join(lines)
app/extraction/schemas.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema-constrained extraction types.
2
+
3
+ These Pydantic models are the contract the LLM extractor must satisfy. The model
4
+ may NEVER emit a value outside these constraints β€” condition ratings are a closed
5
+ enum, evidence is mandatory, and unsupported fields are represented explicitly
6
+ (``SupportLevel.NOT_FOUND``) rather than interpolated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+
13
+ from pydantic import BaseModel, Field, field_validator
14
+
15
+
16
+ class ConditionRating(str, Enum):
17
+ """RICS Home Survey condition rating β€” a closed set.
18
+
19
+ The extractor must map any source rating onto exactly one of these. It may
20
+ never invent an intermediate value, upgrade, or downgrade. ``NI`` / ``NA``
21
+ are explicit "no rating present" sentinels so a missing rating is never
22
+ silently coerced to a real one.
23
+ """
24
+
25
+ CR1 = "1" # No repair currently needed.
26
+ CR2 = "2" # Defects requiring repair/replacement but not urgent.
27
+ CR3 = "3" # Defects that are serious and/or need urgent attention.
28
+ NI = "NI" # Not inspected.
29
+ NA = "NA" # Not applicable / no rating stated in source.
30
+
31
+ @classmethod
32
+ def coerce(cls, value: object) -> "ConditionRating":
33
+ """Map a raw model/source value onto the enum without inventing a rating.
34
+
35
+ Anything that is not an exact, recognised rating becomes :attr:`NA`
36
+ (not a guessed CR1/CR2/CR3). This is deliberate: an unparseable rating
37
+ must degrade to "no rating", never to a fabricated severity.
38
+ """
39
+ if isinstance(value, cls):
40
+ return value
41
+ s = str(value or "").strip().upper()
42
+ direct = {
43
+ "1": cls.CR1, "CR1": cls.CR1, "CONDITION RATING 1": cls.CR1,
44
+ "2": cls.CR2, "CR2": cls.CR2, "CONDITION RATING 2": cls.CR2,
45
+ "3": cls.CR3, "CR3": cls.CR3, "CONDITION RATING 3": cls.CR3,
46
+ "NI": cls.NI, "NOT INSPECTED": cls.NI,
47
+ "NA": cls.NA, "N/A": cls.NA, "": cls.NA, "NONE": cls.NA,
48
+ }
49
+ return direct.get(s, cls.NA)
50
+
51
+
52
+ class SupportLevel(str, Enum):
53
+ """How well a claim is backed by cited source spans."""
54
+
55
+ SUPPORTED = "supported" # Every token traceable to a cited span.
56
+ PARTIAL = "partial" # Core claim supported; minor unverified detail.
57
+ NOT_FOUND = "not_found" # No supporting evidence β€” must be dropped.
58
+
59
+
60
+ class EvidenceSpan(BaseModel):
61
+ """A literal span copied from a retrieved source chunk.
62
+
63
+ ``text`` MUST be a verbatim (or near-verbatim) substring of the chunk
64
+ identified by ``chunk_id``. The citation validator rejects spans that do
65
+ not actually appear in their cited chunk.
66
+ """
67
+
68
+ chunk_id: str = Field(..., description="ID of the retrieved chunk this span came from.")
69
+ doc_id: str | None = Field(default=None, description="Source document UUID.")
70
+ page: int | None = Field(default=None, description="1-based page number when known.")
71
+ section_label: str | None = Field(default=None, description="Section/page heading label.")
72
+ text: str = Field(..., min_length=1, description="Verbatim span from the cited chunk.")
73
+
74
+ @field_validator("text")
75
+ @classmethod
76
+ def _strip(cls, v: str) -> str:
77
+ v = v.strip()
78
+ if not v:
79
+ raise ValueError("evidence span text cannot be blank")
80
+ return v
81
+
82
+
83
+ class GroundedClaim(BaseModel):
84
+ """An atomic claim and the evidence that supports it."""
85
+
86
+ claim: str = Field(..., min_length=1)
87
+ supporting_spans: list[EvidenceSpan] = Field(default_factory=list)
88
+ support: SupportLevel = Field(default=SupportLevel.NOT_FOUND)
89
+ page_refs: list[int] = Field(default_factory=list)
90
+
91
+
92
+ class SurveyFinding(BaseModel):
93
+ """One condition finding for a building element, fully evidence-backed.
94
+
95
+ This is the structured-extraction unit from STEP 4 of the spec. ``finding``
96
+ is descriptive prose derived ONLY from the cited spans; ``condition_rating``
97
+ is a closed enum; ``evidence`` carries the literal spans; ``page_refs`` the
98
+ source pages.
99
+ """
100
+
101
+ section: str = Field(..., description="Domain/section, e.g. 'Roofing', 'Drainage'.")
102
+ element: str = Field(..., description="Specific element, e.g. 'Main roof covering'.")
103
+ condition_rating: ConditionRating = Field(default=ConditionRating.NA)
104
+ finding: str = Field(..., min_length=1, description="Evidence-grounded description.")
105
+ evidence: list[EvidenceSpan] = Field(default_factory=list)
106
+ page_refs: list[int] = Field(default_factory=list)
107
+ support: SupportLevel = Field(default=SupportLevel.NOT_FOUND)
108
+
109
+ @field_validator("condition_rating", mode="before")
110
+ @classmethod
111
+ def _coerce_rating(cls, v: object) -> ConditionRating:
112
+ return ConditionRating.coerce(v)
113
+
114
+
115
+ class ContradictionKind(str, Enum):
116
+ OPERATIONAL = "operational_vs_non_operational"
117
+ CONDITION = "satisfactory_vs_defective"
118
+ RATING_CONFLICT = "inconsistent_condition_rating"
119
+ DUPLICATE = "duplicate_finding"
120
+ MUTUALLY_EXCLUSIVE = "mutually_exclusive_statement"
121
+ UNSUPPORTED_SUMMARY = "unsupported_summary"
122
+
123
+
124
+ class Contradiction(BaseModel):
125
+ """A detected logical inconsistency between two findings (or within one)."""
126
+
127
+ kind: ContradictionKind
128
+ element: str
129
+ detail: str
130
+ finding_indices: list[int] = Field(default_factory=list)
131
+ resolution: str | None = Field(
132
+ default=None,
133
+ description="Which side was kept and why (evidence-supported version preserved).",
134
+ )
135
+
136
+
137
+ class ClaimType(str, Enum):
138
+ """Closed set of atomic claim categories (no free-text types)."""
139
+
140
+ CONDITION_RATING = "condition_rating"
141
+ MEASUREMENT = "measurement"
142
+ MATERIAL = "material"
143
+ ENTITY = "entity" # named product/model/species/person/org
144
+ LOCATION = "location"
145
+ DATE = "date"
146
+ QUANTITY = "quantity"
147
+ OBSERVATION = "observation" # plain factual observation
148
+ OTHER = "other"
149
+
150
+
151
+ class ClaimEvidence(BaseModel):
152
+ """Exact evidence binding for one atomic claim."""
153
+
154
+ text: str = Field(..., min_length=1, description="Verbatim span from the cited chunk.")
155
+ page: int = Field(default=0, description="1-based page number; 0 when unknown.")
156
+ section: str = Field(default="", description="Section/heading label of the source span.")
157
+ chunk_id: str = Field(..., min_length=1, description="ID of the cited source chunk.")
158
+
159
+
160
+ class ClaimVerification(BaseModel):
161
+ """Verification outcome for one atomic claim."""
162
+
163
+ supported: bool = False
164
+ contradiction_detected: bool = False
165
+ confidence: float = Field(default=0.0, ge=0.0, le=1.0)
166
+
167
+
168
+ class AtomicClaim(BaseModel):
169
+ """A single atomic factual claim with mandatory evidence binding.
170
+
171
+ This is the forensic unit required by the evidence-comparison contract:
172
+ one indivisible fact, the exact span that supports it, and the deterministic
173
+ verification result. Unsupported claims are dropped before output.
174
+ """
175
+
176
+ claim: str = Field(..., min_length=1)
177
+ claim_type: ClaimType = Field(default=ClaimType.OTHER)
178
+ evidence: ClaimEvidence
179
+ verification: ClaimVerification = Field(default_factory=ClaimVerification)
180
+
181
+
182
+ class SectionExtraction(BaseModel):
183
+ """The full schema-constrained extraction for one report section."""
184
+
185
+ section: str
186
+ findings: list[SurveyFinding] = Field(default_factory=list)
187
+ contradictions: list[Contradiction] = Field(default_factory=list)
188
+ dropped_claims: list[str] = Field(
189
+ default_factory=list,
190
+ description="Claims removed for lack of supporting evidence (audit trail).",
191
+ )
192
+
193
+ @property
194
+ def confidence(self) -> float:
195
+ """Fraction of findings that are fully supported (0.0–1.0)."""
196
+ if not self.findings:
197
+ return 0.0
198
+ supported = sum(1 for f in self.findings if f.support == SupportLevel.SUPPORTED)
199
+ return round(supported / len(self.findings), 3)
app/generator/postprocess.py CHANGED
@@ -377,6 +377,10 @@ def enforce_verify(
377
  for key, val in _slots.items():
378
  text = text.replace(key, val)
379
 
 
 
 
 
380
  return text
381
 
382
 
@@ -429,10 +433,29 @@ def verbatim_overlap_ratio(text: str, sources: list[str], n: int = 6) -> float:
429
  return matched / len(text_grams)
430
 
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  def _strip_missing_fact_phrases(text: str) -> str:
433
  """Remove hard-coded missing-data filler phrases from output text."""
434
  if not text:
435
  return text
 
436
  cleaned = re.sub(
437
  r"\bInformation not provided in source document\b[.,;:!?]*",
438
  "",
 
377
  for key, val in _slots.items():
378
  text = text.replace(key, val)
379
 
380
+ # Collapse degenerate runs of the missing-data placeholder (e.g. a minimum-
381
+ # interference output that padded sparse notes by repeating the token).
382
+ text = _collapse_placeholder_runs(text)
383
+
384
  return text
385
 
386
 
 
433
  return matched / len(text_grams)
434
 
435
 
436
+ _REPEATED_PLACEHOLDER_RE = re.compile(r"(?:\[DATA NOT PROVIDED\]\s*){2,}", re.IGNORECASE)
437
+
438
+
439
+ def _collapse_placeholder_runs(text: str) -> str:
440
+ """Collapse degenerate runs of the missing-data placeholder.
441
+
442
+ Minimum-interference mode can pad sparse notes up to the word floor by
443
+ repeating ``[DATA NOT PROVIDED]`` dozens or hundreds of times. Any run of
444
+ two or more (optionally whitespace-separated) collapses to a single token,
445
+ so it still signals a gap without spamming the section.
446
+ """
447
+ if not text:
448
+ return text
449
+ collapsed = _REPEATED_PLACEHOLDER_RE.sub("[DATA NOT PROVIDED] ", text)
450
+ collapsed = re.sub(r"\s{2,}", " ", collapsed)
451
+ return collapsed.strip()
452
+
453
+
454
  def _strip_missing_fact_phrases(text: str) -> str:
455
  """Remove hard-coded missing-data filler phrases from output text."""
456
  if not text:
457
  return text
458
+ text = _collapse_placeholder_runs(text)
459
  cleaned = re.sub(
460
  r"\bInformation not provided in source document\b[.,;:!?]*",
461
  "",
app/generator/prompts.py CHANGED
@@ -268,7 +268,9 @@ the STANDARD SOURCE PASSAGES. Use retrieved uploaded-report excerpts only to res
268
  ambiguous terminology or to confirm section relevance β€” do NOT copy narrative or \
269
  property-specific findings from them. Do NOT invent, infer, expand, or editorialize beyond \
270
  grammar and structural placement. Preserve the surveyor's original wording as closely as \
271
- professional RICS grammar allows.\
 
 
272
  """
273
 
274
 
@@ -325,10 +327,13 @@ structure of [standard_paragraphs].\n\
325
  Use [uploaded_reports] solely to understand terminology and paragraph context. Do not copy content from them.\n\
326
  Do NOT add any information, interpretation, opinion, transition phrase, or filler sentence that does not \
327
  originate directly from the messy notes.\n\
328
- If a subsection of the standard structure has no corresponding data in the messy notes, leave it blank or \
329
- insert the exact token [DATA NOT PROVIDED] β€” do not fill it.\n\
 
330
  Preserve the user's original wording as closely as possible; clean grammar and structure only.\n\
331
- Output length: match the density of the input notes β€” no padding. Stay within {min_words}–{max_words} words.\n\
 
 
332
  """
333
 
334
 
 
268
  ambiguous terminology or to confirm section relevance β€” do NOT copy narrative or \
269
  property-specific findings from them. Do NOT invent, infer, expand, or editorialize beyond \
270
  grammar and structural placement. Preserve the surveyor's original wording as closely as \
271
+ professional RICS grammar allows. There is no minimum length and no word-count floor: never pad, \
272
+ never repeat a sentence or token, and never emit [DATA NOT PROVIDED] more than once in a row β€” a \
273
+ sparse note simply yields a sparse section.\
274
  """
275
 
276
 
 
327
  Use [uploaded_reports] solely to understand terminology and paragraph context. Do not copy content from them.\n\
328
  Do NOT add any information, interpretation, opinion, transition phrase, or filler sentence that does not \
329
  originate directly from the messy notes.\n\
330
+ If a subsection of the standard structure has no corresponding data in the messy notes, omit it. You MAY \
331
+ insert the exact token [DATA NOT PROVIDED] at most ONCE for that subsection β€” never repeat it, never place \
332
+ it more than once in a row, and never use it (or any phrase) as filler to reach a length target.\n\
333
  Preserve the user's original wording as closely as possible; clean grammar and structure only.\n\
334
+ Output length is governed SOLELY by the density of the input notes. There is NO minimum word count: short \
335
+ notes produce short output. Never pad, repeat sentences, or duplicate tokens to lengthen the text. Do not \
336
+ exceed {max_words} words.\n\
337
  """
338
 
339
 
app/ingest/hierarchy.py CHANGED
@@ -113,7 +113,10 @@ def build_hierarchical_documents(
113
  chunk.metadata["section_title"] = stitle
114
  chunk.metadata["paragraph_index"] = j
115
  chunk.metadata["parent_hierarchy"] = "section"
116
- chunk.metadata["section_type"] = "paragraph"
 
 
 
117
  chunk.metadata["proposed_chunk_id"] = f"{doc_id}_hier_{sid}_p{j:04d}"
118
  chunk.metadata.setdefault("source", stem)
119
  paragraph_docs.append(chunk)
 
113
  chunk.metadata["section_title"] = stitle
114
  chunk.metadata["paragraph_index"] = j
115
  chunk.metadata["parent_hierarchy"] = "section"
116
+ # Preserve table-aware tagging from the splitter; only default prose
117
+ # chunks to "paragraph".
118
+ if chunk.metadata.get("section_type") != "table":
119
+ chunk.metadata["section_type"] = "paragraph"
120
  chunk.metadata["proposed_chunk_id"] = f"{doc_id}_hier_{sid}_p{j:04d}"
121
  chunk.metadata.setdefault("source", stem)
122
  paragraph_docs.append(chunk)
app/ingest/ocr_normalize.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OCR / layout normalization for extracted PDF text (STEP 1).
2
+
3
+ PDF text extraction routinely corrupts the document in ways that poison
4
+ downstream retrieval and generation:
5
+
6
+ * sentences are broken by hard line wraps,
7
+ * words are split with end-of-line hyphens ("condi-\ntion"),
8
+ * the same running header / footer / page number repeats on every page,
9
+ * whitespace and newlines are inconsistent.
10
+
11
+ These functions repair that corruption deterministically before chunking, so
12
+ chunks contain whole sentences and no boilerplate noise. Pure string ops β€” no
13
+ dependencies, fully unit-testable.
14
+
15
+ The table sentinel (``[TABLE]…[/TABLE]``) emitted by the parser is treated as
16
+ opaque: normalization never reflows text inside a table block.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ TABLE_OPEN = "[TABLE]"
24
+ TABLE_CLOSE = "[/TABLE]"
25
+
26
+ _TABLE_BLOCK_RE = re.compile(
27
+ re.escape(TABLE_OPEN) + r".*?" + re.escape(TABLE_CLOSE),
28
+ re.DOTALL,
29
+ )
30
+ # end-of-line hyphenation: "condi-\ntion" -> "condition"
31
+ _HYPHEN_WRAP_RE = re.compile(r"(\w)-\n[ \t]*(\w)")
32
+ # bare page-number / "Page x of y" / "x / y" lines
33
+ _PAGE_NUM_RE = re.compile(
34
+ r"^\s*(?:page\s+)?\d+\s*(?:of|/)\s*\d+\s*$|^\s*page\s+\d+\s*$|^\s*\d{1,4}\s*$",
35
+ re.IGNORECASE,
36
+ )
37
+
38
+
39
+ def _protect_tables(text: str) -> tuple[str, list[str]]:
40
+ """Replace table blocks with placeholders so reflow never touches them."""
41
+ blocks: list[str] = []
42
+
43
+ def _stash(m: re.Match[str]) -> str:
44
+ blocks.append(m.group(0))
45
+ return f"\x00TBL{len(blocks) - 1}\x00"
46
+
47
+ return _TABLE_BLOCK_RE.sub(_stash, text), blocks
48
+
49
+
50
+ def _restore_tables(text: str, blocks: list[str]) -> str:
51
+ for i, block in enumerate(blocks):
52
+ text = text.replace(f"\x00TBL{i}\x00", block)
53
+ return text
54
+
55
+
56
+ def normalize_text(text: str) -> str:
57
+ """Repair a single page/section of extracted text.
58
+
59
+ Steps: normalize unicode whitespace, de-hyphenate wrapped words, unwrap
60
+ hard-wrapped sentences within a paragraph (single newline -> space) while
61
+ preserving paragraph breaks (blank lines), and collapse excess whitespace.
62
+ Table blocks are preserved verbatim.
63
+ """
64
+ if not text:
65
+ return ""
66
+
67
+ protected, blocks = _protect_tables(text)
68
+
69
+ # Normalize unicode whitespace / non-breaking spaces.
70
+ protected = protected.replace("\u00a0", " ").replace("\r\n", "\n").replace("\r", "\n")
71
+
72
+ # De-hyphenate words split across a line break.
73
+ protected = _HYPHEN_WRAP_RE.sub(r"\1\2", protected)
74
+
75
+ # Unwrap: within each blank-line-delimited paragraph, join hard-wrapped
76
+ # lines into a single line. This restores sentence continuity that PDF
77
+ # extraction destroys by emitting one newline per visual line.
78
+ paragraphs = re.split(r"\n[ \t]*\n", protected)
79
+ rebuilt: list[str] = []
80
+ for para in paragraphs:
81
+ if "\x00TBL" in para:
82
+ rebuilt.append(para.strip())
83
+ continue
84
+ lines = [ln.strip() for ln in para.split("\n") if ln.strip()]
85
+ if not lines:
86
+ continue
87
+ rebuilt.append(" ".join(lines))
88
+ out = "\n\n".join(rebuilt)
89
+
90
+ # Collapse runs of spaces and excessive blank lines.
91
+ out = re.sub(r"[ \t]{2,}", " ", out)
92
+ out = re.sub(r"\n{3,}", "\n\n", out)
93
+
94
+ return _restore_tables(out.strip(), blocks)
95
+
96
+
97
+ def _candidate_boundary_lines(page_text: str, edge: int = 3) -> set[str]:
98
+ """First/last ``edge`` non-empty lines of a page (header/footer candidates)."""
99
+ lines = [ln.strip() for ln in page_text.split("\n") if ln.strip()]
100
+ if not lines:
101
+ return set()
102
+ return set(lines[:edge]) | set(lines[-edge:])
103
+
104
+
105
+ def strip_running_headers_footers(pages: list[str], *, edge: int = 3) -> list[str]:
106
+ """Remove repeated running headers/footers and page numbers across pages.
107
+
108
+ A short line appearing in the top/bottom ``edge`` lines of a majority of
109
+ pages is treated as boilerplate and removed from every page. Bare page
110
+ numbers are always removed. Single-page documents are returned unchanged
111
+ (no cross-page signal to safely act on).
112
+ """
113
+ if len(pages) < 3:
114
+ # Still strip bare page numbers even when we can't detect repetition.
115
+ return [_drop_page_numbers(p) for p in pages]
116
+
117
+ freq: dict[str, int] = {}
118
+ for p in pages:
119
+ for line in _candidate_boundary_lines(p, edge):
120
+ if len(line) <= 120:
121
+ freq[line] = freq.get(line, 0) + 1
122
+
123
+ threshold = max(2, int(len(pages) * 0.5))
124
+ boilerplate = {ln for ln, n in freq.items() if n >= threshold}
125
+
126
+ cleaned: list[str] = []
127
+ for p in pages:
128
+ kept = []
129
+ for line in p.split("\n"):
130
+ s = line.strip()
131
+ if s and s in boilerplate:
132
+ continue
133
+ kept.append(line)
134
+ cleaned.append(_drop_page_numbers("\n".join(kept)))
135
+ return cleaned
136
+
137
+
138
+ def _drop_page_numbers(text: str) -> str:
139
+ return "\n".join(
140
+ ln for ln in text.split("\n") if not _PAGE_NUM_RE.match(ln.strip())
141
+ )
142
+
143
+
144
+ def normalize_pages(pages: list[str]) -> list[str]:
145
+ """Full document-level normalization: strip boilerplate, then reflow each page."""
146
+ deboiled = strip_running_headers_footers(pages)
147
+ return [normalize_text(p) for p in deboiled]
app/ingest/parser_pdf.py CHANGED
@@ -1,59 +1,155 @@
1
- """Parse .pdf files using the LangChain PyMuPDFLoader.
2
-
3
- LangChain replaces the manual pymupdf font-size heuristics and line-by-line
4
- block extraction with ``PyMuPDFLoader`` which returns one LangChain
5
- ``Document`` per page. Page-level metadata (``page``, ``source``) is
6
- preserved automatically.
7
-
8
- Compared to master branch:
9
- - βœ… Simpler code β€” no font-size heading heuristics
10
- - βœ… Standard LangChain interface with reliable per-page Documents
11
- - βœ… File handle automatically managed by the loader
12
- - ⚠️ Heading detection not performed (heading levels not in metadata)
 
 
 
 
 
 
 
 
 
13
  """
14
 
 
 
15
  import logging
16
  from pathlib import Path
17
 
18
- from langchain_community.document_loaders import PyMuPDFLoader
19
  from langchain_core.documents import Document
20
 
 
 
21
  logger = logging.getLogger(__name__)
22
 
23
 
24
- def parse_pdf(file_path: Path) -> list[Document]:
25
- """Load a ``.pdf`` file and return its pages as LangChain Documents.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- Uses ``langchain_community.document_loaders.PyMuPDFLoader`` which
28
- extracts text from each page and attaches ``page``, ``source``,
29
- ``total_pages``, and ``file_path`` metadata.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  Args:
32
  file_path: Absolute path to a ``.pdf`` file.
33
 
34
  Returns:
35
- List of :class:`langchain_core.documents.Document` objects, one per
36
- PDF page. Empty pages are included with empty ``page_content``.
 
37
 
38
  Raises:
39
  FileNotFoundError: If ``file_path`` does not exist.
40
- ValueError: If the file cannot be opened as a valid PDF.
41
-
42
- Example::
43
-
44
- docs = parse_pdf(Path("/uploads/survey.pdf"))
45
- print(f"Loaded {len(docs)} pages")
46
  """
47
  if not file_path.exists():
48
  raise FileNotFoundError(f"File not found: {file_path}")
49
 
50
  try:
51
- loader = PyMuPDFLoader(str(file_path))
52
- docs = loader.load()
53
- except Exception as exc:
54
- raise ValueError(f"Cannot open PDF '{file_path}': {exc}") from exc
55
-
56
- logger.debug(
57
- "Parsed %d page(s) from %s via LangChain PyMuPDFLoader", len(docs), file_path.name
58
- )
59
- return docs
 
 
 
 
 
 
 
1
+ """Layout- and table-aware PDF parsing (STEP 1).
2
+
3
+ Replaces the plain ``PyMuPDFLoader`` (which flattens tables into ambiguous
4
+ text and leaves OCR line-wrap corruption in place) with a hardened parser built
5
+ on PyMuPDF (``fitz``) β€” already a project dependency, so no new packages.
6
+
7
+ Per page we:
8
+
9
+ 1. Extract text in reading order (``sort=True``) to reduce multi-column
10
+ scrambling.
11
+ 2. Detect tables with ``page.find_tables()`` and render each as Markdown
12
+ wrapped in ``[TABLE]…[/TABLE]`` sentinels so condition-rating blocks and
13
+ financial totals survive chunking with row/column structure intact and are
14
+ NEVER flattened into prose.
15
+ 3. Normalize the prose across pages: strip running headers/footers and page
16
+ numbers, repair hyphenation, and unwrap hard-wrapped sentences.
17
+
18
+ Output remains a list of LangChain ``Document`` objects (one per page) with the
19
+ same ``page`` / ``source`` / ``total_pages`` metadata the rest of the pipeline
20
+ expects, so this is a drop-in replacement. If anything fails, we fall back to
21
+ the original ``PyMuPDFLoader`` so ingestion never hard-fails on a quirky PDF.
22
  """
23
 
24
+ from __future__ import annotations
25
+
26
  import logging
27
  from pathlib import Path
28
 
 
29
  from langchain_core.documents import Document
30
 
31
+ from app.ingest.ocr_normalize import TABLE_CLOSE, TABLE_OPEN, normalize_pages
32
+
33
  logger = logging.getLogger(__name__)
34
 
35
 
36
+ def _cell(value: object) -> str:
37
+ """Render one table cell: strip, collapse newlines, escape pipes."""
38
+ s = "" if value is None else str(value)
39
+ return s.replace("\n", " ").replace("|", "\\|").strip()
40
+
41
+
42
+ def _table_to_markdown(rows: list[list[object]]) -> str:
43
+ """Render extracted table rows as a GitHub-flavoured Markdown table."""
44
+ cleaned = [[_cell(c) for c in row] for row in rows if row]
45
+ cleaned = [r for r in cleaned if any(c for c in r)]
46
+ if not cleaned:
47
+ return ""
48
+ width = max(len(r) for r in cleaned)
49
+ cleaned = [r + [""] * (width - len(r)) for r in cleaned]
50
+ header = cleaned[0]
51
+ body = cleaned[1:]
52
+ lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(["---"] * width) + " |"]
53
+ for r in body:
54
+ lines.append("| " + " | ".join(r) + " |")
55
+ return "\n".join(lines)
56
+
57
+
58
+ def _extract_page_tables(page: object) -> list[str]:
59
+ """Return Markdown for each table on ``page`` (best-effort, never raises)."""
60
+ out: list[str] = []
61
+ try:
62
+ finder = page.find_tables()
63
+ except Exception as exc: # find_tables can choke on malformed content
64
+ logger.debug("find_tables failed on a page: %s", exc)
65
+ return out
66
+ tables = getattr(finder, "tables", None) or []
67
+ for tbl in tables:
68
+ try:
69
+ md = _table_to_markdown(tbl.extract())
70
+ except Exception as exc: # noqa: BLE001 β€” a bad table must not kill the page
71
+ logger.debug("table.extract failed: %s", exc)
72
+ continue
73
+ if md:
74
+ out.append(f"{TABLE_OPEN}\n{md}\n{TABLE_CLOSE}")
75
+ return out
76
+
77
 
78
+ def _parse_pdf_fitz(file_path: Path) -> list[Document]:
79
+ """Primary path: layout/table-aware extraction via PyMuPDF."""
80
+ import fitz # PyMuPDF β€” already a dependency
81
+
82
+ doc = fitz.open(str(file_path))
83
+ try:
84
+ total_pages = doc.page_count
85
+ prose_pages: list[str] = []
86
+ page_tables: list[list[str]] = []
87
+ for page in doc:
88
+ prose_pages.append(page.get_text("text", sort=True) or "")
89
+ page_tables.append(_extract_page_tables(page))
90
+ finally:
91
+ doc.close()
92
+
93
+ normalized = normalize_pages(prose_pages)
94
+
95
+ documents: list[Document] = []
96
+ for i, prose in enumerate(normalized):
97
+ parts = [prose.strip()] if prose.strip() else []
98
+ parts.extend(page_tables[i])
99
+ content = "\n\n".join(parts)
100
+ documents.append(
101
+ Document(
102
+ page_content=content,
103
+ metadata={
104
+ "page": i,
105
+ "total_pages": total_pages,
106
+ "source": file_path.name,
107
+ "file_path": str(file_path),
108
+ "has_tables": bool(page_tables[i]),
109
+ },
110
+ )
111
+ )
112
+ return documents
113
+
114
+
115
+ def _parse_pdf_fallback(file_path: Path) -> list[Document]:
116
+ """Fallback: original LangChain loader (no table structure, no normalization)."""
117
+ from langchain_community.document_loaders import PyMuPDFLoader
118
+
119
+ return PyMuPDFLoader(str(file_path)).load()
120
+
121
+
122
+ def parse_pdf(file_path: Path) -> list[Document]:
123
+ """Load a ``.pdf`` and return one normalized, table-aware Document per page.
124
 
125
  Args:
126
  file_path: Absolute path to a ``.pdf`` file.
127
 
128
  Returns:
129
+ List of :class:`langchain_core.documents.Document`, one per page, with
130
+ prose normalized and tables preserved as Markdown inside
131
+ ``[TABLE]…[/TABLE]`` sentinels.
132
 
133
  Raises:
134
  FileNotFoundError: If ``file_path`` does not exist.
135
+ ValueError: If the file cannot be opened as a valid PDF by either path.
 
 
 
 
 
136
  """
137
  if not file_path.exists():
138
  raise FileNotFoundError(f"File not found: {file_path}")
139
 
140
  try:
141
+ docs = _parse_pdf_fitz(file_path)
142
+ logger.debug(
143
+ "Parsed %d page(s) from %s (table-aware PyMuPDF)", len(docs), file_path.name
144
+ )
145
+ return docs
146
+ except Exception as exc: # noqa: BLE001 β€” degrade gracefully, never lose ingestion
147
+ logger.warning(
148
+ "Table-aware PDF parse failed for %s (%s); falling back to PyMuPDFLoader",
149
+ file_path.name,
150
+ exc,
151
+ )
152
+ try:
153
+ return _parse_pdf_fallback(file_path)
154
+ except Exception as exc2:
155
+ raise ValueError(f"Cannot open PDF '{file_path}': {exc2}") from exc2
app/llm/openai_chat.py CHANGED
@@ -16,6 +16,8 @@ async def chat_completions_create_raw(
16
  max_tokens: int = 800,
17
  temperature: float = 0.0,
18
  response_format: dict[str, str] | None = None,
 
 
19
  phase: str = "chat",
20
  section_id: str | None = None,
21
  api_key: str | None = None,
@@ -35,6 +37,10 @@ async def chat_completions_create_raw(
35
  }
36
  if response_format is not None:
37
  kwargs["response_format"] = response_format
 
 
 
 
38
 
39
  if settings.enable_async_pipeline:
40
  from openai import AsyncOpenAI
@@ -96,6 +102,8 @@ async def chat_completions_create(
96
  max_tokens: int = 800,
97
  temperature: float = 0.0,
98
  response_format: dict[str, str] | None = None,
 
 
99
  phase: str = "chat",
100
  section_id: str | None = None,
101
  api_key: str | None = None,
@@ -108,6 +116,8 @@ async def chat_completions_create(
108
  max_tokens=max_tokens,
109
  temperature=temperature,
110
  response_format=response_format,
 
 
111
  api_key=api_key,
112
  phase=phase,
113
  section_id=section_id,
 
16
  max_tokens: int = 800,
17
  temperature: float = 0.0,
18
  response_format: dict[str, str] | None = None,
19
+ top_p: float | None = None,
20
+ seed: int | None = None,
21
  phase: str = "chat",
22
  section_id: str | None = None,
23
  api_key: str | None = None,
 
37
  }
38
  if response_format is not None:
39
  kwargs["response_format"] = response_format
40
+ if top_p is not None:
41
+ kwargs["top_p"] = top_p
42
+ if seed is not None:
43
+ kwargs["seed"] = seed
44
 
45
  if settings.enable_async_pipeline:
46
  from openai import AsyncOpenAI
 
102
  max_tokens: int = 800,
103
  temperature: float = 0.0,
104
  response_format: dict[str, str] | None = None,
105
+ top_p: float | None = None,
106
+ seed: int | None = None,
107
  phase: str = "chat",
108
  section_id: str | None = None,
109
  api_key: str | None = None,
 
116
  max_tokens=max_tokens,
117
  temperature=temperature,
118
  response_format=response_format,
119
+ top_p=top_p,
120
+ seed=seed,
121
  api_key=api_key,
122
  phase=phase,
123
  section_id=section_id,
app/main.py CHANGED
@@ -16,6 +16,7 @@ from sqlalchemy import text
16
 
17
  from app.api import (
18
  agentic,
 
19
  canonical_rollout,
20
  catalog,
21
  content_similarity,
@@ -314,6 +315,7 @@ def create_app() -> FastAPI:
314
  )
315
  app.add_middleware(TenantAuthMiddleware)
316
 
 
317
  app.include_router(upload.router, tags=["upload"])
318
  app.include_router(survey_level.router, tags=["upload"])
319
  app.include_router(style_library.router, tags=["style-library"])
 
16
 
17
  from app.api import (
18
  agentic,
19
+ auth,
20
  canonical_rollout,
21
  catalog,
22
  content_similarity,
 
315
  )
316
  app.add_middleware(TenantAuthMiddleware)
317
 
318
+ app.include_router(auth.router, tags=["auth"])
319
  app.include_router(upload.router, tags=["upload"])
320
  app.include_router(survey_level.router, tags=["upload"])
321
  app.include_router(style_library.router, tags=["style-library"])
app/models/schemas.py CHANGED
@@ -42,6 +42,30 @@ class WritingStyleProfile(BaseModel):
42
  )
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # ── Upload ────────────────────────────────────────────────────────────────────
46
 
47
  class UploadResponse(BaseModel):
@@ -429,6 +453,14 @@ class SectionPayload(BaseModel):
429
  default=None,
430
  description="ISO 8601 UTC timestamp when this section was last generated or updated by AI.",
431
  )
 
 
 
 
 
 
 
 
432
 
433
 
434
  class AILevel(int, Enum):
@@ -1116,6 +1148,20 @@ class DocumentDeleteResponse(BaseModel):
1116
  detail: str = ""
1117
 
1118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  # ── Style Library (per-tenant past reports for style learning) ───────────────
1120
 
1121
 
 
42
  )
43
 
44
 
45
+ # ── Auth ──────────────────────────────────────────────────────────────────────
46
+
47
+ class AuthRequest(BaseModel):
48
+ """Register / login payload: tenant identity + passphrase."""
49
+
50
+ tenant_id: str = Field(..., description="Tenant/User ID (workspace identifier)")
51
+ passphrase: str = Field(..., description="Account passphrase (min 8 chars)")
52
+
53
+
54
+ class AuthTokenResponse(BaseModel):
55
+ """Issued bearer token bound to the verified tenant."""
56
+
57
+ tenant_id: str
58
+ access_token: str
59
+ token_type: str = "bearer"
60
+ expires_at: int = Field(..., description="Unix epoch seconds when the token expires")
61
+
62
+
63
+ class AuthWhoAmIResponse(BaseModel):
64
+ """Tenant resolved from the current request's verified credentials."""
65
+
66
+ tenant_id: str
67
+
68
+
69
  # ── Upload ────────────────────────────────────────────────────────────────────
70
 
71
  class UploadResponse(BaseModel):
 
453
  default=None,
454
  description="ISO 8601 UTC timestamp when this section was last generated or updated by AI.",
455
  )
456
+ citation_audit: dict[str, Any] | None = Field(
457
+ default=None,
458
+ description=(
459
+ "Citation-grounded extraction audit for this section: confidence "
460
+ "(fraction of fully-supported findings), supported findings count, "
461
+ "detected contradictions, and claims dropped for lack of evidence."
462
+ ),
463
+ )
464
 
465
 
466
  class AILevel(int, Enum):
 
1148
  detail: str = ""
1149
 
1150
 
1151
+ class DocumentReingestResponse(BaseModel):
1152
+ """Result of re-queuing one or more documents through the ingestion pipeline."""
1153
+
1154
+ queued: int = Field(..., description="Number of documents re-queued for ingestion.")
1155
+ document_ids: list[str] = Field(default_factory=list)
1156
+ skipped_active: int = Field(
1157
+ default=0, description="Documents skipped because a report is generating against them."
1158
+ )
1159
+ skipped_missing_file: int = Field(
1160
+ default=0, description="Documents skipped because their source file is gone."
1161
+ )
1162
+ detail: str = ""
1163
+
1164
+
1165
  # ── Style Library (per-tenant past reports for style learning) ───────────────
1166
 
1167
 
app/services/document_library.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for tenant RAG document list and delete operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from fastapi import HTTPException
10
+ from sqlalchemy import delete, func, select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from app.db.models import Document, DocumentPurpose, IngestStatus, Report
14
+ from app.models.schemas import DocumentDeleteResponse, DocumentListItem
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def ingest_status_to_display(status: IngestStatus | str) -> str:
20
+ """Map DB ingest status to UI-friendly labels."""
21
+ raw = status.value if isinstance(status, IngestStatus) else str(status)
22
+ if raw in ("pending", "processing"):
23
+ return "processing"
24
+ if raw == "complete":
25
+ return "ready"
26
+ if raw == "failed":
27
+ return "failed"
28
+ return raw
29
+
30
+
31
+ def file_size_bytes(file_path: str | None) -> int | None:
32
+ """Return on-disk byte size when the upload file still exists."""
33
+ if not file_path:
34
+ return None
35
+ try:
36
+ p = Path(file_path)
37
+ if p.is_file():
38
+ return int(p.stat().st_size)
39
+ except OSError:
40
+ return None
41
+ return None
42
+
43
+
44
+ def document_list_item(
45
+ doc: Document,
46
+ *,
47
+ chunk_count: int,
48
+ linked_report_count: int,
49
+ ) -> DocumentListItem:
50
+ purpose = doc.document_purpose
51
+ purpose_val = purpose.value if isinstance(purpose, DocumentPurpose) else str(purpose)
52
+ ingest = doc.status.value if isinstance(doc.status, IngestStatus) else str(doc.status)
53
+ return DocumentListItem(
54
+ document_id=doc.id,
55
+ filename=doc.filename,
56
+ file_size_bytes=file_size_bytes(doc.file_path),
57
+ upload_timestamp=doc.created_at,
58
+ updated_at=doc.updated_at,
59
+ status=ingest_status_to_display(doc.status),
60
+ ingest_status=ingest,
61
+ document_purpose=purpose_val,
62
+ chunk_count=int(chunk_count),
63
+ survey_level=doc.survey_level,
64
+ error_message=doc.error_message,
65
+ storage_path=doc.file_path,
66
+ linked_report_count=int(linked_report_count),
67
+ )
68
+
69
+
70
+ async def delete_tenant_document(
71
+ db: AsyncSession,
72
+ *,
73
+ tenant_id: str,
74
+ document_id: str,
75
+ expected_purpose: DocumentPurpose | None = DocumentPurpose.report_source,
76
+ ) -> DocumentDeleteResponse:
77
+ """Remove a document from vector index, disk, and database.
78
+
79
+ Args:
80
+ expected_purpose: When set, reject deletes for other purposes (e.g.
81
+ style_corpus must use the style-library delete route).
82
+ """
83
+ doc = await db.get(Document, document_id)
84
+ if doc is None or doc.tenant_id != tenant_id:
85
+ raise HTTPException(status_code=404, detail="Document not found")
86
+
87
+ if expected_purpose is not None and doc.document_purpose != expected_purpose:
88
+ raise HTTPException(
89
+ status_code=400,
90
+ detail=(
91
+ "This document is not a RAG report-source upload "
92
+ "(use DELETE /style-library/{id} for style-library items)."
93
+ ),
94
+ )
95
+
96
+ cnt = await db.execute(
97
+ select(func.count()).select_from(Report).where(Report.document_id == document_id)
98
+ )
99
+ if int(cnt.scalar_one() or 0) > 0:
100
+ raise HTTPException(
101
+ status_code=409,
102
+ detail=(
103
+ "This file is still linked to one or more reports. "
104
+ "Finish or abandon those jobs first, or upload replacements under a new document."
105
+ ),
106
+ )
107
+
108
+ warnings: list[str] = []
109
+ chunks_before = 0
110
+ chunks_after = 0
111
+ vector_deleted = False
112
+
113
+ try:
114
+ from app.vectorstore.factory import get_vectorstore
115
+
116
+ vs = get_vectorstore()
117
+ chunks_before = int(vs.count_for_doc(document_id))
118
+ try:
119
+ vs.delete_document(document_id)
120
+ vector_deleted = True
121
+ chunks_after = int(vs.count_for_doc(document_id))
122
+ if chunks_after > 0:
123
+ warnings.append(
124
+ f"Vector index still reports {chunks_after} chunk(s) for this document."
125
+ )
126
+ except Exception as exc: # noqa: BLE001
127
+ logger.warning("Vector store delete failed for doc=%s: %s", document_id, exc)
128
+ warnings.append(f"Vector store deletion error: {exc}")
129
+ try:
130
+ chunks_after = int(vs.count_for_doc(document_id))
131
+ except Exception: # noqa: BLE001
132
+ chunks_after = chunks_before
133
+ except Exception as exc: # noqa: BLE001
134
+ logger.warning("Vector store unavailable during delete doc=%s: %s", document_id, exc)
135
+ warnings.append(f"Vector store unavailable: {exc}")
136
+
137
+ file_removed = False
138
+ fp = Path(doc.file_path)
139
+ try:
140
+ if fp.is_file():
141
+ fp.unlink()
142
+ file_removed = True
143
+ except OSError as exc:
144
+ logger.warning("Could not remove file %s: %s", fp, exc)
145
+ warnings.append(f"Could not remove file from disk: {exc}")
146
+
147
+ await db.execute(delete(Document).where(Document.id == document_id))
148
+ await db.commit()
149
+
150
+ from app.retrieval.semantic_cache import invalidate_semantic_cache_for_tenant
151
+ from app.services.photo_policy import invalidate_tenant_photo_policy_cache
152
+
153
+ await invalidate_semantic_cache_for_tenant(tenant_id)
154
+ invalidate_tenant_photo_policy_cache(tenant_id)
155
+
156
+ removed = vector_deleted and (chunks_after == 0) and file_removed
157
+ detail = "Document removed from disk, database, and search index."
158
+ if warnings:
159
+ detail = "Document removed with warnings: " + "; ".join(warnings)
160
+
161
+ return DocumentDeleteResponse(
162
+ document_id=document_id,
163
+ removed=removed or not warnings,
164
+ detail=detail,
165
+ vector_chunks_removed=max(0, chunks_before - chunks_after),
166
+ file_removed=file_removed,
167
+ database_removed=True,
168
+ warnings=warnings,
169
+ )
app/services/generation.py CHANGED
@@ -1426,6 +1426,38 @@ def _mode_controls(ai_level: int, mode: str, ai_percent: int | None = None) -> t
1426
  return t, h
1427
 
1428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1429
  async def _generate_section_text(
1430
  tenant_id: str,
1431
  template_id: str,
@@ -1627,6 +1659,32 @@ async def _generate_section_text(
1627
  universe.add(str(primary_document_id))
1628
  universe.update(ref_ids)
1629
  universe.update(runtime_extra)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630
  allowed = frozenset(universe) if universe else None
1631
 
1632
  if not allowed:
@@ -2288,6 +2346,61 @@ async def _generate_section_text(
2288
  return text, provenance, confidence, top_results, doc_ctx_results, metrics
2289
 
2290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2291
  # ── GENERATE mode ──────────────────────────────────────────────────────────────
2292
 
2293
  async def _run_generate(
@@ -2356,6 +2469,7 @@ async def _run_generate(
2356
  fallback_used=bool(cached.get("fallback_used", fallback_used)),
2357
  interference_level=_tier_cached or interference_level,
2358
  mark_report_complete=mark_report_complete,
 
2359
  )
2360
  return
2361
 
@@ -2404,6 +2518,20 @@ async def _run_generate(
2404
  merged_for_meta.append(r)
2405
  provenance = attach_snippet_metadata(provenance, merged_for_meta, filenames)
2406
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2407
  # 8. Cache & persist (ai_level stored for auditability; it is already part of the key)
2408
  section_cache.set(cache_key, {
2409
  "text": text,
@@ -2417,6 +2545,7 @@ async def _run_generate(
2417
  "pipeline": str(pipeline),
2418
  "fallback_used": bool(fallback_used),
2419
  "interference_level": interference_level,
 
2420
  })
2421
  _bullet_clamps_meta = (
2422
  metrics.get("bullet_clamps")
@@ -2454,6 +2583,7 @@ async def _run_generate(
2454
  mark_report_complete=mark_report_complete,
2455
  bullet_clamps=_bullet_clamps_meta,
2456
  coverage_metrics=_coverage_meta,
 
2457
  )
2458
  logger.info(
2459
  "Generated section=%s for report=%s (confidence=%.3f, style=%s, ai=%s%% level=%s)",
@@ -3010,6 +3140,7 @@ async def _persist_section(
3010
  mark_report_complete: bool = True,
3011
  bullet_clamps: dict[str, Any] | None = None,
3012
  coverage_metrics: dict[str, Any] | None = None,
 
3013
  ) -> None:
3014
  """Upsert a section row and optionally mark the report as complete.
3015
 
@@ -3070,6 +3201,8 @@ async def _persist_section(
3070
  meta["bullet_clamps"] = bullet_clamps
3071
  if coverage_metrics:
3072
  meta["notes_coverage"] = coverage_metrics
 
 
3073
  provenance_json = json.dumps({"sources": provenance, "meta": meta})
3074
 
3075
  if section_orm is None and not is_sqlite_database():
 
1426
  return t, h
1427
 
1428
 
1429
+ async def _tenant_report_source_doc_ids(
1430
+ db: AsyncSession | None,
1431
+ tenant_id: str,
1432
+ ) -> list[str]:
1433
+ """Return all of the tenant's successfully-ingested report-source doc IDs.
1434
+
1435
+ Used to widen RAG retrieval to the tenant's whole library (old + new
1436
+ uploads) when ``settings.rag_use_full_tenant_library`` is enabled.
1437
+ ``style_corpus`` documents are excluded so past-report observations never
1438
+ enter the factual evidence pool. Best-effort: returns ``[]`` on any error so
1439
+ generation degrades to per-report isolation rather than failing.
1440
+ """
1441
+ if db is None:
1442
+ return []
1443
+ try:
1444
+ from sqlalchemy import select as _select
1445
+
1446
+ from app.db.models import Document, DocumentPurpose, IngestStatus
1447
+
1448
+ result = await db.execute(
1449
+ _select(Document.id).where(
1450
+ Document.tenant_id == tenant_id,
1451
+ Document.status == IngestStatus.complete,
1452
+ Document.document_purpose == DocumentPurpose.report_source,
1453
+ )
1454
+ )
1455
+ return [str(row[0]) for row in result.all() if row[0]]
1456
+ except Exception as exc: # noqa: BLE001 β€” never break generation on this widening step
1457
+ logger.warning("Full-library doc id lookup failed tenant=%s: %s", tenant_id, exc)
1458
+ return []
1459
+
1460
+
1461
  async def _generate_section_text(
1462
  tenant_id: str,
1463
  template_id: str,
 
1659
  universe.add(str(primary_document_id))
1660
  universe.update(ref_ids)
1661
  universe.update(runtime_extra)
1662
+
1663
+ # Whole-library retrieval (old + new uploads). When enabled and we are NOT
1664
+ # in strict per-report isolation, widen the admissible document set to every
1665
+ # ingested report-source document for this tenant. The report's own upload
1666
+ # (primary_document_id) and explicit references stay prioritised by the
1667
+ # retriever's ordering; style_corpus is excluded by the query above and by
1668
+ # the report-source filter in the lookup helper. Runtime section vectors are
1669
+ # always kept.
1670
+ library_doc_ids: list[str] = []
1671
+ if (
1672
+ getattr(settings, "rag_use_full_tenant_library", True)
1673
+ and not strict_uploaded_only
1674
+ ):
1675
+ library_doc_ids = await _tenant_report_source_doc_ids(db, tenant_id)
1676
+ if library_doc_ids:
1677
+ universe.update(library_doc_ids)
1678
+ logger.info(
1679
+ "Full-library retrieval for section=%s tenant=%s: %d report-source "
1680
+ "doc(s) admissible (primary=%s, explicit_refs=%d)",
1681
+ template_id,
1682
+ tenant_id,
1683
+ len(library_doc_ids),
1684
+ (primary_document_id[:8] if primary_document_id else None),
1685
+ len(ref_ids),
1686
+ )
1687
+
1688
  allowed = frozenset(universe) if universe else None
1689
 
1690
  if not allowed:
 
2346
  return text, provenance, confidence, top_results, doc_ctx_results, metrics
2347
 
2348
 
2349
+ async def _attach_citation_audit(
2350
+ *,
2351
+ metrics: dict[str, Any],
2352
+ template_id: str,
2353
+ survey_level: int | None,
2354
+ evidence: list[SearchResult],
2355
+ tenant_id: str,
2356
+ ) -> None:
2357
+ """Run the deterministic grounding audit and record it in ``metrics``.
2358
+
2359
+ Best-effort and fully isolated: any failure here must never affect the
2360
+ generated section, so the audit is wrapped and only logged. This is an
2361
+ additive traceability artifact, not part of the generation contract.
2362
+ """
2363
+ try:
2364
+ from app.extraction.extractor import audit_section_grounding
2365
+ from app.templates.registry import get_template
2366
+
2367
+ tmpl = get_template(template_id, survey_level)
2368
+ section_name = tmpl.title if tmpl else template_id
2369
+
2370
+ extraction = await audit_section_grounding(
2371
+ section_name=section_name,
2372
+ template_id=template_id,
2373
+ chunks=evidence,
2374
+ tenant_id=tenant_id,
2375
+ )
2376
+ metrics["citation_audit"] = {
2377
+ "section": extraction.section,
2378
+ "confidence": extraction.confidence,
2379
+ "findings": len(extraction.findings),
2380
+ "contradictions": [c.model_dump() for c in extraction.contradictions],
2381
+ "dropped_claims": extraction.dropped_claims,
2382
+ }
2383
+ if extraction.contradictions or extraction.dropped_claims:
2384
+ logger.warning(
2385
+ "Citation audit section=%s confidence=%.2f findings=%d "
2386
+ "contradictions=%d dropped=%d",
2387
+ extraction.section,
2388
+ extraction.confidence,
2389
+ len(extraction.findings),
2390
+ len(extraction.contradictions),
2391
+ len(extraction.dropped_claims),
2392
+ )
2393
+ else:
2394
+ logger.info(
2395
+ "Citation audit section=%s confidence=%.2f findings=%d (clean)",
2396
+ extraction.section,
2397
+ extraction.confidence,
2398
+ len(extraction.findings),
2399
+ )
2400
+ except Exception as exc: # noqa: BLE001 β€” audit must never break generation
2401
+ logger.warning("Citation audit skipped for section=%s: %s", template_id, exc)
2402
+
2403
+
2404
  # ── GENERATE mode ──────────────────────────────────────────────────────────────
2405
 
2406
  async def _run_generate(
 
2469
  fallback_used=bool(cached.get("fallback_used", fallback_used)),
2470
  interference_level=_tier_cached or interference_level,
2471
  mark_report_complete=mark_report_complete,
2472
+ citation_audit=cached.get("citation_audit") if isinstance(cached.get("citation_audit"), dict) else None,
2473
  )
2474
  return
2475
 
 
2518
  merged_for_meta.append(r)
2519
  provenance = attach_snippet_metadata(provenance, merged_for_meta, filenames)
2520
 
2521
+ # 7b. Optional citation-grounded audit (non-destructive). Runs the
2522
+ # deterministic extraction + contradiction layer over the SAME retrieved
2523
+ # evidence the section was built from, scoped to this section's domain.
2524
+ # It never mutates `text`; it only records traceability (confidence,
2525
+ # contradictions, dropped/ungrounded claims) into metrics for auditing.
2526
+ if settings.enable_citation_extraction and top_results:
2527
+ await _attach_citation_audit(
2528
+ metrics=metrics,
2529
+ template_id=template_id,
2530
+ survey_level=report.survey_level,
2531
+ evidence=list(top_results),
2532
+ tenant_id=tenant_id,
2533
+ )
2534
+
2535
  # 8. Cache & persist (ai_level stored for auditability; it is already part of the key)
2536
  section_cache.set(cache_key, {
2537
  "text": text,
 
2545
  "pipeline": str(pipeline),
2546
  "fallback_used": bool(fallback_used),
2547
  "interference_level": interference_level,
2548
+ "citation_audit": metrics.get("citation_audit") if isinstance(metrics.get("citation_audit"), dict) else None,
2549
  })
2550
  _bullet_clamps_meta = (
2551
  metrics.get("bullet_clamps")
 
2583
  mark_report_complete=mark_report_complete,
2584
  bullet_clamps=_bullet_clamps_meta,
2585
  coverage_metrics=_coverage_meta,
2586
+ citation_audit=metrics.get("citation_audit") if isinstance(metrics.get("citation_audit"), dict) else None,
2587
  )
2588
  logger.info(
2589
  "Generated section=%s for report=%s (confidence=%.3f, style=%s, ai=%s%% level=%s)",
 
3140
  mark_report_complete: bool = True,
3141
  bullet_clamps: dict[str, Any] | None = None,
3142
  coverage_metrics: dict[str, Any] | None = None,
3143
+ citation_audit: dict[str, Any] | None = None,
3144
  ) -> None:
3145
  """Upsert a section row and optionally mark the report as complete.
3146
 
 
3201
  meta["bullet_clamps"] = bullet_clamps
3202
  if coverage_metrics:
3203
  meta["notes_coverage"] = coverage_metrics
3204
+ if citation_audit:
3205
+ meta["citation_audit"] = citation_audit
3206
  provenance_json = json.dumps({"sources": provenance, "meta": meta})
3207
 
3208
  if section_orm is None and not is_sqlite_database():
app/services/photo_vision.py CHANGED
@@ -68,7 +68,7 @@ async def _vision_batch_call_async(
68
  return await chat_completions_create_raw(
69
  messages=[
70
  {"role": "system", "content": "You are a careful building inspection assistant."},
71
- {"role": "user", "content": [{"type": "input_text", "text": prompt}, *images]},
72
  ],
73
  model=model,
74
  max_tokens=800,
@@ -103,7 +103,9 @@ async def _vision_batches_async(
103
  images: list[dict] = []
104
  for p, ct in chunk:
105
  try:
106
- images.append({"type": "input_image", "image_url": _data_url_for_image(p, ct)})
 
 
107
  except Exception as exc: # noqa: BLE001
108
  logger.debug("Could not read photo for vision: %s", exc)
109
  if not images:
@@ -177,7 +179,9 @@ def _vision_batches_sync(
177
  images: list[dict] = []
178
  for p, ct in chunk:
179
  try:
180
- images.append({"type": "input_image", "image_url": _data_url_for_image(p, ct)})
 
 
181
  except Exception as exc: # noqa: BLE001
182
  logger.debug("Could not read photo for vision: %s", exc)
183
  if not images:
@@ -210,7 +214,7 @@ def _vision_batches_sync(
210
  model=model,
211
  messages=[
212
  {"role": "system", "content": "You are a careful building inspection assistant."},
213
- {"role": "user", "content": [{"type": "input_text", "text": prompt}, *images]},
214
  ],
215
  temperature=0.1,
216
  )
 
68
  return await chat_completions_create_raw(
69
  messages=[
70
  {"role": "system", "content": "You are a careful building inspection assistant."},
71
+ {"role": "user", "content": [{"type": "text", "text": prompt}, *images]},
72
  ],
73
  model=model,
74
  max_tokens=800,
 
103
  images: list[dict] = []
104
  for p, ct in chunk:
105
  try:
106
+ images.append(
107
+ {"type": "image_url", "image_url": {"url": _data_url_for_image(p, ct)}}
108
+ )
109
  except Exception as exc: # noqa: BLE001
110
  logger.debug("Could not read photo for vision: %s", exc)
111
  if not images:
 
179
  images: list[dict] = []
180
  for p, ct in chunk:
181
  try:
182
+ images.append(
183
+ {"type": "image_url", "image_url": {"url": _data_url_for_image(p, ct)}}
184
+ )
185
  except Exception as exc: # noqa: BLE001
186
  logger.debug("Could not read photo for vision: %s", exc)
187
  if not images:
 
214
  model=model,
215
  messages=[
216
  {"role": "system", "content": "You are a careful building inspection assistant."},
217
+ {"role": "user", "content": [{"type": "text", "text": prompt}, *images]},
218
  ],
219
  temperature=0.1,
220
  )
app/tests/test_extraction_grounding.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for the citation-grounded extraction layer.
2
+
3
+ These tests are the executable success criteria for the anti-hallucination
4
+ guarantees. They are fully deterministic and require NO OpenAI key β€” they
5
+ exercise the pure-Python validation/contradiction core that gates the LLM.
6
+
7
+ Covered failure modes (from the spec's TESTING REQUIREMENTS):
8
+ - altered condition ratings
9
+ - fabricated materials
10
+ - invented locations / entity substitution
11
+ - unsupported risk / severity claims
12
+ - contradiction generation (rating / condition / operational / duplicate)
13
+ - malformed / fabricated sentences (paraphrase with no grounding)
14
+ - dropped warranties (positive findings preserved when grounded)
15
+ - fabricated monetary totals
16
+ - citation to a non-existent chunk
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from app.extraction.citation_validator import (
22
+ span_in_chunk,
23
+ validate_finding,
24
+ validate_findings,
25
+ )
26
+ from app.extraction.contradiction import audit_contradictions
27
+ from app.extraction.schemas import (
28
+ ConditionRating,
29
+ ContradictionKind,
30
+ EvidenceSpan,
31
+ SupportLevel,
32
+ SurveyFinding,
33
+ )
34
+
35
+
36
+ def _finding(element: str, rating, text: str, *, chunk_id="c1", span=None) -> SurveyFinding:
37
+ return SurveyFinding(
38
+ section="Roofing",
39
+ element=element,
40
+ condition_rating=rating,
41
+ finding=text,
42
+ evidence=[EvidenceSpan(chunk_id=chunk_id, text=span or text)],
43
+ )
44
+
45
+
46
+ # ── ConditionRating enum: closed set, never fabricated ──────────────────────
47
+
48
+ def test_rating_enum_coerces_unknown_to_na_not_a_guess():
49
+ assert ConditionRating.coerce("2") is ConditionRating.CR2
50
+ assert ConditionRating.coerce("CR3") is ConditionRating.CR3
51
+ # Garbage must degrade to NA, never to a fabricated severity.
52
+ assert ConditionRating.coerce("urgent") is ConditionRating.NA
53
+ assert ConditionRating.coerce("") is ConditionRating.NA
54
+ assert ConditionRating.coerce(None) is ConditionRating.NA
55
+
56
+
57
+ def test_finding_rating_is_schema_constrained():
58
+ f = SurveyFinding(section="Roofing", element="Ridge", condition_rating="severe", finding="x")
59
+ assert f.condition_rating is ConditionRating.NA
60
+
61
+
62
+ # ── span_in_chunk: verbatim + OCR-drift tolerance, but rejects absent text ──
63
+
64
+ def test_span_match_verbatim_and_drift():
65
+ chunk = "The main roof covering is natural slate, generally in sound condition."
66
+ assert span_in_chunk("natural slate", chunk)
67
+ assert span_in_chunk("The main roof covering is natural slate", chunk)
68
+ # whitespace/case drift still matches
69
+ assert span_in_chunk("NATURAL slate", chunk)
70
+
71
+
72
+ def test_span_absent_is_rejected():
73
+ chunk = "The main roof covering is natural slate."
74
+ assert not span_in_chunk("concrete interlocking tiles", chunk)
75
+
76
+
77
+ # ── Altered condition ratings are caught by the contradiction audit ─────────
78
+
79
+ def test_altered_rating_conflict_detected_and_resolved():
80
+ strong = _finding("Main roof", ConditionRating.CR2, "Slate covering shows slipped tiles.")
81
+ strong.support = SupportLevel.SUPPORTED
82
+ weak = _finding("Main roof", ConditionRating.CR1, "Slate covering shows slipped tiles.")
83
+ weak.support = SupportLevel.PARTIAL
84
+ resolved, reports = audit_contradictions([strong, weak])
85
+ assert len(resolved) == 1
86
+ assert resolved[0].condition_rating is ConditionRating.CR2 # stronger evidence kept
87
+ assert any(r.kind is ContradictionKind.RATING_CONFLICT for r in reports)
88
+
89
+
90
+ # ── Fabricated materials / invented locations: entity substitution ──────────
91
+
92
+ def test_entity_substitution_is_dropped():
93
+ pool = {"c1": "A London plane tree is located near the rear boundary."}
94
+ # Model swapped the species β€” must be rejected as unsupported entity.
95
+ bad = SurveyFinding(
96
+ section="Grounds", element="Tree", condition_rating="NA",
97
+ finding="A Lombardy Poplar is located near the rear boundary.",
98
+ evidence=[EvidenceSpan(chunk_id="c1", text="located near the rear boundary")],
99
+ )
100
+ support, violations = validate_finding(bad, pool)
101
+ assert support is SupportLevel.NOT_FOUND
102
+ assert any("entity" in v for v in violations)
103
+
104
+
105
+ def test_correct_entity_is_supported():
106
+ pool = {"c1": "A London plane tree is located near the rear boundary."}
107
+ good = SurveyFinding(
108
+ section="Grounds", element="Tree", condition_rating="NA",
109
+ finding="A London plane tree is located near the rear boundary.",
110
+ evidence=[EvidenceSpan(chunk_id="c1", text="A London plane tree is located near the rear boundary")],
111
+ )
112
+ support, violations = validate_finding(good, pool)
113
+ assert support is SupportLevel.SUPPORTED
114
+ assert violations == []
115
+
116
+
117
+ # ── Fabricated monetary totals / numbers ────────────────────────────────────
118
+
119
+ def test_fabricated_total_is_dropped():
120
+ pool = {"c1": "Repairs to the parapet are recommended."}
121
+ bad = SurveyFinding(
122
+ section="Roofing", element="Parapet", condition_rating="2",
123
+ finding="Repairs to the parapet are recommended at a cost of Β£12,500.",
124
+ evidence=[EvidenceSpan(chunk_id="c1", text="Repairs to the parapet are recommended")],
125
+ )
126
+ support, violations = validate_finding(bad, pool)
127
+ assert support is SupportLevel.NOT_FOUND
128
+ assert any("number" in v or "amount" in v for v in violations)
129
+
130
+
131
+ def test_grounded_total_is_preserved():
132
+ pool = {"c1": "Repairs to the parapet are recommended at a cost of Β£12,500."}
133
+ good = SurveyFinding(
134
+ section="Roofing", element="Parapet", condition_rating="2",
135
+ finding="Repairs to the parapet are recommended at a cost of Β£12,500.",
136
+ evidence=[EvidenceSpan(chunk_id="c1", text="Repairs to the parapet are recommended at a cost of Β£12,500")],
137
+ )
138
+ support, _ = validate_finding(good, pool)
139
+ assert support is SupportLevel.SUPPORTED
140
+
141
+
142
+ # ── Unsupported risk / severity escalation ──────────────────────────────────
143
+
144
+ def test_unsupported_severity_is_dropped():
145
+ pool = {"c1": "There is minor surface staining to the ceiling."}
146
+ bad = SurveyFinding(
147
+ section="Interior", element="Ceiling", condition_rating="2",
148
+ finding="There is minor surface staining to the ceiling, a catastrophic and unsafe defect.",
149
+ evidence=[EvidenceSpan(chunk_id="c1", text="There is minor surface staining to the ceiling")],
150
+ )
151
+ support, violations = validate_finding(bad, pool)
152
+ assert support is SupportLevel.NOT_FOUND
153
+ assert any("severity" in v for v in violations)
154
+
155
+
156
+ def test_severity_allowed_when_in_source():
157
+ pool = {"c1": "The boiler flue is unsafe and must not be used."}
158
+ good = SurveyFinding(
159
+ section="Services", element="Boiler flue", condition_rating="3",
160
+ finding="The boiler flue is unsafe and must not be used.",
161
+ evidence=[EvidenceSpan(chunk_id="c1", text="The boiler flue is unsafe and must not be used")],
162
+ )
163
+ support, _ = validate_finding(good, pool)
164
+ assert support is SupportLevel.SUPPORTED
165
+
166
+
167
+ # ── Citation to a non-existent chunk ────────────────────────────────────────
168
+
169
+ def test_citation_to_unknown_chunk_is_rejected():
170
+ pool = {"c1": "Slate covering is sound."}
171
+ bad = SurveyFinding(
172
+ section="Roofing", element="Covering", condition_rating="1",
173
+ finding="Slate covering is sound.",
174
+ evidence=[EvidenceSpan(chunk_id="ghost", text="Slate covering is sound")],
175
+ )
176
+ support, violations = validate_finding(bad, pool)
177
+ assert support is SupportLevel.NOT_FOUND
178
+ assert any("unknown chunk_id" in v for v in violations)
179
+
180
+
181
+ # ── Malformed / paraphrased fabrication with no grounding ───────────────────
182
+
183
+ def test_ungrounded_paraphrase_is_dropped():
184
+ pool = {"c1": "The flat roof is covered in felt."}
185
+ bad = SurveyFinding(
186
+ section="Roofing", element="Flat roof", condition_rating="2",
187
+ finding="Extensive structural movement threatens imminent failure of the dwelling.",
188
+ evidence=[EvidenceSpan(chunk_id="c1", text="The flat roof is covered in felt")],
189
+ )
190
+ support, _ = validate_finding(bad, pool)
191
+ assert support is SupportLevel.NOT_FOUND
192
+
193
+
194
+ # ── Positive findings / warranties preserved when grounded ──────────────────
195
+
196
+ def test_warranty_preserved():
197
+ pool = {"c1": "The replacement boiler was fitted in 2021 and carries a 10 year manufacturer warranty."}
198
+ good = SurveyFinding(
199
+ section="Services", element="Boiler", condition_rating="1",
200
+ finding="The replacement boiler was fitted in 2021 and carries a 10 year manufacturer warranty.",
201
+ evidence=[EvidenceSpan(
202
+ chunk_id="c1",
203
+ text="The replacement boiler was fitted in 2021 and carries a 10 year manufacturer warranty",
204
+ )],
205
+ )
206
+ support, _ = validate_finding(good, pool)
207
+ assert support is SupportLevel.SUPPORTED
208
+
209
+
210
+ # ── Contradiction: satisfactory vs defective ────────────────────────────────
211
+
212
+ def test_satisfactory_vs_defective_contradiction():
213
+ a = _finding("Gutters", ConditionRating.NA, "The gutters are in good condition and sound.")
214
+ a.support = SupportLevel.SUPPORTED
215
+ b = _finding("Gutters", ConditionRating.NA, "The gutters are defective and leaking badly.")
216
+ b.support = SupportLevel.NOT_FOUND
217
+ resolved, reports = audit_contradictions([a, b])
218
+ assert len(resolved) == 1
219
+ assert resolved[0] is a # evidence-stronger side kept
220
+ assert any(r.kind is ContradictionKind.CONDITION for r in reports)
221
+
222
+
223
+ # ── Contradiction: operational vs non-operational ───────────────────────────
224
+
225
+ def test_operational_contradiction():
226
+ a = _finding("Heating", ConditionRating.NA, "The heating system is fully operational and working.")
227
+ a.support = SupportLevel.SUPPORTED
228
+ b = _finding("Heating", ConditionRating.NA, "The heating system is not operational and out of order.")
229
+ b.support = SupportLevel.PARTIAL
230
+ resolved, reports = audit_contradictions([a, b])
231
+ assert len(resolved) == 1
232
+ assert any(r.kind is ContradictionKind.OPERATIONAL for r in reports)
233
+
234
+
235
+ # ── Duplicate collapse ──────────────────────────────────────────────────────
236
+
237
+ def test_duplicate_findings_collapsed():
238
+ a = _finding("Chimney", ConditionRating.CR2, "The chimney stack shows perished pointing requiring repair.")
239
+ b = _finding("Chimney", ConditionRating.CR2, "The chimney stack shows perished pointing requiring repair.")
240
+ resolved, reports = audit_contradictions([a, b])
241
+ assert len(resolved) == 1
242
+ assert any(r.kind is ContradictionKind.DUPLICATE for r in reports)
243
+
244
+
245
+ # ── End-to-end gate: mixed batch keeps only grounded, non-conflicting ───────
246
+
247
+ def test_validate_findings_batch_drops_unsupported():
248
+ pool = {
249
+ "c1": "The main roof is natural slate in sound condition.",
250
+ "c2": "The rear addition roof is covered in felt with no visible defects.",
251
+ }
252
+ findings = [
253
+ SurveyFinding(section="Roofing", element="Main roof", condition_rating="1",
254
+ finding="The main roof is natural slate in sound condition.",
255
+ evidence=[EvidenceSpan(chunk_id="c1", text="The main roof is natural slate in sound condition")]),
256
+ SurveyFinding(section="Roofing", element="Rear roof", condition_rating="3",
257
+ finding="The rear addition roof has collapsed and is deadly.",
258
+ evidence=[EvidenceSpan(chunk_id="c2", text="The rear addition roof is covered in felt")]),
259
+ ]
260
+ kept, dropped = validate_findings(findings, pool)
261
+ assert len(kept) == 1
262
+ assert kept[0].element == "Main roof"
263
+ assert len(dropped) == 1
264
+
265
+
266
+ """Section-domain scoping (STEP 6) β€” prevent cross-section contamination."""
267
+
268
+
269
+ def test_classify_section_aliases_and_keywords():
270
+ from app.extraction.domain_scope import classify_section
271
+
272
+ assert classify_section("Roof coverings") == "roofing"
273
+ assert classify_section("Chimney stacks") == "chimney"
274
+ assert classify_section("Rainwater pipes and gutters") == "rainwater"
275
+ assert classify_section("Electricity") == "electrical"
276
+ assert classify_section("About the property") == "general"
277
+
278
+
279
+ def test_classify_text_dominant_domain():
280
+ from app.extraction.domain_scope import classify_text
281
+
282
+ assert classify_text("The natural slate roof covering has slipped tiles at the ridge.") == "roofing"
283
+ assert classify_text("The consumer unit lacks RCD protection on the circuits.") == "electrical"
284
+ assert classify_text("This paragraph is generic boilerplate with no domain.") == "general"
285
+
286
+
287
+ def test_scope_chunks_excludes_foreign_domain():
288
+ from app.extraction.domain_scope import scope_chunks
289
+
290
+ class _Row:
291
+ def __init__(self, text):
292
+ self.text = text
293
+
294
+ chunks = [
295
+ _Row("The slate roof covering is sound at the ridge and eaves."),
296
+ _Row("The drainage manhole and inspection chamber were inspected."),
297
+ _Row("General introductory text about the inspection."),
298
+ ]
299
+ kept = scope_chunks("roofing", chunks)
300
+ texts = [c.text for c in kept]
301
+ assert any("slate roof" in t for t in texts)
302
+ assert any("introductory" in t for t in texts) # general is admissible
303
+ assert not any("manhole" in t for t in texts) # drainage excluded
304
+
305
+
306
+ def test_scope_chunks_never_starves_under_strict():
307
+ from app.extraction.domain_scope import scope_chunks
308
+
309
+ class _Row:
310
+ def __init__(self, text):
311
+ self.text = text
312
+
313
+ # All chunks belong to a different domain -> strict returns originals
314
+ # rather than an empty pool.
315
+ chunks = [_Row("The consumer unit and wiring circuits were inspected.")]
316
+ kept = scope_chunks("roofing", chunks, strict=True)
317
+ assert len(kept) == 1
318
+ kept_nonstrict = scope_chunks("roofing", chunks, strict=False)
319
+ assert kept_nonstrict == []
320
+
321
+
322
+ def test_section_extraction_confidence():
323
+ pool = {"c1": "The main roof is natural slate in sound condition."}
324
+ f = SurveyFinding(section="Roofing", element="Main roof", condition_rating="1",
325
+ finding="The main roof is natural slate in sound condition.",
326
+ evidence=[EvidenceSpan(chunk_id="c1", text="The main roof is natural slate in sound condition")])
327
+ kept, _ = validate_findings([f], pool)
328
+ from app.extraction.schemas import SectionExtraction
329
+ sec = SectionExtraction(section="Roofing", findings=kept)
330
+ assert sec.confidence == 1.0
331
+
332
+
333
+ """Post-generation output validator + abstention (forbidden phrases / metrics)."""
334
+
335
+
336
+ def test_output_validator_flags_forbidden_phrases():
337
+ from app.extraction.output_validator import find_violations, is_clean
338
+
339
+ bad = "The roof appears to be defective and overall reliability is questionable."
340
+ v = find_violations(bad, evidence="")
341
+ assert any("appears to" in x for x in v)
342
+ assert any("overall" in x for x in v)
343
+ assert not is_clean(bad)
344
+
345
+
346
+ def test_output_validator_flags_fabricated_percentage_and_score():
347
+ from app.extraction.output_validator import find_violations
348
+
349
+ v = find_violations("Authenticity score is high with 87% confidence.", evidence="")
350
+ assert any("percentage" in x for x in v)
351
+ assert any("metric" in x for x in v)
352
+
353
+
354
+ def test_output_validator_allows_terms_present_in_evidence():
355
+ from app.extraction.output_validator import is_clean
356
+
357
+ # "unsafe" is permitted because it is verbatim in the evidence.
358
+ text = "The flue is unsafe."
359
+ evidence = "The boiler flue is unsafe and must not be used."
360
+ assert is_clean(text, evidence)
361
+
362
+
363
+ def test_output_validator_clean_text_passes():
364
+ from app.extraction.output_validator import is_clean
365
+
366
+ assert is_clean("The main roof covering is natural slate.", evidence="")
367
+
368
+
369
+ def test_abstain_on_low_confidence_and_attribution_failure():
370
+ from app.extraction.output_validator import should_abstain
371
+
372
+ assert should_abstain("clean text", "", confidence=0.5, min_confidence=1.0)
373
+ assert should_abstain("clean text", "", evidence_aligned=False)
374
+ assert should_abstain("clean text", "", source_attributed=False)
375
+ assert not should_abstain("The roof is slate.", "", confidence=1.0)
376
+
377
+
378
+ def test_findings_to_atomic_claims_grounded():
379
+ from app.extraction.extractor import findings_to_atomic_claims
380
+ from app.extraction.schemas import ClaimType
381
+
382
+ f = SurveyFinding(
383
+ section="Roofing", element="Main roof", condition_rating="2",
384
+ finding="The main roof covering is natural slate with slipped tiles.",
385
+ evidence=[EvidenceSpan(chunk_id="c1", page=12, section_label="Page 12",
386
+ text="The main roof covering is natural slate with slipped tiles")],
387
+ )
388
+ f.support = SupportLevel.SUPPORTED
389
+ claims = findings_to_atomic_claims([f])
390
+ types = {c.claim_type for c in claims}
391
+ assert ClaimType.CONDITION_RATING in types
392
+ assert ClaimType.OBSERVATION in types
393
+ rating = next(c for c in claims if c.claim_type is ClaimType.CONDITION_RATING)
394
+ assert "is 2" in rating.claim
395
+ assert rating.evidence.chunk_id == "c1"
396
+ assert rating.evidence.page == 12
397
+ assert rating.verification.supported is True
398
+ assert rating.verification.confidence == 1.0
399
+
400
+
401
+ def test_findings_to_atomic_claims_abstains_below_confidence():
402
+ from app.extraction.extractor import findings_to_atomic_claims
403
+
404
+ f = SurveyFinding(
405
+ section="Roofing", element="Main roof", condition_rating="2",
406
+ finding="The main roof covering is natural slate.",
407
+ evidence=[EvidenceSpan(chunk_id="c1", text="The main roof covering is natural slate")],
408
+ )
409
+ f.support = SupportLevel.PARTIAL # confidence 0.5 < default min 1.0
410
+ assert findings_to_atomic_claims([f], min_confidence=1.0) == []
411
+ # Lowering the bar admits them.
412
+ assert findings_to_atomic_claims([f], min_confidence=0.5)
app/tests/test_full_library_retrieval.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests: report generation must use old + new tenant RAG uploads.
2
+
3
+ When ``rag_use_full_tenant_library`` is enabled (default), every user's report
4
+ generation widens retrieval to all ingested ``report_source`` documents for
5
+ that tenant β€” not only the file attached when the report was created.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ import pytest
14
+ import pytest_asyncio
15
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
16
+
17
+ from app.db.database import Base
18
+ from app.db.models import Document, DocumentPurpose, IngestStatus, Report, ReportStatus
19
+ from app.generator.adapter import MockLLMAdapter
20
+ from app.models.schemas import WritingStyleProfile
21
+ from app.services.generation import (
22
+ _generate_section_text,
23
+ _tenant_report_source_doc_ids,
24
+ )
25
+
26
+ _MOCK_PROFILE = WritingStyleProfile(
27
+ tone="formal",
28
+ formality_level="professional",
29
+ avg_sentence_complexity="moderate",
30
+ vocabulary_level="technical",
31
+ common_phrases=[],
32
+ structural_patterns=[],
33
+ writing_style_summary="Professional RICS surveyor style.",
34
+ )
35
+
36
+
37
+ @pytest_asyncio.fixture
38
+ async def memory_session() -> AsyncSession:
39
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
40
+ async with engine.begin() as conn:
41
+ await conn.run_sync(Base.metadata.create_all)
42
+ factory = async_sessionmaker(engine, expire_on_commit=False)
43
+ async with factory() as session:
44
+ yield session
45
+ await engine.dispose()
46
+
47
+
48
+ def _doc(
49
+ *,
50
+ tenant_id: str,
51
+ status: IngestStatus = IngestStatus.complete,
52
+ purpose: DocumentPurpose = DocumentPurpose.report_source,
53
+ ) -> Document:
54
+ return Document(
55
+ id=str(uuid.uuid4()),
56
+ tenant_id=tenant_id,
57
+ filename="survey.pdf",
58
+ file_path="/tmp/survey.pdf",
59
+ status=status,
60
+ document_purpose=purpose,
61
+ )
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_tenant_report_source_doc_ids_includes_all_complete_report_source(
66
+ memory_session: AsyncSession,
67
+ ) -> None:
68
+ tenant = "tenant_alpha"
69
+ old = _doc(tenant_id=tenant)
70
+ new = _doc(tenant_id=tenant)
71
+ pending = _doc(tenant_id=tenant, status=IngestStatus.pending)
72
+ style = _doc(tenant_id=tenant, purpose=DocumentPurpose.style_corpus)
73
+ memory_session.add_all([old, new, pending, style])
74
+ await memory_session.commit()
75
+
76
+ ids = await _tenant_report_source_doc_ids(memory_session, tenant)
77
+
78
+ assert set(ids) == {old.id, new.id}
79
+ assert pending.id not in ids
80
+ assert style.id not in ids
81
+
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_tenant_report_source_doc_ids_isolated_per_tenant(
85
+ memory_session: AsyncSession,
86
+ ) -> None:
87
+ doc_a = _doc(tenant_id="tenant_A")
88
+ doc_b = _doc(tenant_id="tenant_B")
89
+ memory_session.add_all([doc_a, doc_b])
90
+ await memory_session.commit()
91
+
92
+ ids_a = await _tenant_report_source_doc_ids(memory_session, "tenant_A")
93
+ ids_b = await _tenant_report_source_doc_ids(memory_session, "tenant_B")
94
+
95
+ assert ids_a == [doc_a.id]
96
+ assert ids_b == [doc_b.id]
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_tenant_report_source_doc_ids_returns_empty_without_db() -> None:
101
+ assert await _tenant_report_source_doc_ids(None, "any") == []
102
+
103
+
104
+ @pytest.mark.asyncio
105
+ async def test_generate_section_text_search_includes_old_library_doc(
106
+ memory_session: AsyncSession,
107
+ ) -> None:
108
+ """Paragraph retrieval must pass old + new doc IDs in doc_id_in."""
109
+ tenant = "tenant_lib"
110
+ primary = _doc(tenant_id=tenant)
111
+ old_upload = _doc(tenant_id=tenant)
112
+ memory_session.add_all([primary, old_upload])
113
+ report = Report(
114
+ id=str(uuid.uuid4()),
115
+ tenant_id=tenant,
116
+ document_id=primary.id,
117
+ status=ReportStatus.generating,
118
+ )
119
+ memory_session.add(report)
120
+ await memory_session.commit()
121
+
122
+ captured: list[frozenset[str] | None] = []
123
+
124
+ def _search(*_args, **kwargs): # noqa: ANN002, ANN003
125
+ captured.append(kwargs.get("doc_id_in"))
126
+ return []
127
+
128
+ fake_vs = MagicMock(search=_search)
129
+
130
+ with (
131
+ patch("app.services.generation.settings.rag_use_full_tenant_library", True),
132
+ patch("app.services.generation.settings.hierarchical_rag_enabled", True),
133
+ patch("app.services.generation.get_vectorstore", return_value=fake_vs),
134
+ patch("app.services.generation.rerank", return_value=[]),
135
+ patch("app.llm.generation_facade.get_llm_adapter", return_value=MockLLMAdapter()),
136
+ patch(
137
+ "app.services.generation.get_template",
138
+ return_value=MagicMock(skeleton="[D]: [content]."),
139
+ ),
140
+ ):
141
+ await _generate_section_text(
142
+ tenant_id=tenant,
143
+ template_id="D",
144
+ bullets=["Semi-detached property"],
145
+ style_profile=_MOCK_PROFILE,
146
+ primary_document_id=primary.id,
147
+ reference_document_ids=[],
148
+ retrieval_level="paragraph",
149
+ db=memory_session,
150
+ strict_uploaded_only=False,
151
+ )
152
+
153
+ assert captured, "vectorstore.search should have been called"
154
+ allowed = captured[0]
155
+ assert allowed is not None
156
+ assert primary.id in allowed
157
+ assert old_upload.id in allowed
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_generate_section_text_strict_mode_excludes_library_widening(
162
+ memory_session: AsyncSession,
163
+ ) -> None:
164
+ tenant = "tenant_strict"
165
+ primary = _doc(tenant_id=tenant)
166
+ old_upload = _doc(tenant_id=tenant)
167
+ memory_session.add_all([primary, old_upload])
168
+ await memory_session.commit()
169
+
170
+ captured: list[frozenset[str] | None] = []
171
+
172
+ def _search(*_args, **kwargs): # noqa: ANN002, ANN003
173
+ captured.append(kwargs.get("doc_id_in"))
174
+ return []
175
+
176
+ fake_vs = MagicMock(search=_search)
177
+
178
+ with (
179
+ patch("app.services.generation.settings.rag_use_full_tenant_library", True),
180
+ patch("app.services.generation.settings.hierarchical_rag_enabled", True),
181
+ patch("app.services.generation.get_vectorstore", return_value=fake_vs),
182
+ patch("app.services.generation.rerank", return_value=[]),
183
+ patch("app.llm.generation_facade.get_llm_adapter", return_value=MockLLMAdapter()),
184
+ patch(
185
+ "app.services.generation.get_template",
186
+ return_value=MagicMock(skeleton="[D]: [content]."),
187
+ ),
188
+ ):
189
+ await _generate_section_text(
190
+ tenant_id=tenant,
191
+ template_id="D",
192
+ bullets=["Test"],
193
+ style_profile=_MOCK_PROFILE,
194
+ primary_document_id=primary.id,
195
+ reference_document_ids=[],
196
+ retrieval_level="paragraph",
197
+ db=memory_session,
198
+ strict_uploaded_only=True,
199
+ )
200
+
201
+ assert captured
202
+ allowed = captured[0]
203
+ assert allowed is not None
204
+ assert primary.id in allowed
205
+ assert old_upload.id not in allowed
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_generate_section_text_flag_off_excludes_library_widening(
210
+ memory_session: AsyncSession,
211
+ ) -> None:
212
+ tenant = "tenant_flag_off"
213
+ primary = _doc(tenant_id=tenant)
214
+ old_upload = _doc(tenant_id=tenant)
215
+ memory_session.add_all([primary, old_upload])
216
+ await memory_session.commit()
217
+
218
+ captured: list[frozenset[str] | None] = []
219
+
220
+ def _search(*_args, **kwargs): # noqa: ANN002, ANN003
221
+ captured.append(kwargs.get("doc_id_in"))
222
+ return []
223
+
224
+ fake_vs = MagicMock(search=_search)
225
+
226
+ with (
227
+ patch("app.services.generation.settings.rag_use_full_tenant_library", False),
228
+ patch("app.services.generation.settings.hierarchical_rag_enabled", True),
229
+ patch("app.services.generation.get_vectorstore", return_value=fake_vs),
230
+ patch("app.services.generation.rerank", return_value=[]),
231
+ patch("app.llm.generation_facade.get_llm_adapter", return_value=MockLLMAdapter()),
232
+ patch(
233
+ "app.services.generation.get_template",
234
+ return_value=MagicMock(skeleton="[D]: [content]."),
235
+ ),
236
+ ):
237
+ await _generate_section_text(
238
+ tenant_id=tenant,
239
+ template_id="D",
240
+ bullets=["Test"],
241
+ style_profile=_MOCK_PROFILE,
242
+ primary_document_id=primary.id,
243
+ reference_document_ids=[],
244
+ retrieval_level="paragraph",
245
+ db=memory_session,
246
+ strict_uploaded_only=False,
247
+ )
248
+
249
+ assert captured
250
+ allowed = captured[0]
251
+ assert allowed is not None
252
+ assert primary.id in allowed
253
+ assert old_upload.id not in allowed
app/tests/test_generator.py CHANGED
@@ -139,7 +139,9 @@ def test_enforce_verify_wraps_invented_number() -> None:
139
  snippets=[],
140
  )
141
  assert "120" not in result
142
- assert result == "The property has floor area."
 
 
143
 
144
 
145
  def test_enforce_verify_wraps_invented_entity() -> None:
 
139
  snippets=[],
140
  )
141
  assert "120" not in result
142
+ # enforce_verify collapses the whitespace left where the invented number
143
+ # was removed (\s{2,} -> single space), so no double space survives.
144
+ assert result == "The property has floor area."
145
 
146
 
147
  def test_enforce_verify_wraps_invented_entity() -> None:
app/tests/test_ingest_normalize.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for OCR normalization, table preservation, and chunking.
2
+
3
+ Covers the spec's remaining failure modes:
4
+ - OCR contamination (running headers/footers, page numbers, hyphenation)
5
+ - malformed sentences (hard-wrap unwrap)
6
+ - missing tables / chunk-boundary corruption (tables kept intact, never split)
7
+
8
+ All deterministic and dependency-light (no live PDF / network needed).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from langchain_core.documents import Document
14
+
15
+ from app.chunking.splitter import split_documents
16
+ from app.ingest.ocr_normalize import (
17
+ TABLE_CLOSE,
18
+ TABLE_OPEN,
19
+ normalize_pages,
20
+ normalize_text,
21
+ strip_running_headers_footers,
22
+ )
23
+ from app.ingest.parser_pdf import _table_to_markdown
24
+
25
+
26
+ # ── OCR normalization ───────────────────────────────────────────────────────
27
+
28
+ def test_dehyphenation_repairs_wrapped_words():
29
+ out = normalize_text("The roof is in poor condi-\ntion at the ridge.")
30
+ assert "condition" in out
31
+ assert "condi-" not in out
32
+
33
+
34
+ def test_unwrap_restores_sentence_continuity():
35
+ raw = "The main roof covering\nis natural slate and\nremains in sound condition."
36
+ out = normalize_text(raw)
37
+ assert out == "The main roof covering is natural slate and remains in sound condition."
38
+
39
+
40
+ def test_paragraph_breaks_preserved():
41
+ raw = "First paragraph line one\nline two.\n\nSecond paragraph here."
42
+ out = normalize_text(raw)
43
+ assert out == "First paragraph line one line two.\n\nSecond paragraph here."
44
+
45
+
46
+ def test_table_block_preserved_verbatim_through_normalization():
47
+ table = f"{TABLE_OPEN}\n| A | B |\n| --- | --- |\n| 1 | 2 |\n{TABLE_CLOSE}"
48
+ raw = f"Some intro text that is\nhard wrapped.\n\n{table}\n\nTrailing note."
49
+ out = normalize_text(raw)
50
+ assert table in out # untouched
51
+ assert "intro text that is hard wrapped." in out # prose still reflowed
52
+
53
+
54
+ def test_running_headers_and_page_numbers_stripped():
55
+ pages = [
56
+ "ACME Surveyors Ltd\nRoof section content for page one.\nPage 1 of 3",
57
+ "ACME Surveyors Ltd\nDrainage content for page two.\nPage 2 of 3",
58
+ "ACME Surveyors Ltd\nElectrical content for page three.\nPage 3 of 3",
59
+ ]
60
+ cleaned = strip_running_headers_footers(pages)
61
+ joined = "\n".join(cleaned)
62
+ assert "ACME Surveyors Ltd" not in joined # repeated header removed
63
+ assert "Page 1 of 3" not in joined # page numbers removed
64
+ assert "Roof section content" in joined # real content kept
65
+
66
+
67
+ def test_normalize_pages_single_page_keeps_content():
68
+ pages = ["Only one page here.\nWith a wrapped line."]
69
+ out = normalize_pages(pages)
70
+ assert out == ["Only one page here. With a wrapped line."]
71
+
72
+
73
+ # ── Table rendering ─────────────────────────────────────────────────────────
74
+
75
+ def test_table_to_markdown_handles_none_and_pipes():
76
+ rows = [["Element", "Rating"], ["Roof | main", None], ["Drains", "2"]]
77
+ md = _table_to_markdown(rows)
78
+ lines = md.splitlines()
79
+ assert lines[0] == "| Element | Rating |"
80
+ assert lines[1] == "| --- | --- |"
81
+ assert r"Roof \| main" in md # pipe escaped
82
+ assert "| Drains | 2 |" in lines[-1]
83
+
84
+
85
+ def test_table_to_markdown_pads_ragged_rows():
86
+ md = _table_to_markdown([["A", "B", "C"], ["1"]])
87
+ # ragged body row padded to header width
88
+ assert "| 1 | | |" in md
89
+
90
+
91
+ # ── Table-aware chunking ────────────────────────────────────────────────────
92
+
93
+ def _table(rows: int) -> str:
94
+ body = "\n".join(f"| Element{i} | Rating{i} | Cost{i} |" for i in range(rows))
95
+ return f"{TABLE_OPEN}\n| Element | Rating | Cost |\n| --- | --- | --- |\n{body}\n{TABLE_CLOSE}"
96
+
97
+
98
+ def test_table_kept_as_single_chunk():
99
+ table = _table(rows=3)
100
+ doc = Document(page_content=f"Intro prose.\n\n{table}\n\nOutro prose.", metadata={"doc": "d1"})
101
+ chunks = split_documents([doc], chunk_size=200, chunk_overlap=0)
102
+ table_chunks = [c for c in chunks if c.metadata.get("section_type") == "table"]
103
+ assert len(table_chunks) == 1
104
+ assert table_chunks[0].page_content.startswith(TABLE_OPEN)
105
+ assert table_chunks[0].page_content.rstrip().endswith(TABLE_CLOSE)
106
+
107
+
108
+ def test_large_table_not_split_across_chunks():
109
+ # A table far larger than chunk_size must still emit exactly one chunk.
110
+ table = _table(rows=60)
111
+ doc = Document(page_content=f"Heading.\n\n{table}", metadata={})
112
+ chunks = split_documents([doc], chunk_size=100, chunk_overlap=0)
113
+ table_chunks = [c for c in chunks if c.metadata.get("section_type") == "table"]
114
+ assert len(table_chunks) == 1
115
+ assert table_chunks[0].page_content.count(TABLE_OPEN) == 1
116
+ assert table_chunks[0].page_content.count(TABLE_CLOSE) == 1
117
+
118
+
119
+ def test_prose_without_table_unaffected():
120
+ doc = Document(page_content="A simple paragraph of prose with no tables.", metadata={})
121
+ chunks = split_documents([doc], chunk_size=200, chunk_overlap=0)
122
+ assert len(chunks) == 1
123
+ assert chunks[0].metadata.get("section_type") != "table"
124
+
125
+
126
+ def test_table_and_prose_order_preserved():
127
+ table = _table(rows=2)
128
+ doc = Document(page_content=f"Before text.\n\n{table}\n\nAfter text.", metadata={})
129
+ chunks = split_documents([doc], chunk_size=300, chunk_overlap=0)
130
+ kinds = ["table" if c.metadata.get("section_type") == "table" else "prose" for c in chunks]
131
+ assert "table" in kinds
132
+ # the table chunk is between prose chunks
133
+ t_idx = kinds.index("table")
134
+ assert any(k == "prose" for k in kinds[:t_idx])
135
+ assert any(k == "prose" for k in kinds[t_idx + 1:])
frontend/index.html CHANGED
@@ -274,6 +274,22 @@
274
  .cite-chip:hover { border-color: var(--navy); background: #f0f4fa; }
275
  .cite-chip kbd { font-size: .68rem; opacity: .75; }
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  .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; }
278
  .app-toast.hidden { opacity: 0; pointer-events: none; }
279
  .app-toast.toast-ok { background: #1a5f4a; }
@@ -294,6 +310,39 @@
294
  .similar-modal-close { border: none; background: transparent; font-size: 1.5rem; line-height: 1; cursor: pointer; color: var(--muted); padding: 0 .25rem; }
295
  .similar-modal-close:hover { color: var(--danger); }
296
  .similar-modal-body { padding: 1rem 1.2rem 1.4rem; overflow-y: auto; flex: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  .similar-intro { font-size: .86rem; color: var(--muted); margin: 0 0 1rem; line-height: 1.5; }
298
  .similar-h4 { margin: 1.2rem 0 .6rem; font-size: .88rem; color: var(--navy); text-transform: uppercase; letter-spacing: .04em; }
299
  .similar-h4:first-of-type { margin-top: 0; }
@@ -554,7 +603,7 @@
554
  .divider { border: none; border-top: 1.5px solid var(--border); margin: 1.2rem 0; }
555
  </style>
556
  </head>
557
- <body>
558
 
559
  <header>
560
  <div class="logo">
@@ -564,12 +613,46 @@
564
  <span class="subtitle">RICS Survey Report Generator</span>
565
  <div class="spacer"></div>
566
  <div class="tenant-badge" id="tenant-badge">Loading…</div>
 
 
 
 
 
 
 
 
567
  <button class="theme-toggle" id="theme-toggle-btn" onclick="toggleTheme()" title="Switch between light and dark mode">
568
  <span class="th-icon" id="theme-icon">β˜€οΈ</span>
569
  <span id="theme-label">Light</span>
570
  </button>
571
  </header>
572
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  <div class="steps" id="step-indicator">
574
  <div class="step-item active" id="si-1"><div class="step-num">1</div> Upload</div>
575
  <div class="step-sep">β€Ί</div>
@@ -819,6 +902,41 @@
819
  </div>
820
  </div>
821
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  <!-- ── Tier mismatch warning modal ──────────────────────────────────────── -->
823
  <div id="tier-mismatch-modal" class="similar-modal hidden" role="alertdialog" aria-modal="true" aria-labelledby="tier-mismatch-title">
824
  <div class="similar-modal-backdrop"></div>
@@ -862,8 +980,10 @@
862
 
863
  const state = {
864
  tenantId: null,
 
865
  docId: null,
866
  docIds: [], // all accepted document UUIDs from last batch upload
 
867
  reportId: null,
868
  surveyLevel: 3,
869
  file: null,
@@ -1015,7 +1135,10 @@ function apiUrl(path) {
1015
  }
1016
 
1017
  async function apiFetch(method, path, body, headers = {}, options = {}) {
1018
- const opts = { method, headers: { 'X-Tenant-ID': state.tenantId, ...headers } };
 
 
 
1019
  if (body instanceof FormData) { opts.body = body; }
1020
  else if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
1021
  // Optional AbortSignal β€” used by _saveSectionEdit to cancel an in-flight save
@@ -1023,6 +1146,10 @@ async function apiFetch(method, path, body, headers = {}, options = {}) {
1023
  // callers can ignore this; behaviour is unchanged when signal is omitted.
1024
  if (options && options.signal) opts.signal = options.signal;
1025
  const res = await fetch(apiUrl(path), opts);
 
 
 
 
1026
  if (res.status === 204) {
1027
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1028
  return null;
@@ -1055,6 +1182,123 @@ async function apiFetch(method, path, body, headers = {}, options = {}) {
1055
  return data;
1056
  }
1057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1058
  /* ── Tier mismatch modal ─────────────────────────────────────────────── */
1059
  let _mismatchResolve = null;
1060
  let _mismatchReject = null;
@@ -1276,9 +1520,8 @@ $('btn-upload').addEventListener('click', handleUpload);
1276
 
1277
  async function handleUpload() {
1278
  if (!state.files.length) { showAlert('upload-alert', 'warn', 'Please choose at least one file.'); return; }
1279
- let tid = $('tenant-input').value.trim();
1280
- if (!tid) { tid = 'tenant_' + Math.random().toString(36).slice(2, 10); $('tenant-input').value = tid; }
1281
- state.tenantId = tid;
1282
  $('tenant-badge').textContent = `πŸ”‘ ${tid}`;
1283
  $('tenant-badge').classList.add('visible');
1284
  showStep(2);
@@ -1302,6 +1545,7 @@ async function handleUpload() {
1302
 
1303
  state.docIds = up.items.filter(it => it.document_id).map(it => it.document_id);
1304
  if (!state.docIds.length) throw new Error('Batch upload returned no document IDs');
 
1305
 
1306
  $('proc-title').textContent = 'Indexing reference documents…';
1307
  $('proc-detail').textContent = `Queued ${up.accepted} file(s) for parsing & embedding${up.rejected ? ` (${up.rejected} rejected)` : ''}`;
@@ -2397,6 +2641,8 @@ function buildResultCard(sec, result, isKept) {
2397
  </div>`).join('')}
2398
  </details>` : '';
2399
 
 
 
2400
  const trx = result.ai_transparency;
2401
  const req = trx && typeof trx.requested_ai_involvement_percent === 'number' ? trx.requested_ai_involvement_percent : null;
2402
  const meas = trx && typeof trx.measured_ai_involvement_percent === 'number' ? trx.measured_ai_involvement_percent : null;
@@ -2451,6 +2697,7 @@ function buildResultCard(sec, result, isKept) {
2451
  >${textHtml}</div>
2452
  ${notesHtml}
2453
  ${transHtml}
 
2454
  ${citeChips}
2455
  ${provHtml}
2456
  <div class="result-actions">
@@ -2666,6 +2913,43 @@ function bindResultTextEditing(code) {
2666
  });
2667
  }
2668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2669
  function buildCitationChips(prov) {
2670
  if (!prov || !prov.length) return '';
2671
  const chips = prov.map((p, i) => {
@@ -2720,6 +3004,185 @@ function escapeHtml(t) {
2720
  return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2721
  }
2722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2723
  window._lastLibraryMatches = [];
2724
 
2725
  function peerSectionsMap(excludeCode) {
@@ -3096,15 +3559,18 @@ $('btn-download').addEventListener('click', () => {
3096
 
3097
  /* ── Restart ─────────────────────────────────────────────────────────────── */
3098
  $('btn-restart').addEventListener('click', () => {
3099
- Object.assign(state, { tenantId:null, docId:null, docIds:[], reportId:null, file:null, files:[], sections:{}, results:{}, styleProfile:null });
 
3100
  fileInput.value = '';
3101
- $('tenant-input').value = '';
3102
  $('file-display').classList.add('hidden');
3103
  zone.classList.remove('has-file','drag-over');
3104
  $('btn-upload').disabled = true;
3105
- $('tenant-badge').classList.remove('visible');
3106
  $('style-profile-panel').classList.add('hidden');
3107
  hideAlert('upload-alert');
 
 
 
 
3108
  showStep(1);
3109
  });
3110
 
 
274
  .cite-chip:hover { border-color: var(--navy); background: #f0f4fa; }
275
  .cite-chip kbd { font-size: .68rem; opacity: .75; }
276
 
277
+ /* ── Auth gate ─────────────────────────────────────────────────────── */
278
+ .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); }
279
+ .auth-overlay.hidden-gate { display: none; }
280
+ body.auth-gate { overflow: hidden; }
281
+ .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; }
282
+ .auth-brand { font-size: 1.4rem; font-weight: 800; color: var(--navy); text-align: center; }
283
+ .auth-brand span { color: var(--teal); }
284
+ .auth-logo { font-size: 1.5rem; }
285
+ .auth-tagline { font-size: .85rem; color: var(--muted); text-align: center; margin: .5rem 0 1.4rem; line-height: 1.5; }
286
+ .auth-tabs { display: flex; gap: .4rem; background: #eef2f8; border-radius: 8px; padding: .25rem; margin-bottom: 1.2rem; }
287
+ .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); }
288
+ .auth-tab.active { background: var(--card); color: var(--navy); box-shadow: 0 1px 4px rgba(0,0,0,.08); }
289
+ .auth-submit { width: 100%; margin-top: .4rem; justify-content: center; }
290
+ html.dark .auth-tabs { background: #18202c; }
291
+ html.dark .auth-card { background: #0f1c2b; }
292
+
293
  .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; }
294
  .app-toast.hidden { opacity: 0; pointer-events: none; }
295
  .app-toast.toast-ok { background: #1a5f4a; }
 
310
  .similar-modal-close { border: none; background: transparent; font-size: 1.5rem; line-height: 1; cursor: pointer; color: var(--muted); padding: 0 .25rem; }
311
  .similar-modal-close:hover { color: var(--danger); }
312
  .similar-modal-body { padding: 1rem 1.2rem 1.4rem; overflow-y: auto; flex: 1; }
313
+
314
+ /* ── RAG Document Manager ─────────────────────────────────────────── */
315
+ .docmgr-toolbar { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; flex-wrap: wrap; }
316
+ .docmgr-count { font-size: .8rem; color: var(--muted); font-weight: 600; }
317
+ .docmgr-table { width: 100%; border-collapse: collapse; font-size: .82rem; }
318
+ .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; }
319
+ .docmgr-table td { padding: .55rem .55rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
320
+ .docmgr-table tr:last-child td { border-bottom: none; }
321
+ .docmgr-table tr.row-deleting { opacity: .5; pointer-events: none; }
322
+ .docmgr-fname { font-weight: 600; color: var(--navy); word-break: break-word; max-width: 22rem; }
323
+ .docmgr-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .68rem; color: var(--muted); }
324
+ .docmgr-meta { font-size: .72rem; color: var(--muted); white-space: nowrap; }
325
+ .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; }
326
+ .docmgr-badge.b-ready { background: #e3f4ec; color: #1a5f4a; }
327
+ .docmgr-badge.b-processing { background: #fef6e0; color: #8a6d3b; }
328
+ .docmgr-badge.b-pending { background: #eef2f8; color: var(--navy-lt); }
329
+ .docmgr-badge.b-failed { background: #fdecea; color: #b71c1c; }
330
+ .docmgr-badge.b-session { background: var(--teal); color: #fff; margin-left: .35rem; }
331
+ .docmgr-badge.b-style { background: #ece3f7; color: #5b3a8a; }
332
+ .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); }
333
+ .docmgr-del:hover { background: var(--danger); color: #fff; }
334
+ .docmgr-del:disabled { opacity: .5; cursor: default; }
335
+ .docmgr-empty { text-align: center; padding: 2.5rem 1rem; color: var(--muted); }
336
+ .docmgr-empty .icon { font-size: 2.2rem; display: block; margin-bottom: .6rem; }
337
+ .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; }
338
+ @keyframes docmgr-spin { to { transform: rotate(360deg); } }
339
+ .docmgr-loading { text-align: center; padding: 2rem 1rem; color: var(--muted); }
340
+ html.dark .docmgr-badge.b-ready { background: #14352a; color: #6fdab2; }
341
+ html.dark .docmgr-badge.b-processing { background: #3a2f17; color: #e6c878; }
342
+ html.dark .docmgr-badge.b-pending { background: #18202c; color: #9db4d0; }
343
+ html.dark .docmgr-badge.b-failed { background: #3a1715; color: #f0a39d; }
344
+ html.dark .docmgr-badge.b-style { background: #2a1f3a; color: #c5a8e8; }
345
+ html.dark .similar-modal-head[style*="fdecea"], html.dark #doc-delete-modal .similar-modal-head { background: #3a1715 !important; }
346
  .similar-intro { font-size: .86rem; color: var(--muted); margin: 0 0 1rem; line-height: 1.5; }
347
  .similar-h4 { margin: 1.2rem 0 .6rem; font-size: .88rem; color: var(--navy); text-transform: uppercase; letter-spacing: .04em; }
348
  .similar-h4:first-of-type { margin-top: 0; }
 
603
  .divider { border: none; border-top: 1.5px solid var(--border); margin: 1.2rem 0; }
604
  </style>
605
  </head>
606
+ <body class="auth-gate">
607
 
608
  <header>
609
  <div class="logo">
 
613
  <span class="subtitle">RICS Survey Report Generator</span>
614
  <div class="spacer"></div>
615
  <div class="tenant-badge" id="tenant-badge">Loading…</div>
616
+ <button class="theme-toggle" id="btn-doc-manager" onclick="openDocManager()" title="View and delete your uploaded RAG documents">
617
+ <span aria-hidden="true">πŸ—‚οΈ</span>
618
+ <span>My Documents</span>
619
+ </button>
620
+ <button class="theme-toggle" id="btn-logout" onclick="logout()" title="Sign out" style="display:none">
621
+ <span aria-hidden="true">πŸšͺ</span>
622
+ <span>Log out</span>
623
+ </button>
624
  <button class="theme-toggle" id="theme-toggle-btn" onclick="toggleTheme()" title="Switch between light and dark mode">
625
  <span class="th-icon" id="theme-icon">β˜€οΈ</span>
626
  <span id="theme-label">Light</span>
627
  </button>
628
  </header>
629
 
630
+ <!-- ── Auth gate ─────────────────────────────────────────────────────────── -->
631
+ <div id="auth-overlay" class="auth-overlay" aria-hidden="false">
632
+ <div class="auth-card">
633
+ <div class="auth-brand"><span class="auth-logo">πŸ“‹</span> Report Genius <span>AI</span></div>
634
+ <p class="auth-tagline">Sign in to your private workspace. Your documents and reports are isolated to your account.</p>
635
+ <div class="auth-tabs">
636
+ <button type="button" class="auth-tab active" id="auth-tab-login" onclick="setAuthMode('login')">Log in</button>
637
+ <button type="button" class="auth-tab" id="auth-tab-register" onclick="setAuthMode('register')">Create account</button>
638
+ </div>
639
+ <form id="auth-form" onsubmit="submitAuth(event)" autocomplete="on">
640
+ <div class="form-group">
641
+ <label for="auth-tenant">Tenant / User ID</label>
642
+ <input type="text" id="auth-tenant" name="username" autocomplete="username" placeholder="e.g. acme_surveys" />
643
+ <div class="form-hint">3–128 chars Β· letters, digits, '.', '_', '-'. This is your private workspace key.</div>
644
+ </div>
645
+ <div class="form-group">
646
+ <label for="auth-pass">Passphrase</label>
647
+ <input type="password" id="auth-pass" name="password" autocomplete="current-password" placeholder="At least 8 characters" />
648
+ <div class="form-hint" id="auth-pass-hint">Use a strong, memorable passphrase. There is no recovery β€” keep it safe.</div>
649
+ </div>
650
+ <div id="auth-alert" class="hidden"></div>
651
+ <button type="submit" class="btn btn-primary auth-submit" id="auth-submit-btn">Log in</button>
652
+ </form>
653
+ </div>
654
+ </div>
655
+
656
  <div class="steps" id="step-indicator">
657
  <div class="step-item active" id="si-1"><div class="step-num">1</div> Upload</div>
658
  <div class="step-sep">β€Ί</div>
 
902
  </div>
903
  </div>
904
 
905
+ <!-- ── RAG Document Manager modal ────────────────────────────────────────── -->
906
+ <div id="doc-manager-modal" class="similar-modal hidden" role="dialog" aria-modal="true" aria-labelledby="doc-manager-title">
907
+ <div class="similar-modal-backdrop" onclick="closeDocManager()"></div>
908
+ <div class="similar-modal-panel" style="width:min(900px,100%)">
909
+ <div class="similar-modal-head">
910
+ <h3 id="doc-manager-title">πŸ—‚οΈ RAG Document Manager</h3>
911
+ <button type="button" class="similar-modal-close" onclick="closeDocManager()" aria-label="Close">Γ—</button>
912
+ </div>
913
+ <div class="similar-modal-body">
914
+ <p class="similar-intro" id="doc-manager-intro">
915
+ All documents indexed under your tenant. Deleting removes the file, its database record, and every
916
+ vector embedding for that document from the search index. This cannot be undone.
917
+ </p>
918
+ <div class="docmgr-toolbar">
919
+ <span id="docmgr-count" class="docmgr-count"></span>
920
+ <button type="button" class="btn-similar" id="docmgr-refresh" onclick="loadDocManagerList()">↻ Refresh</button>
921
+ <button type="button" class="btn-similar" id="docmgr-reingest" onclick="reingestAllDocuments()" title="Re-process every document through the latest table-aware parser and chunker">⟳ Re-ingest library</button>
922
+ </div>
923
+ <div id="doc-manager-list"></div>
924
+ </div>
925
+ </div>
926
+ </div>
927
+
928
+ <!-- ── Delete confirmation modal ─────────────────────────────────────────── -->
929
+ <div id="doc-delete-modal" class="similar-modal hidden" role="alertdialog" aria-modal="true" aria-labelledby="doc-delete-title" style="z-index:9500">
930
+ <div class="similar-modal-backdrop" onclick="cancelDocDelete()"></div>
931
+ <div class="similar-modal-panel" style="max-width:480px">
932
+ <div class="similar-modal-head" style="background:#fdecea;border-bottom:2px solid var(--danger)">
933
+ <h3 id="doc-delete-title" style="color:var(--danger)">Delete document?</h3>
934
+ <button type="button" class="similar-modal-close" onclick="cancelDocDelete()" aria-label="Close">Γ—</button>
935
+ </div>
936
+ <div class="similar-modal-body" id="doc-delete-body" style="padding:1.2rem"></div>
937
+ </div>
938
+ </div>
939
+
940
  <!-- ── Tier mismatch warning modal ──────────────────────────────────────── -->
941
  <div id="tier-mismatch-modal" class="similar-modal hidden" role="alertdialog" aria-modal="true" aria-labelledby="tier-mismatch-title">
942
  <div class="similar-modal-backdrop"></div>
 
980
 
981
  const state = {
982
  tenantId: null,
983
+ token: null,
984
  docId: null,
985
  docIds: [], // all accepted document UUIDs from last batch upload
986
+ sessionDocIds: [], // every doc uploaded during THIS browser session (for "current" vs "previous" tagging)
987
  reportId: null,
988
  surveyLevel: 3,
989
  file: null,
 
1135
  }
1136
 
1137
  async function apiFetch(method, path, body, headers = {}, options = {}) {
1138
+ const baseHeaders = { ...headers };
1139
+ if (state.token) baseHeaders['Authorization'] = `Bearer ${state.token}`;
1140
+ if (state.tenantId) baseHeaders['X-Tenant-ID'] = state.tenantId; // dev fallback
1141
+ const opts = { method, headers: baseHeaders };
1142
  if (body instanceof FormData) { opts.body = body; }
1143
  else if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
1144
  // Optional AbortSignal β€” used by _saveSectionEdit to cancel an in-flight save
 
1146
  // callers can ignore this; behaviour is unchanged when signal is omitted.
1147
  if (options && options.signal) opts.signal = options.signal;
1148
  const res = await fetch(apiUrl(path), opts);
1149
+ if (res.status === 401 && !(options && options.noAuthRedirect)) {
1150
+ handleAuthExpired();
1151
+ throw new Error('Your session expired. Please log in again.');
1152
+ }
1153
  if (res.status === 204) {
1154
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1155
  return null;
 
1182
  return data;
1183
  }
1184
 
1185
+ /* ── Authentication (must run before protected API calls) ─────────────── */
1186
+ const AUTH_KEY = 'rics_auth';
1187
+ let _authMode = 'login';
1188
+
1189
+ function setAuthMode(mode) {
1190
+ _authMode = mode;
1191
+ $('auth-tab-login').classList.toggle('active', mode === 'login');
1192
+ $('auth-tab-register').classList.toggle('active', mode === 'register');
1193
+ $('auth-submit-btn').textContent = mode === 'register' ? 'Create account & sign in' : 'Log in';
1194
+ $('auth-pass').setAttribute('autocomplete', mode === 'register' ? 'new-password' : 'current-password');
1195
+ $('auth-pass-hint').textContent = mode === 'register'
1196
+ ? 'At least 8 characters. There is no recovery β€” store it safely.'
1197
+ : 'Enter the passphrase you registered with this Tenant/User ID.';
1198
+ hideAlert('auth-alert');
1199
+ }
1200
+
1201
+ function showAuthOverlay() {
1202
+ const el = $('auth-overlay');
1203
+ if (el) {
1204
+ el.classList.remove('hidden-gate');
1205
+ el.setAttribute('aria-hidden', 'false');
1206
+ }
1207
+ document.body.classList.add('auth-gate');
1208
+ setTimeout(() => { const t = $('auth-tenant'); if (t) t.focus(); }, 50);
1209
+ }
1210
+
1211
+ function hideAuthOverlay() {
1212
+ const el = $('auth-overlay');
1213
+ if (el) {
1214
+ el.classList.add('hidden-gate');
1215
+ el.setAttribute('aria-hidden', 'true');
1216
+ }
1217
+ document.body.classList.remove('auth-gate');
1218
+ }
1219
+
1220
+ function _persistAuth(sess) {
1221
+ localStorage.setItem(AUTH_KEY, JSON.stringify(sess));
1222
+ }
1223
+
1224
+ function applyAuthSession(sess) {
1225
+ state.token = sess.access_token;
1226
+ state.tenantId = sess.tenant_id;
1227
+ _persistAuth(sess);
1228
+ $('tenant-badge').textContent = `πŸ”‘ ${sess.tenant_id}`;
1229
+ $('tenant-badge').classList.add('visible');
1230
+ $('btn-logout').style.display = '';
1231
+ const ti = $('tenant-input');
1232
+ if (ti) { ti.value = sess.tenant_id; ti.readOnly = true; ti.title = 'Logged in as this tenant'; }
1233
+ hideAuthOverlay();
1234
+ }
1235
+
1236
+ async function submitAuth(event) {
1237
+ event.preventDefault();
1238
+ const tenant = $('auth-tenant').value.trim();
1239
+ const pass = $('auth-pass').value;
1240
+ if (!tenant || !pass) { showAlert('auth-alert', 'warn', 'Enter both a Tenant/User ID and a passphrase.'); return; }
1241
+ const btn = $('auth-submit-btn');
1242
+ btn.disabled = true;
1243
+ const original = btn.textContent;
1244
+ btn.textContent = _authMode === 'register' ? 'Creating account…' : 'Signing in…';
1245
+ try {
1246
+ const path = _authMode === 'register' ? '/auth/register' : '/auth/login';
1247
+ const res = await apiFetch('POST', path, { tenant_id: tenant, passphrase: pass }, {}, { noAuthRedirect: true });
1248
+ applyAuthSession(res);
1249
+ $('auth-pass').value = '';
1250
+ showToast(_authMode === 'register' ? 'Account created β€” welcome.' : 'Signed in.', 'ok');
1251
+ } catch (err) {
1252
+ showAlert('auth-alert', 'error', escapeHtml(err.message || 'Authentication failed.'));
1253
+ } finally {
1254
+ btn.disabled = false;
1255
+ btn.textContent = original;
1256
+ }
1257
+ }
1258
+
1259
+ function logout() {
1260
+ localStorage.removeItem(AUTH_KEY);
1261
+ state.token = null;
1262
+ state.tenantId = null;
1263
+ $('btn-logout').style.display = 'none';
1264
+ $('tenant-badge').classList.remove('visible');
1265
+ if (typeof closeDocManager === 'function') closeDocManager();
1266
+ const ti = $('tenant-input');
1267
+ if (ti) { ti.readOnly = false; ti.value = ''; ti.title = ''; }
1268
+ showAuthOverlay();
1269
+ showToast('Signed out.', 'ok');
1270
+ }
1271
+
1272
+ function handleAuthExpired() {
1273
+ localStorage.removeItem(AUTH_KEY);
1274
+ state.token = null;
1275
+ state.tenantId = null;
1276
+ $('btn-logout').style.display = 'none';
1277
+ $('tenant-badge').classList.remove('visible');
1278
+ const ti = $('tenant-input');
1279
+ if (ti) { ti.readOnly = false; ti.value = ''; ti.title = ''; }
1280
+ showAuthOverlay();
1281
+ showAlert('auth-alert', 'warn', 'Your session expired. Please log in again.');
1282
+ }
1283
+
1284
+ async function bootstrapAuth() {
1285
+ setAuthMode('login');
1286
+ let sess = null;
1287
+ try { sess = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch { sess = null; }
1288
+ const stillValid = sess && sess.access_token && (!sess.expires_at || sess.expires_at * 1000 > Date.now() + 30000);
1289
+ if (!stillValid) { showAuthOverlay(); return; }
1290
+ state.token = sess.access_token;
1291
+ state.tenantId = sess.tenant_id;
1292
+ try {
1293
+ const me = await apiFetch('GET', '/auth/me', null, {}, { noAuthRedirect: true });
1294
+ applyAuthSession({ ...sess, tenant_id: me.tenant_id });
1295
+ } catch {
1296
+ handleAuthExpired();
1297
+ }
1298
+ }
1299
+
1300
+ bootstrapAuth();
1301
+
1302
  /* ── Tier mismatch modal ─────────────────────────────────────────────── */
1303
  let _mismatchResolve = null;
1304
  let _mismatchReject = null;
 
1520
 
1521
  async function handleUpload() {
1522
  if (!state.files.length) { showAlert('upload-alert', 'warn', 'Please choose at least one file.'); return; }
1523
+ if (!state.token || !state.tenantId) { showAuthOverlay(); return; }
1524
+ const tid = state.tenantId; // identity comes from the verified token, not the input
 
1525
  $('tenant-badge').textContent = `πŸ”‘ ${tid}`;
1526
  $('tenant-badge').classList.add('visible');
1527
  showStep(2);
 
1545
 
1546
  state.docIds = up.items.filter(it => it.document_id).map(it => it.document_id);
1547
  if (!state.docIds.length) throw new Error('Batch upload returned no document IDs');
1548
+ state.sessionDocIds = Array.from(new Set([...(state.sessionDocIds || []), ...state.docIds]));
1549
 
1550
  $('proc-title').textContent = 'Indexing reference documents…';
1551
  $('proc-detail').textContent = `Queued ${up.accepted} file(s) for parsing & embedding${up.rejected ? ` (${up.rejected} rejected)` : ''}`;
 
2641
  </div>`).join('')}
2642
  </details>` : '';
2643
 
2644
+ const auditHtml = buildCitationAudit(result.citation_audit);
2645
+
2646
  const trx = result.ai_transparency;
2647
  const req = trx && typeof trx.requested_ai_involvement_percent === 'number' ? trx.requested_ai_involvement_percent : null;
2648
  const meas = trx && typeof trx.measured_ai_involvement_percent === 'number' ? trx.measured_ai_involvement_percent : null;
 
2697
  >${textHtml}</div>
2698
  ${notesHtml}
2699
  ${transHtml}
2700
+ ${auditHtml}
2701
  ${citeChips}
2702
  ${provHtml}
2703
  <div class="result-actions">
 
2913
  });
2914
  }
2915
 
2916
+ function buildCitationAudit(audit) {
2917
+ if (!audit || typeof audit !== 'object') return '';
2918
+ const conf = typeof audit.confidence === 'number' ? Math.round(audit.confidence * 100) : null;
2919
+ const findings = typeof audit.findings === 'number' ? audit.findings : 0;
2920
+ const contradictions = Array.isArray(audit.contradictions) ? audit.contradictions : [];
2921
+ const dropped = Array.isArray(audit.dropped_claims) ? audit.dropped_claims : [];
2922
+ // Nothing meaningful to show (e.g. no API key / no findings) β€” stay quiet.
2923
+ if (conf === null && !findings && !contradictions.length && !dropped.length) return '';
2924
+
2925
+ const clean = !contradictions.length && !dropped.length;
2926
+ const tone = clean
2927
+ ? 'border:1px solid #bfe3c6;background:#f1faf3;color:#1f6b35'
2928
+ : 'border:1px solid #f0d6b4;background:#fff7ea;color:#7a4a00';
2929
+ const icon = clean ? 'βœ…' : '⚠️';
2930
+ const confStr = conf !== null ? `${conf}%` : 'n/a';
2931
+
2932
+ let body = `<strong>${icon} Evidence audit:</strong> ${findings} grounded finding(s), `
2933
+ + `confidence ${confStr}.`;
2934
+
2935
+ if (contradictions.length) {
2936
+ body += `<details style="margin-top:.4rem"><summary style="cursor:pointer">`
2937
+ + `${contradictions.length} contradiction(s) resolved</summary>`
2938
+ + `<ul style="margin:.35rem 0 0 1.1rem;list-style:disc">`
2939
+ + contradictions.map(c => `<li>${escapeHtml((c.element || '') + ': ' + (c.detail || ''))}`
2940
+ + (c.resolution ? ` <em>(${escapeHtml(c.resolution)})</em>` : '') + `</li>`).join('')
2941
+ + `</ul></details>`;
2942
+ }
2943
+ if (dropped.length) {
2944
+ body += `<details style="margin-top:.4rem"><summary style="cursor:pointer">`
2945
+ + `${dropped.length} unsupported claim(s) dropped</summary>`
2946
+ + `<ul style="margin:.35rem 0 0 1.1rem;list-style:disc">`
2947
+ + dropped.map(d => `<li>${escapeHtml(String(d))}</li>`).join('')
2948
+ + `</ul></details>`;
2949
+ }
2950
+ return `<div class="section-transparency" style="${tone}">${body}</div>`;
2951
+ }
2952
+
2953
  function buildCitationChips(prov) {
2954
  if (!prov || !prov.length) return '';
2955
  const chips = prov.map((p, i) => {
 
3004
  return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
3005
  }
3006
 
3007
+ /* ── RAG Document Manager ──────────────────────────────────────────────── */
3008
+ function _docMgrResolveTenant() {
3009
+ if (state.tenantId) return state.tenantId;
3010
+ const typed = ($('tenant-input') && $('tenant-input').value || '').trim();
3011
+ if (typed) { state.tenantId = typed; }
3012
+ return state.tenantId;
3013
+ }
3014
+
3015
+ function formatBytes(n) {
3016
+ if (n == null || isNaN(n)) return 'β€”';
3017
+ if (n < 1024) return `${n} B`;
3018
+ const units = ['KB', 'MB', 'GB'];
3019
+ let val = n / 1024, i = 0;
3020
+ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
3021
+ return `${val.toFixed(val >= 10 ? 0 : 1)} ${units[i]}`;
3022
+ }
3023
+
3024
+ function formatDateTime(iso) {
3025
+ if (!iso) return 'β€”';
3026
+ let s = String(iso).trim();
3027
+ // Server timestamps are UTC; SQLite often omits the offset in JSON.
3028
+ if (s && !/Z$|[+-]\d{2}:\d{2}$/.test(s)) s += 'Z';
3029
+ const d = new Date(s);
3030
+ if (isNaN(d.getTime())) return 'β€”';
3031
+ return d.toLocaleString(undefined, {
3032
+ year: 'numeric',
3033
+ month: 'short',
3034
+ day: 'numeric',
3035
+ hour: '2-digit',
3036
+ minute: '2-digit',
3037
+ });
3038
+ }
3039
+
3040
+ const DOC_STATUS_BADGE = {
3041
+ complete: { cls: 'b-ready', label: 'Ready' },
3042
+ processing: { cls: 'b-processing', label: 'Processing' },
3043
+ pending: { cls: 'b-pending', label: 'Pending' },
3044
+ failed: { cls: 'b-failed', label: 'Failed' },
3045
+ };
3046
+
3047
+ function openDocManager() {
3048
+ const tid = _docMgrResolveTenant();
3049
+ $('doc-manager-modal').classList.remove('hidden');
3050
+ if (tid) {
3051
+ $('tenant-badge').textContent = `πŸ”‘ ${tid}`;
3052
+ $('tenant-badge').classList.add('visible');
3053
+ loadDocManagerList();
3054
+ } else {
3055
+ $('doc-manager-list').innerHTML =
3056
+ '<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>';
3057
+ $('docmgr-count').textContent = '';
3058
+ }
3059
+ }
3060
+
3061
+ function closeDocManager() {
3062
+ $('doc-manager-modal').classList.add('hidden');
3063
+ }
3064
+
3065
+ async function reingestAllDocuments() {
3066
+ const btn = $('docmgr-reingest');
3067
+ if (!confirm('Re-ingest every document through the latest parser/chunker? '
3068
+ + 'Existing chunks are replaced. Documents with a report currently generating are skipped.')) {
3069
+ return;
3070
+ }
3071
+ const original = btn ? btn.textContent : '';
3072
+ if (btn) { btn.disabled = true; btn.textContent = '⟳ Re-ingesting…'; }
3073
+ try {
3074
+ const res = await apiFetch('POST', '/documents/reingest', {});
3075
+ showToast((res && res.detail) || `Re-queued ${res ? res.queued : 0} document(s).`, 'ok');
3076
+ await loadDocManagerList();
3077
+ } catch (err) {
3078
+ showToast('Re-ingest failed: ' + (err && err.message ? err.message : 'request failed'), 'err');
3079
+ } finally {
3080
+ if (btn) { btn.disabled = false; btn.textContent = original; }
3081
+ }
3082
+ }
3083
+
3084
+ async function loadDocManagerList() {
3085
+ const listEl = $('doc-manager-list');
3086
+ const countEl = $('docmgr-count');
3087
+ listEl.innerHTML = '<div class="docmgr-loading"><span class="docmgr-spinner"></span> Loading your documents…</div>';
3088
+ countEl.textContent = '';
3089
+ try {
3090
+ const data = await apiFetch('GET', '/documents?limit=500', null);
3091
+ const docs = (data && data.documents) || [];
3092
+ if (!docs.length) {
3093
+ listEl.innerHTML =
3094
+ '<div class="docmgr-empty"><span class="icon">πŸ“­</span>No documents uploaded yet under this tenant.</div>';
3095
+ return;
3096
+ }
3097
+ const sessionSet = new Set(state.sessionDocIds || []);
3098
+ _docMgrNames = {};
3099
+ const rows = docs.map(d => {
3100
+ _docMgrNames[d.document_id] = d.filename || 'untitled';
3101
+ const badge = DOC_STATUS_BADGE[d.status] || DOC_STATUS_BADGE.pending;
3102
+ const isSession = sessionSet.has(d.document_id);
3103
+ const isStyle = d.document_purpose === 'style_corpus';
3104
+ const sessionTag = isSession
3105
+ ? '<span class="docmgr-badge b-session" title="Uploaded during this session">This session</span>'
3106
+ : '';
3107
+ const styleTag = isStyle
3108
+ ? '<span class="docmgr-badge b-style" title="Past report kept for style learning only">Style</span>'
3109
+ : '';
3110
+ const errAttr = d.error ? ` title="${escapeHtml(String(d.error))}"` : '';
3111
+ return `<tr id="docrow-${escapeHtml(d.document_id)}">
3112
+ <td>
3113
+ <div class="docmgr-fname">${escapeHtml(d.filename || 'untitled')}${sessionTag}${styleTag}</div>
3114
+ <div class="docmgr-id">${escapeHtml(d.document_id)}</div>
3115
+ </td>
3116
+ <td class="docmgr-meta">${formatDateTime(d.created_at)}</td>
3117
+ <td class="docmgr-meta">${formatBytes(d.file_size)}</td>
3118
+ <td><span class="docmgr-badge ${badge.cls}"${errAttr}>${badge.label}</span></td>
3119
+ <td style="text-align:right">
3120
+ <button type="button" class="docmgr-del" onclick="confirmDocDelete('${escapeHtml(d.document_id)}')">πŸ—‘ Delete</button>
3121
+ </td>
3122
+ </tr>`;
3123
+ }).join('');
3124
+ listEl.innerHTML = `<table class="docmgr-table">
3125
+ <thead><tr><th>Document</th><th>Uploaded</th><th>Size</th><th>Status</th><th></th></tr></thead>
3126
+ <tbody>${rows}</tbody>
3127
+ </table>`;
3128
+ const sessionCount = docs.filter(d => sessionSet.has(d.document_id)).length;
3129
+ countEl.textContent = `${docs.length} document${docs.length === 1 ? '' : 's'}` +
3130
+ (sessionCount ? ` Β· ${sessionCount} from this session` : '');
3131
+ } catch (err) {
3132
+ listEl.innerHTML =
3133
+ `<div class="docmgr-empty"><span class="icon">⚠️</span>Could not load documents: ${escapeHtml(err.message || 'request failed')}</div>`;
3134
+ }
3135
+ }
3136
+
3137
+ let _pendingDeleteDocId = null;
3138
+ let _docMgrNames = {};
3139
+
3140
+ function confirmDocDelete(docId) {
3141
+ _pendingDeleteDocId = docId;
3142
+ const filename = _docMgrNames[docId] || 'this document';
3143
+ $('doc-delete-body').innerHTML = `
3144
+ <p style="margin:0 0 .8rem;line-height:1.5">
3145
+ Permanently delete <strong>${escapeHtml(filename)}</strong>?
3146
+ </p>
3147
+ <p style="margin:0 0 1.1rem;font-size:.82rem;color:var(--muted);line-height:1.5">
3148
+ This removes the file, its database record, and all vector embeddings for this document from the search index.
3149
+ Reports already generated are unaffected. <strong>This cannot be undone.</strong>
3150
+ </p>
3151
+ <div style="display:flex;justify-content:flex-end;gap:.6rem">
3152
+ <button type="button" class="btn-similar" onclick="cancelDocDelete()">Cancel</button>
3153
+ <button type="button" class="docmgr-del" id="doc-delete-confirm-btn" onclick="executeDocDelete()">πŸ—‘ Delete permanently</button>
3154
+ </div>`;
3155
+ $('doc-delete-modal').classList.remove('hidden');
3156
+ }
3157
+
3158
+ function cancelDocDelete() {
3159
+ _pendingDeleteDocId = null;
3160
+ $('doc-delete-modal').classList.add('hidden');
3161
+ }
3162
+
3163
+ async function executeDocDelete() {
3164
+ const docId = _pendingDeleteDocId;
3165
+ if (!docId) return;
3166
+ const confirmBtn = $('doc-delete-confirm-btn');
3167
+ if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.innerHTML = '<span class="docmgr-spinner"></span> Deleting…'; }
3168
+ const row = $(`docrow-${docId}`);
3169
+ if (row) row.classList.add('row-deleting');
3170
+ try {
3171
+ const res = await apiFetch('DELETE', `/documents/${encodeURIComponent(docId)}`, null);
3172
+ cancelDocDelete();
3173
+ state.docIds = (state.docIds || []).filter(id => id !== docId);
3174
+ state.sessionDocIds = (state.sessionDocIds || []).filter(id => id !== docId);
3175
+ showToast((res && res.detail) || 'Document deleted.', 'ok');
3176
+ await loadDocManagerList();
3177
+ } catch (err) {
3178
+ cancelDocDelete();
3179
+ if (row) row.classList.remove('row-deleting');
3180
+ const msg = err.message || 'Delete failed';
3181
+ // 409 = still linked to a report (FK guard) β€” surface the server's guidance.
3182
+ showToast(msg.length > 160 ? msg.slice(0, 157) + '…' : msg, 'err');
3183
+ }
3184
+ }
3185
+
3186
  window._lastLibraryMatches = [];
3187
 
3188
  function peerSectionsMap(excludeCode) {
 
3559
 
3560
  /* ── Restart ─────────────────────────────────────────────────────────────── */
3561
  $('btn-restart').addEventListener('click', () => {
3562
+ // Preserve the authenticated identity (token + tenant) across a new report.
3563
+ Object.assign(state, { docId:null, docIds:[], reportId:null, file:null, files:[], sections:{}, results:{}, styleProfile:null });
3564
  fileInput.value = '';
 
3565
  $('file-display').classList.add('hidden');
3566
  zone.classList.remove('has-file','drag-over');
3567
  $('btn-upload').disabled = true;
 
3568
  $('style-profile-panel').classList.add('hidden');
3569
  hideAlert('upload-alert');
3570
+ if (state.tenantId) {
3571
+ const ti = $('tenant-input');
3572
+ if (ti) { ti.value = state.tenantId; ti.readOnly = true; }
3573
+ }
3574
  showStep(1);
3575
  });
3576