File size: 4,946 Bytes
b68816f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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