Spaces:
Runtime error
Runtime error
File size: 7,324 Bytes
aad7814 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """Upload-time redaction strategy parsing and session PII context assembly.
Builds the ``db_context`` map forwarded to :func:`sanitise_text_for_rag_sync`
for exact-string wipes in the deterministic redaction path. Values originate
from optional multipart form fields and, when absent, from the tenant's most
recent report section text (best-effort identity extraction).
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from app.services.document_sanitiser import RedactionStrategy
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
class InvalidRedactionStrategyError(ValueError):
"""Raised when an upload ``redaction_strategy`` value cannot be mapped."""
_STRATEGY_ALIASES: dict[str, RedactionStrategy] = {
"ai_hybrid": RedactionStrategy.AI_HYBRID,
"ai": RedactionStrategy.AI_HYBRID,
"hybrid": RedactionStrategy.AI_HYBRID,
"deterministic": RedactionStrategy.DETERMINISTIC_CODE,
"deterministic_code": RedactionStrategy.DETERMINISTIC_CODE,
"code": RedactionStrategy.DETERMINISTIC_CODE,
}
def parse_redaction_strategy(raw: str | None) -> RedactionStrategy:
"""Map an upload form ``redaction_strategy`` string to :class:`RedactionStrategy`.
Args:
raw: User-supplied strategy name. ``None`` or blank defaults to
``AI_HYBRID``.
Returns:
The resolved enum member.
Raises:
InvalidRedactionStrategyError: When ``raw`` is non-empty but not recognised.
"""
if raw is None or not str(raw).strip():
return RedactionStrategy.AI_HYBRID
key = str(raw).strip().lower().replace("-", "_")
resolved = _STRATEGY_ALIASES.get(key)
if resolved is None:
allowed = sorted({"AI_HYBRID", "DETERMINISTIC_CODE", *_STRATEGY_ALIASES})
raise InvalidRedactionStrategyError(
f"Invalid redaction_strategy {raw!r}. "
f"Allowed values: {', '.join(allowed)}."
)
return resolved
def merge_db_context(*parts: dict[str, str] | None) -> dict[str, str]:
"""Merge context dicts; later keys do not overwrite earlier non-empty values."""
merged: dict[str, str] = {}
for part in parts:
if not part:
continue
for key, value in part.items():
v = (value or "").strip()
if v and key not in merged:
merged[key] = v
return merged
def db_context_from_form_fields(
*,
client_name: str | None = None,
property_address: str | None = None,
surveyor_name: str | None = None,
) -> dict[str, str]:
"""Build a ``db_context`` fragment from optional multipart form fields."""
ctx: dict[str, str] = {}
if client_name and client_name.strip():
ctx["client_name"] = client_name.strip()
if property_address and property_address.strip():
ctx["property_address"] = property_address.strip()
if surveyor_name and surveyor_name.strip():
ctx["surveyor_name"] = surveyor_name.strip()
return ctx
async def fetch_tenant_db_context(
db: AsyncSession,
tenant_id: str,
) -> dict[str, str]:
"""Best-effort PII context from the tenant's recent report section text.
Uses :func:`app.services.generation._extract_property_identity` on the
latest report sections so uploads can wipe names/addresses already present
in the tenant's active reporting session without re-entering them manually.
Args:
db: Active async database session.
tenant_id: Authenticated tenant identifier.
Returns:
A (possibly empty) ``{label: sensitive_value}`` map suitable for
``db_context`` in the deterministic redaction path.
"""
ctx: dict[str, str] = {}
try:
from sqlalchemy import select
from app.db.models import Report, ReportSection, ReportStatus
from app.services.generation import _extract_property_identity
res = await db.execute(
select(ReportSection.text)
.join(Report, Report.id == ReportSection.report_id)
.where(Report.tenant_id == tenant_id)
.where(
Report.status.in_(
(
ReportStatus.pending,
ReportStatus.generating,
ReportStatus.complete,
ReportStatus.partial,
)
)
)
.order_by(Report.updated_at.desc())
.limit(24)
)
lines = [row[0] for row in res.all() if row and row[0]]
if not lines:
return ctx
identity = _extract_property_identity(lines)
addr = (identity.get("address") or "").strip()
pc = (identity.get("postcode") or "").strip()
if addr:
if pc and pc.upper() not in addr.upper():
addr = f"{addr}, {pc}"
ctx["property_address"] = addr
elif pc:
ctx["property_address"] = pc
if identity.get("surveyor_name"):
ctx["surveyor_name"] = str(identity["surveyor_name"]).strip()
if identity.get("firm"):
ctx["company_name"] = str(identity["firm"]).strip()
except Exception as exc: # noqa: BLE001 — context fetch must never block upload
logger.debug("Tenant db_context backfill skipped tenant=%s: %s", tenant_id, exc)
return ctx
async def build_upload_db_context(
db: AsyncSession,
tenant_id: str,
*,
client_name: str | None = None,
property_address: str | None = None,
surveyor_name: str | None = None,
) -> dict[str, str]:
"""Merge upload form PII fields with tenant session backfill from reports."""
form_ctx = db_context_from_form_fields(
client_name=client_name,
property_address=property_address,
surveyor_name=surveyor_name,
)
tenant_ctx = await fetch_tenant_db_context(db, tenant_id)
return merge_db_context(tenant_ctx, form_ctx)
def serialize_db_context(ctx: dict[str, str] | None) -> str | None:
"""Persist ``db_context`` on a :class:`~app.db.models.Document` row."""
if not ctx:
return None
cleaned = {k: v.strip() for k, v in ctx.items() if k and (v or "").strip()}
return json.dumps(cleaned, ensure_ascii=False) if cleaned else None
def deserialize_db_context(raw: str | None) -> dict[str, str] | None:
"""Load persisted ``db_context`` JSON; returns ``None`` when absent/invalid."""
if not raw or not str(raw).strip():
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid redaction_context_json on document; ignoring.")
return None
if not isinstance(data, dict):
return None
return {
str(k): str(v).strip()
for k, v in data.items()
if k and isinstance(v, (str, int, float)) and str(v).strip()
}
def load_document_redaction_strategy(raw: str | None) -> RedactionStrategy:
"""Resolve a stored document strategy string (never raises — defaults safely)."""
try:
return parse_redaction_strategy(raw)
except InvalidRedactionStrategyError:
logger.warning("Unknown stored redaction_strategy %r; defaulting to AI_HYBRID.", raw)
return RedactionStrategy.AI_HYBRID
|