"""The declared half of a domain — the TBox (V4-9, V4-10). `measures`, `policies` and `units` are DERIVED from entries. What a domain *is*, and what it deliberately is *not*, cannot be derived from a term list — that is the ontology literature's expert-authored TBox, and it is what the review loop exists for. Decided 2026-09-08: **the model proposes, the expert approves.** A draft to correct beats a blank form. **`boundary` is derived, not written.** v3's real lesson was not the rename, it was *changing the question so the answer is checkable*: `purpose` (composed, unverifiable) became `purpose_verbatim` (located, span-checked). A boundary sentence is inherently unquotable — it is a statement about absence — so asking a model to write one produces exactly the unfalsifiable claim v3 removed. Instead the boundary is the **complement of the covered subdomains** over a closed enum: 12 members, this corpus covers 6, so the domain demonstrably has no approved knowledge about the other 6. That is deterministic, checkable, and it is what makes an honest refusal possible — "this domain governs equipment availability, not cost" becomes a fact about the corpus rather than an opinion. What still needs a human (or a model draft an expert corrects) is the free-text elaboration, the entities and the dimensions. Those are left EMPTY rather than guessed: an empty field an expert fills is better than a plausible one nobody checks. """ from __future__ import annotations import hashlib from typing import Any from src.knowledge_extraction.models import SubdomainEnum from src.middlewares.logging import get_logger logger = get_logger("knowledge_declare") # `other` is a catch-all. It is evidence of nothing, so it counts neither as # coverage nor as a gap - including it would make every corpus look broader than # it is and every boundary narrower. _UNINFORMATIVE = {"other"} ALL_SUBDOMAINS = [s.value for s in SubdomainEnum if s.value not in _UNINFORMATIVE] def declaration_id(scope_id: str) -> str: """Identity of one company's domain declaration. Scope-derived, not document-derived, which is the whole difference between this and a `DocumentBrief`. Deterministic for the same reason every other id is: an expert's approval must survive a re-proposal. """ digest = hashlib.sha256(scope_id.encode("utf-8")).hexdigest()[:10] return f"d_{digest}" def propose_declaration( scope_id: str, *, covered_subdomains: list[str], document_titles: list[str], units: list[str] | None = None, ) -> dict[str, Any]: """Draft a domain declaration from what the corpus demonstrably contains. Returns the payload for a `kind='domain'` entry. **No model is called.** Every field here is derived or left empty; nothing is invented, so nothing in the draft can be wrong in a way an expert would have to detect rather than simply complete. """ covered = [s for s in covered_subdomains if s not in _UNINFORMATIVE] uncovered = [s for s in ALL_SUBDOMAINS if s not in covered] payload: dict[str, Any] = { "domain_id": declaration_id(scope_id), "scope_id": scope_id, # A name a reader recognises, taken from the documents themselves rather # than composed. One document means its own title; several means the # corpus needs a name only an expert can give it. "domain_name": document_titles[0] if len(document_titles) == 1 else None, "subdomains": covered, # THE boundary, derived: what this corpus demonstrably says nothing # about. A planner meeting a question in one of these has a DOMAIN gap, # which is a different and more honest answer than a data gap. "uncovered_subdomains": uncovered, # The FACT, not a sentence: which subdomains this corpus has no approved # knowledge about. The renderer owns the wording, so the two cannot # disagree and a consumer can read the list rather than parse prose. "boundary": ", ".join(uncovered) if uncovered else None, # Left for the expert. Empty beats plausible-but-unchecked. "boundary_note": None, "conventions": { "time_grain": None, "calendar": None, "units": [{"symbol": u} for u in (units or [])], }, "entities": [], "dimensions": [], # Says how this draft was made, so a reviewer knows what they are # correcting and a later reader knows no model touched it. "proposed_by": "derived", "derived_from": { "n_documents": len(document_titles), "covered": covered, "enum_size": len(ALL_SUBDOMAINS), }, } logger.info( "domain_declaration_proposed", extra={ "scope_id": scope_id, "covered": len(covered), "uncovered": len(uncovered), }, ) return payload