Spaces:
Runtime error
Runtime error
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 +2 -0
- app/api/auth.py +184 -0
- app/api/generate.py +1 -0
- app/api/middleware.py +51 -13
- app/api/status.py +33 -2
- app/api/upload.py +157 -11
- app/chunking/splitter.py +51 -2
- app/config.py +35 -0
- app/db/database.py +75 -2
- app/db/models.py +25 -2
- app/extraction/__init__.py +47 -0
- app/extraction/citation_validator.py +229 -0
- app/extraction/contradiction.py +186 -0
- app/extraction/domain_scope.py +201 -0
- app/extraction/extractor.py +249 -0
- app/extraction/output_validator.py +109 -0
- app/extraction/prompts.py +75 -0
- app/extraction/schemas.py +199 -0
- app/generator/postprocess.py +23 -0
- app/generator/prompts.py +9 -4
- app/ingest/hierarchy.py +4 -1
- app/ingest/ocr_normalize.py +147 -0
- app/ingest/parser_pdf.py +131 -35
- app/llm/openai_chat.py +10 -0
- app/main.py +2 -0
- app/models/schemas.py +46 -0
- app/services/document_library.py +169 -0
- app/services/generation.py +133 -0
- app/services/photo_vision.py +8 -4
- app/tests/test_extraction_grounding.py +412 -0
- app/tests/test_full_library_retrieval.py +253 -0
- app/tests/test_generator.py +3 -1
- app/tests/test_ingest_normalize.py +135 -0
- frontend/index.html +474 -8
.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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
_EXEMPT: frozenset[str] = frozenset(
|
| 11 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
)
|
| 13 |
|
| 14 |
-
# Path prefixes that do not require
|
| 15 |
_EXEMPT_PREFIXES: tuple[str, ...] = ("/static/",)
|
| 16 |
|
| 17 |
|
| 18 |
class TenantAuthMiddleware(BaseHTTPMiddleware):
|
| 19 |
-
"""
|
| 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 |
-
|
|
|
|
| 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
|
| 39 |
-
if
|
| 40 |
return JSONResponse(
|
| 41 |
status_code=401,
|
| 42 |
-
content={"detail": "
|
| 43 |
)
|
| 44 |
|
| 45 |
-
request.state.tenant_id = tenant_id
|
| 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
|
| 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
|
|
|
|
| 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,
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 324 |
-
select(
|
|
|
|
|
|
|
|
|
|
| 325 |
)
|
| 326 |
-
if
|
| 327 |
raise HTTPException(
|
| 328 |
status_code=409,
|
| 329 |
detail=(
|
| 330 |
-
"
|
| 331 |
-
"
|
| 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=
|
| 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 =
|
|
|
|
|
|
|
| 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)
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 329 |
-
insert the exact token [DATA NOT PROVIDED]
|
|
|
|
| 330 |
Preserve the user's original wording as closely as possible; clean grammar and structure only.\n\
|
| 331 |
-
Output length
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
``
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 25 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
Args:
|
| 32 |
file_path: Absolute path to a ``.pdf`` file.
|
| 33 |
|
| 34 |
Returns:
|
| 35 |
-
List of :class:`langchain_core.documents.Document`
|
| 36 |
-
|
|
|
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1280 |
-
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 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 |
-
|
|
|
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 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 |
|