File size: 17,699 Bytes
a55ea57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
"""
File: /requests.py
Created Date: Monday October 20th 2025
Author: Christian Nonis <alch.infoemail@gmail.com>
-----
Last Modified: Sunday April 12th 2026 1:35:36 pm
Modified By: Christian Nonis <alch.infoemail@gmail.com>
-----
"""

from typing import Any, Dict, List, Literal, Optional, Tuple, Union

from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator

from src.constants.data import Observation, PartialNode, PartialPredicate, TextChunk
from src.constants.kg import (
    EntitySynergy,
    IdentificationParams,
    Node,
    Predicate,
    Relationship,
)
from src.constants.tasks.ingestion import IngestionTaskArgs
from src.core.search.entity_info import MatchPath


class IngestionRequestBody(IngestionTaskArgs):
    """
    Request body for the ingestion endpoint.
    """


class IngestionStructuredDataElement(BaseModel):
    """
    Element for the structured ingestion endpoint.
    """

    json_data: dict = Field(
        default={},
        description="The data rapresenting the structured element.",
    )
    metadata: Optional[dict] = Field(
        default={},
        description="The metadata of the structured element. The information here will be appended to the entity but not analyzed.",
    )
    types: List[str] = Field(
        default=[],
        description="A list of types, used to categorize the data.",
    )
    identification_params: Optional[IdentificationParams] = Field(
        default=None,
        description="The parameters used to identify the structured element.",
    )
    textual_data: Optional[dict] = Field(
        default={},
        description="The textual descriptive data rapresenting the structured element. Could be a description, a summary, some notes, etc.",
    )


class RequestPartialNode(PartialNode):
    uuid: Optional[str] = Field(None, description="The id of the node.")
    type: str = Field(description="The type of the node.")
    labels: Optional[List[str]] = Field(
        default=None,
        description="Optional labels; defaults to [type] when omitted.",
    )
    description: Optional[str] = Field(
        default=None,
        description="The description of the node.",
    )
    properties: Optional[dict] = Field(
        default_factory=dict,
        description="The properties of the node.",
    )
    happened_at: Optional[str] = Field(
        None,
        description="The date and time the node happened at if known otherwise None. Mostly used for event nodes.",
    )

    @model_validator(mode="after")
    def default_labels_from_type(self):
        if not self.labels:
            self.labels = [self.type]
        if self.properties is None:
            self.properties = {}
        return self


class RequestPartialPredicate(PartialPredicate):
    uuid: Optional[str] = Field(None, description="The id of the relationship.")
    description: Optional[str] = Field(
        default=None,
        description="The description of the relationship.",
    )
    amount: Optional[Union[int, float]] = Field(
        None,
        description="The amount of the relationship, if it is a quantitative relationship.",
    )


class IngestionTripleSet(BaseModel):
    subject: Optional[RequestPartialNode] = Field(
        None,
        description="The optional actor of the triple.",
    )
    subj_event: Optional[RequestPartialPredicate] = Field(
        None,
        description="The optional event predicate of the triple.",
    )
    event: Optional[RequestPartialNode] = Field(
        None,
        description="The optional event node. Omit for a direct subject-predicate-object edge.",
    )
    event_obj: Optional[RequestPartialPredicate] = Field(
        None,
        description="The optional event-to-object predicate. Paired with event when present.",
    )
    object: RequestPartialNode

    @model_validator(mode="after")
    def require_event_pair_or_direct_edge(self):
        has_event = self.event is not None
        has_event_obj = self.event_obj is not None
        if has_event != has_event_obj:
            raise ValueError("event and event_obj must both be set or both omitted")
        if has_event:
            return self
        if not self.subject or not self.subj_event:
            raise ValueError(
                "direct triples require subject, subj_event, and object"
            )
        return self


