|
13 | 13 | from graphene_django.filter import DjangoFilterConnectionField |
14 | 14 | from graphql import GraphQLError |
15 | 15 | from graphql_jwt.decorators import login_required |
16 | | -from graphql_relay import from_global_id |
| 16 | +from graphql_relay import from_global_id, to_global_id |
17 | 17 |
|
18 | 18 | from config.graphql.filters import LabelFilter, LabelsetFilter, RelationshipFilter |
19 | 19 | from config.graphql.graphene_types import ( |
20 | 20 | AnnotationLabelType, |
21 | 21 | AnnotationType, |
| 22 | + CorpusReferenceType, |
| 23 | + GovernanceGraphCorpusType, |
| 24 | + GovernanceGraphEdgeType, |
| 25 | + GovernanceGraphNodeType, |
| 26 | + GovernanceGraphType, |
22 | 27 | LabelSetType, |
23 | 28 | NoteType, |
24 | 29 | PageAwareAnnotationType, |
|
37 | 42 | DOCUMENT_ANNOTATION_INDEX_LIMIT, |
38 | 43 | MANUAL_ANNOTATION_SENTINEL, |
39 | 44 | ) |
| 45 | +from opencontractserver.constants.stats import GOVERNANCE_GRAPH_MAX_NODES |
40 | 46 | from opencontractserver.documents.models import Document |
| 47 | +from opencontractserver.enrichment import constants as enrichment_constants |
41 | 48 | from opencontractserver.shared.services.base import BaseService |
42 | 49 | from opencontractserver.types.enums import LabelType |
43 | 50 |
|
|
47 | 54 | class AnnotationQueryMixin: |
48 | 55 | """Query fields and resolvers for annotation, relationship, label, labelset, and note queries.""" |
49 | 56 |
|
| 57 | + # CORPUS REFERENCE RESOLVERS ############################### |
| 58 | + corpus_references = DjangoConnectionField( |
| 59 | + CorpusReferenceType, |
| 60 | + corpus_id=graphene.ID(required=True), |
| 61 | + reference_type=graphene.String(), |
| 62 | + canonical_key=graphene.String(), |
| 63 | + ) |
| 64 | + |
| 65 | + def resolve_corpus_references(self, info, corpus_id, **kwargs) -> Any: |
| 66 | + """List enrichment cross-references for a corpus the user can read. |
| 67 | +
|
| 68 | + Visibility is enforced by ``CorpusReferenceService`` (corpus-derived); |
| 69 | + no inline Tier-0 permission fusion here. |
| 70 | + """ |
| 71 | + from opencontractserver.annotations.models import CorpusReference |
| 72 | + from opencontractserver.enrichment.services import CorpusReferenceService |
| 73 | + |
| 74 | + pk_str = from_global_id(corpus_id)[1] |
| 75 | + if not str(pk_str).isdigit(): |
| 76 | + return CorpusReference.objects.none() |
| 77 | + pk = int(pk_str) |
| 78 | + qs = CorpusReferenceService.for_corpus(info.context.user, pk) |
| 79 | + if kwargs.get("reference_type"): |
| 80 | + qs = qs.filter(reference_type=kwargs["reference_type"]) |
| 81 | + if kwargs.get("canonical_key"): |
| 82 | + qs = qs.filter(canonical_key=kwargs["canonical_key"]) |
| 83 | + # Pull the FK targets the type resolves in one pass — without this each |
| 84 | + # CorpusReferenceType row fires a separate query per FK (N+1). |
| 85 | + return qs.select_related( |
| 86 | + "source_annotation", |
| 87 | + "corpus", |
| 88 | + "target_document", |
| 89 | + "target_annotation", |
| 90 | + "target_corpus", |
| 91 | + ) |
| 92 | + |
| 93 | + governance_graph = graphene.Field( |
| 94 | + GovernanceGraphType, |
| 95 | + corpus_id=graphene.ID(required=True), |
| 96 | + limit=graphene.Int(required=False), |
| 97 | + description=( |
| 98 | + "The corpus-scoped reference web in node-link form: documents, " |
| 99 | + "statute sections, and external-citation ghost nodes, with " |
| 100 | + "mention-weighted LAW / LAW_EXTERNAL / DOCUMENT edges. Powers the " |
| 101 | + "Governance Graph panel on the Corpus Intelligence home." |
| 102 | + ), |
| 103 | + ) |
| 104 | + |
| 105 | + @graphql_ratelimit_dynamic(get_rate=get_user_tier_rate("READ_MEDIUM")) |
| 106 | + def resolve_governance_graph(self, info, corpus_id, limit=None) -> Any: |
| 107 | + """Build the governance graph through ``GovernanceGraphService``. |
| 108 | +
|
| 109 | + All visibility decisions (corpus READ gate, per-document READ checks, |
| 110 | + ghost degradation for invisible targets) live in the service; this |
| 111 | + resolver only translates raw PKs / canonical keys into relay ids. |
| 112 | + """ |
| 113 | + # Function-local service import (services import GraphQL types |
| 114 | + # transitively — module-level import would cycle). |
| 115 | + from opencontractserver.enrichment.services import GovernanceGraphService |
| 116 | + |
| 117 | + empty = GovernanceGraphType( |
| 118 | + corpora=[], |
| 119 | + nodes=[], |
| 120 | + edges=[], |
| 121 | + document_count=0, |
| 122 | + external_key_count=0, |
| 123 | + edge_count=0, |
| 124 | + mention_count=0, |
| 125 | + truncated=False, |
| 126 | + ) |
| 127 | + |
| 128 | + corpus_pk = from_global_id(corpus_id)[1] |
| 129 | + # Malformed/empty global ids decode to a non-numeric pk; treat as |
| 130 | + # not-found (empty graph) rather than erroring. |
| 131 | + if not str(corpus_pk).isdigit(): |
| 132 | + return empty |
| 133 | + |
| 134 | + node_cap = GOVERNANCE_GRAPH_MAX_NODES |
| 135 | + if limit is not None and 0 < limit < node_cap: |
| 136 | + node_cap = limit |
| 137 | + |
| 138 | + data = GovernanceGraphService.build( |
| 139 | + info.context.user, int(corpus_pk), node_cap, request=info.context |
| 140 | + ) |
| 141 | + if data is None: |
| 142 | + return empty |
| 143 | + |
| 144 | + def _node_id(endpoint) -> str: |
| 145 | + kind, val = endpoint |
| 146 | + if kind == "doc": |
| 147 | + return to_global_id("DocumentType", val) |
| 148 | + return f"key:{val}" |
| 149 | + |
| 150 | + nodes = [ |
| 151 | + GovernanceGraphNodeType( |
| 152 | + id=to_global_id("DocumentType", n["doc_pk"]), |
| 153 | + document_id=to_global_id("DocumentType", n["doc_pk"]), |
| 154 | + title=n["title"], |
| 155 | + kind=n["kind"], |
| 156 | + corpus_id=( |
| 157 | + to_global_id("CorpusType", n["corpus_pk"]) |
| 158 | + if n["corpus_pk"] |
| 159 | + else None |
| 160 | + ), |
| 161 | + authority=n["authority"], |
| 162 | + degree=n["degree"], |
| 163 | + ) |
| 164 | + for n in data["doc_nodes"] |
| 165 | + ] + [ |
| 166 | + GovernanceGraphNodeType( |
| 167 | + id=f"key:{g['key']}", |
| 168 | + document_id=None, |
| 169 | + title=g["key"], |
| 170 | + kind=enrichment_constants.GRAPH_NODE_EXTERNAL, |
| 171 | + corpus_id=None, |
| 172 | + authority=g["authority"], |
| 173 | + degree=g["degree"], |
| 174 | + ) |
| 175 | + for g in data["ghost_nodes"] |
| 176 | + ] |
| 177 | + |
| 178 | + return GovernanceGraphType( |
| 179 | + corpora=[ |
| 180 | + GovernanceGraphCorpusType( |
| 181 | + id=to_global_id("CorpusType", c["corpus_pk"]), |
| 182 | + title=c["title"], |
| 183 | + kind=c["kind"], |
| 184 | + ) |
| 185 | + for c in data["corpora"] |
| 186 | + ], |
| 187 | + nodes=nodes, |
| 188 | + edges=[ |
| 189 | + GovernanceGraphEdgeType( |
| 190 | + source=_node_id(e["source"]), |
| 191 | + target=_node_id(e["target"]), |
| 192 | + edge_type=e["edge_type"], |
| 193 | + weight=e["weight"], |
| 194 | + ) |
| 195 | + for e in data["edges"] |
| 196 | + ], |
| 197 | + document_count=data["document_count"], |
| 198 | + external_key_count=data["external_key_count"], |
| 199 | + edge_count=data["edge_count"], |
| 200 | + mention_count=data["mention_count"], |
| 201 | + truncated=data["truncated"], |
| 202 | + ) |
| 203 | + |
50 | 204 | # ANNOTATION RESOLVERS ##################################### |
51 | 205 | annotations = DjangoConnectionField( |
52 | 206 | AnnotationType, |
|
0 commit comments