Source code for qfa.services.sensitivity

"""Sensitivity-detection use case.

One LLM call per feedback record, classifying it against the
:class:`~qfa.domain.sensitivity_types.SensitivityType` vocabulary. This is
the smallest of the use cases extracted from ``Orchestrator`` by epic #112:
no embedder, no judge connection, no chunking — which is why its
constructor names exactly one dependency.

Per ADR-017 this class has **no base class**: the LLM-call scaffolding it
shares with the other use cases (anonymise, deadline→timeout, the call
itself, deanonymise) is the injected
:class:`~qfa.services.llm_call_executor.LLMCallExecutor` it delegates to,
not a superclass it inherits from.
"""

from datetime import datetime

from qfa.domain.models import (
    SensitivityAnalysisRequestModel,
    SensitivityAnalysisResultModel,
    SensitivityAnalysisResultModelList,
)
from qfa.domain.sensitivity_types import SENSITIVITY_TYPE_DESCRIPTIONS
from qfa.services.llm_call_executor import LLMCallExecutor
from qfa.services.prompts import build_feedback_record_envelope

_SENSITIVITY_TYPE_GUIDANCE = "\n".join(
    f"- {sensitivity_type.value}: {description}"
    for sensitivity_type, description in SENSITIVITY_TYPE_DESCRIPTIONS.items()
)

_DEFAULT_SENSITIVITY_DETECTION_PROMPT = (
    "Analyze each feedback record and detect whether it contains sensitive content.\n"
    "Classify sensitivity using only the SensitivityType enum values from the response schema.\n"
    "For each record, include a concise natural-language explanation for the classification.\n"
    f"SensitivityType guidance:\n{_SENSITIVITY_TYPE_GUIDANCE}\n"
    "Return one result per input record with the matching feedback_record_id.\n"
    "If no sensitive content is present, return an empty sensitivity_types tuple for that record.\n"
    "Do not include markdown code fences.\n"
    "Note that anonymization might have taken place (e.g. ``<PERSON_0>``, ``<LOCATION_1>``). \n"
    "Please act as if these were not anonymized. For example, if you see ``<PERSON_0>``"
    " treat it as if it said 'John Doe' and classify sensitivity accordingly. \n"
    "Please note that we prefer false positives over false negatives in this classification."
)


[docs] class SensitivityService: """Detect sensitive content in a single feedback record. Parameters ---------- executor : LLMCallExecutor The shared LLM-call scaffolding this use case delegates to: anonymisation of the outgoing message, the deadline-bounded call itself, and restoration of the redacted values in the response. The composition root (:func:`qfa.api.composition.build_services`) hands over the same instance the other services use. """ def __init__(self, executor: LLMCallExecutor) -> None: self._executor = executor
[docs] async def detect_sensitive_content( self, request: SensitivityAnalysisRequestModel, deadline: datetime, ) -> SensitivityAnalysisResultModel: """Detect sensitive content in a single feedback record. Parameters ---------- request : SensitivityAnalysisRequestModel The sensitivity analysis request containing a single feedback record. deadline : datetime The wall-clock deadline for the whole request; the LLM call is timed out against whatever remains of it. Returns ------- SensitivityAnalysisResultModel The sensitivity analysis result for the feedback record. """ system_message = _DEFAULT_SENSITIVITY_DETECTION_PROMPT user_message = build_feedback_record_envelope( request.feedback_record, include_metadata=True, include_id=True ) anonymized_user_message, anonymization_mapping = self._executor.anonymize_text( user_message ) response = await self._executor.complete( system_message=system_message, user_message=anonymized_user_message, tenant_id=request.tenant_id, response_model=SensitivityAnalysisResultModelList, deadline=deadline, ) return_model_as_string = response.structured.model_dump_json() unanonymized_return_model_as_string = self._executor.deanonymize_json( return_model_as_string, anonymization_mapping ) structured = SensitivityAnalysisResultModelList.model_validate_json( unanonymized_return_model_as_string ) raw = structured.results[0] if structured.results else None return SensitivityAnalysisResultModel( feedback_record_id=request.feedback_record.id, sensitivity_types=raw.sensitivity_types if raw else (), explanation=raw.explanation if raw else "No sensitive content detected.", )