class PartialNodeFilter(BaseModel):
    """
    Partial node filter model.
    """

    name: Optional[str] = Field(
        None,
        description="The name of the node to filter by.",
    )
    type: Optional[str] = Field(
        None,
        description="The type of the node to filter by.",
    )
    uuid: Optional[str] = Field(
        None,
        description="The uuid of the node to filter by.",
    )
    meta_description: Optional[str] = Field(
        None,
        description="An extra description that explains what this node is about, helpful for entity resolution.",
    )

    @model_validator(mode="after")
    def require_uuid_or_name_and_type(self):
        if self.uuid:
            return self
        if self.name and self.type:
            return self
        raise ValueError(
            "Anchor must include either uuid, or both name and type."
        )


class IngestionStructuredRequestBody(BaseModel):
    """
    Request body for the structured ingestion endpoint.
    """

    data: List[IngestionTripleSet] = Field(
        ...,
        description="The list of event-centric information triples to ingest.",
    )
    anchor: Optional[PartialNodeFilter] = Field(
        None,
        description="The related information to connect the structured data to.",
    )
    text: Optional[str] = Field(
        default=None,
        description="Additional text context for the structured data.",
    )
    mode: Optional[Literal["deterministic", "hybrid", "enrich"]] = Field(
        default=None,
        description=(
            "Ingest mode. deterministic: no LLM (triples only; UUID anchor or "
            "exact name+type match). hybrid: persist submitted triples then "
            "optionally enrich from text. enrich: same as hybrid with LLM "
            "backfill. When omitted, inferred as hybrid if text is set else "
            "deterministic."
        ),
    )
    brain_id: str = Field(
        default="default", description="The brain identifier to store the data in."
    )

    @field_validator("anchor", mode="before")
    @classmethod
    def reject_string_anchor(cls, value):
        if isinstance(value, str):
            raise ValueError(
                "Anchor must be an object with uuid or name+type, not a string."
            )
        return value

    def resolved_mode(self) -> Literal["deterministic", "hybrid", "enrich"]:
        if self.mode is not None:
            return self.mode
        return "hybrid" if self.text else "deterministic"


class RetrieveRequestResponse(BaseModel):
    """
    Response for the retrieve endpoint.
    """

    data: List[TextChunk]
    observations: List[Observation]
    relationships: List[dict]

    @field_serializer("relationships", when_used="json")
    def _serialize_relationships(self, value: List[Any]):
        try:
            from neo4j.graph import (
                Node as NeoNode,
            )
            from neo4j.graph import (
                Path as NeoPath,
            )
            from neo4j.graph import (
                Relationship as NeoRel,
            )
        except Exception:
            NeoNode = NeoRel = NeoPath = tuple()
        from src.constants.kg import Node as KGNode

        def _ser(obj):
            if obj is None:
                return None
            if isinstance(obj, (str, int, float, bool)):
                return obj
            if isinstance(obj, KGNode):
                return obj.model_dump(mode="json")
            if isinstance(obj, dict):
                return {k: _ser(v) for k, v in obj.items()}
            if isinstance(obj, (list, tuple, set)):
                return [_ser(v) for v in obj]
            if NeoNode and isinstance(obj, NeoNode):
                d = dict(obj)
                labels = getattr(obj, "labels", None)
                if labels is not None:
                    d["_labels"] = list(labels)
                return d
            if NeoRel and isinstance(obj, NeoRel):
                d = dict(obj)
                rel_type = getattr(obj, "type", None)
                if rel_type is not None:
                    d["_type"] = rel_type
                try:
                    nodes_attr = getattr(obj, "nodes", None)
                    if nodes_attr:
                        d["_start_id"] = getattr(nodes_attr[0], "id", None)
                        d["_end_id"] = getattr(nodes_attr[1], "id", None)
                except Exception:
                    pass
                return d
            if NeoPath and isinstance(obj, NeoPath):
                return {
                    "nodes": [_ser(n) for n in obj.nodes],
                    "relationships": [_ser(r) for r in obj.relationships],
                }
            return str(obj)

        return [_ser(v) for v in value] if value is not None else []


class RetrievedNeighborNode(BaseModel):
    """
    Node model for the retrieve neighbors endpoint.
    """

    neighbor: Node
    relationship: Predicate
    most_common: Node
    similarity_score: float = Field(
        0.0, description="The similarity score of the neighbor to the main node."
    )


class RetrieveNeighborsRequestResponse(BaseModel):
    """
    Response for the retrieve neighbors endpoint.
    """

    count: int = Field(0, description="The number of neighbors found.")
    main_node: Node = Field(..., description="The main node of the neighbors.")
    neighbors: List[RetrievedNeighborNode]


class RetrieveNeighborsAiModeRequestBody(BaseModel):
    """
    Request body for the retrieve neighbors AI mode endpoint.
    """

    identification_params: IdentificationParams = Field(
        ...,
        description="The identification parameters of the entity to get neighbors for.",
    )
    limit: int = Field(10, description="The number of neighbors to return.")
    looking_for: Optional[list[str]] = Field(
        ...,
        description="The description of the neighbors to look for.",
    )
    brain_id: str = Field(
        default="default", description="The brain identifier to store the data in."
    )


class RetrieveNeighborsWithIdentificationParamsRequestBody(BaseModel):
    """
    Request body for the retrieve neighbors with identification params endpoint.
    """

    identification_params: IdentificationParams = Field(
        ...,
        description="The identification parameters of the entity to get neighbors for.",
    )
    limit: int = Field(10, description="The number of neighbors to return.")
    brain_id: str = Field(
        default="default", description="The brain identifier to store the data in."
    )
    look_for: Optional[str] = Field(
        None, description="Optional filter for what type of neighbors to look for."
    )


class CreateBrainRequest(BaseModel):
    """
    Request body for the create brain endpoint.
    """

    brain_id: str


class AddEntityRequest(BaseModel):
    """Request model for adding a new entity to the graph."""

    name: str
    brain_id: str = "default"
    labels: list[str] = []
    description: Optional[str] = None
    properties: Optional[dict] = None
    identification_params: Optional[dict] = None
    metadata: Optional[dict] = None


class UpdateEntityRequest(BaseModel):
    """Request model for updating an existing entity in the graph."""

    uuid: str
    brain_id: str = "default"
    new_name: Optional[str] = None
    new_description: Optional[str] = None
    new_labels: Optional[list[str]] = None
    new_properties: Optional[dict] = None
    properties_to_remove: Optional[list[str]] = None


class AddRelationshipRequest(BaseModel):
    """Request model for adding a new relationship between two entities."""

    subject_uuid: str
    predicate_name: str
    predicate_description: str
    object_uuid: str
    brain_id: str = "default"


class UpdateRelationshipRequest(BaseModel):
    """Request model for updating an existing relationship's properties."""

    uuid: str
    brain_id: str = "default"
    new_properties: Optional[dict] = None
    properties_to_remove: Optional[list[str]] = None


class GetEntityInfoResponse(BaseModel):
    """Response model for the get entity info endpoint."""

    target_node: Optional[Node] = None
    path: MatchPath


class GetEntityContextResponse(BaseModel):
    """Response model for the get entity context endpoint."""

    neighborhood: list[dict]
    target_node: Optional[Node] = None
    text_contexts: list[str] = []
    natural_language_web: list[dict] = []


class GetEntitySibilingsResponse(BaseModel):
    """Response model for the get entity siblings endpoint."""

    target_node: Node
    synergies: List[EntitySynergy]
    anchors: Optional[List[Node]] = None
    potential_anchors: Optional[List[Node]] = None


class RecommendRequestBody(BaseModel):
    """Request body for event-graph recommendations."""

    target: str
    brain_id: str = "default"
    polarity: Literal["same", "opposite"] = "same"
    top_k: int = Field(20, ge=1, le=200)
    labels: Optional[List[str]] = None
    include_asymmetric: bool = True
    include_multi_interest: bool = True
    include_attribute_pref: bool = False
    diversify: bool = True
    asymmetric_direction: Literal["outbound", "inbound", "both"] = "outbound"
    exclude_seen: bool = False
    recency_half_life_days: Optional[float] = Field(None, ge=0)
    dampen_degree: bool = False
    behavior_weights: Optional[Dict[str, float]] = None


class RecommendItem(BaseModel):
    node: Node
    score: float
    connected_by: List[Node]
    channel: str


class RecommendResponse(BaseModel):
    target_node: Node
    recommendations: List[RecommendItem]


class GetEntityStatusResponse(BaseModel):
    """Response model for the get entity status endpoint."""

    node: Optional[Node] = None
    exists: bool
    has_relationships: bool
    relationships: List[Tuple[Predicate, Node]]
    observations: List[Observation]


class GetContextRequestBody(BaseModel):
    """Request body for the get context endpoint."""

    text: str
    brain_id: str = "default"
    historical_limit: int = 10
    max_facts: int = Field(40, ge=0)
    max_passages: int = 8
    apply_fact_filter: bool = True
    use_ppr: bool = True
    sufficiency_retry: bool = False
    profile_stages: bool = False
    cross_event_bridges: int = Field(3, ge=0)


class GetContextTriple(BaseModel):
    """Triple for the get context endpoint."""

    identified_entity: str
    triple: Tuple[Node, Predicate, Node, Predicate, Node]
    source_chunk_ids: Optional[List[str]] = None
    source_session_ids: Optional[List[str]] = None


class GetContextResponse(BaseModel):
    """Response for the get context endpoint."""

    text_context: str
    triples: List[GetContextTriple]
    historical_context: List[str] = []
    source_passages: List[str] = []
    graph_session_ids: Optional[List[str]] = None
    temporal_conflicts: Optional[List[dict[str, Any]]] = None
    paths: Optional[List[dict[str, Any]]] = None
    topics: Optional[List[dict[str, Any]]] = None
    stage_timings: Optional[dict[str, Any]] = None


class SearchRequestBody(BaseModel):
    query: str
    brain_id: str = "default"
    k: int = Field(10, ge=1, le=200)
    channels: List[str] = Field(default_factory=lambda: ["passages"])
    node_labels: Optional[List[str]] = None
    community_labels: Optional[List[str]] = None
    expand: Literal["none", "neighbors"] = "none"
    fusion: Optional[Literal["rrf", "cc"]] = None
    fusion_alpha: Optional[float] = Field(default=None, ge=0.0, le=1.0)
    rerank: Optional[str] = Field(
        default=None,
        description="none or plugin:<name>. Unknown plugin names return 400.",
    )
    mode: Literal["default", "catalog"] = "default"
    profile_stages: bool = False
    extras: Optional[Dict[str, str]] = None
    target: Optional[str] = None

    @field_validator("target", mode="before")
    @classmethod
    def empty_target(cls, value):
        if value is None:
            return None
        text = str(value).strip()
        return text or None

    @field_validator("extras", mode="before")
    @classmethod
    def coerce_extras_filter(cls, value):
        if value is None or value == {}:
            return None
        if not isinstance(value, dict):
            raise ValueError("extras must be an object of string keys and values")
        return {
            str(key): str(item)
            for key, item in value.items()
            if item is not None
        } or None


class SearchHitScores(BaseModel):
    bm25: Optional[float] = None
    dense: Optional[float] = None
    rrf: Optional[float] = None
    cc: Optional[float] = None
    rerank: Optional[float] = None
    plugin: Optional[dict[str, float]] = None
    graph: Optional[float] = None
    personalize: Optional[float] = None


class SearchHit(BaseModel):
    id: str
    channel: str
    score: float
    scores: SearchHitScores
    snippet: str
    labels: List[str] = Field(default_factory=list)
    extras: Optional[dict[str, Any]] = None
    node_id: Optional[str] = None


class SearchResponse(BaseModel):
    hits: List[SearchHit]
    stage_timings: Optional[dict[str, Any]] = None
    channel_lists: Optional[dict[str, list[str]]] = None
    facets: Optional[dict[str, dict[str, int]]] = None
    node_ids: List[str] = Field(default_factory=list)