memoryintelligence 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,688 @@
1
+ """
2
+ Memory Intelligence SDK - Client
3
+ ================================
4
+
5
+ The main client for interacting with Memory Intelligence.
6
+ Designed around the six core problem statements:
7
+
8
+ 1. Privacy-Personalization Paradox → retention_policy="meaning_only"
9
+ 2. Black Box Accountability → explain=True on all operations
10
+ 3. Proprietary Data Paralysis → EdgeClient for on-prem
11
+ 4. Context Cost Catastrophe → Compressed meaning retrieval
12
+ 5. Knowledge Silo Epidemic → Scope isolation with cryptographic boundaries
13
+ 6. Attribution Extinction → provenance_mode="authorship"
14
+
15
+ Usage:
16
+ from memoryintelligence import MemoryClient
17
+
18
+ mi = MemoryClient(api_key="mi_sk_...")
19
+
20
+ # Process content → meaning
21
+ umo = mi.process("Meeting notes from today", user_ulid="01ABC...")
22
+
23
+ # Search meaning
24
+ results = mi.search("What did we discuss?", user_ulid="01ABC...")
25
+
26
+ # Match for recommendations
27
+ score = mi.match(user_ulid, candidate_ulid, explain=True)
28
+
29
+ # Delete for GDPR
30
+ mi.delete(user_ulid="01ABC...")
31
+
32
+ Author: Memory Intelligence Team
33
+ Version: 1.0.0
34
+ """
35
+
36
+ import httpx
37
+ import logging
38
+ from datetime import datetime
39
+ from typing import Any, Dict, List, Optional, Union
40
+ from pathlib import Path
41
+
42
+ from .mi_types import (
43
+ # Enums
44
+ Scope,
45
+ RetentionPolicy,
46
+ PIIHandling,
47
+ ProvenanceMode,
48
+ ExplainLevel,
49
+ # Response types
50
+ MeaningObject,
51
+ SearchResponse,
52
+ SearchResult,
53
+ MatchResult,
54
+ DeleteResult,
55
+ VerifyResult,
56
+ Explanation,
57
+ ExplainHuman,
58
+ ExplainAudit,
59
+ Entity,
60
+ Topic,
61
+ SVOTriple,
62
+ Provenance,
63
+ PIIDetection,
64
+ # Config types
65
+ ProcessConfig,
66
+ SearchConfig,
67
+ MatchConfig,
68
+ # Errors
69
+ MIError,
70
+ AuthenticationError,
71
+ RateLimitError,
72
+ ScopeViolationError,
73
+ PIIViolationError,
74
+ GovernanceError,
75
+ )
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ # ============================================================================
81
+ # API Configuration
82
+ # ============================================================================
83
+
84
+ DEFAULT_API_URL = "https://api.memoryintelligence.dev"
85
+ DEFAULT_TIMEOUT = 30.0
86
+ SDK_VERSION = "1.0.0"
87
+
88
+
89
+ # ============================================================================
90
+ # Main Client
91
+ # ============================================================================
92
+
93
+ class MemoryClient:
94
+ """
95
+ Main client for Memory Intelligence SDK.
96
+
97
+ Provides six core operations aligned to developer pain points:
98
+
99
+ - process() → Convert raw content to meaning (Privacy-Personalization)
100
+ - search() → Find relevant memories (Context Cost)
101
+ - match() → Compare memories for relevance (Personalization)
102
+ - explain() → Get explanation for any UMO (Black Box)
103
+ - delete() → Remove all user data (Compliance)
104
+ - verify_provenance() → Verify authorship/timestamp (Attribution)
105
+
106
+ Args:
107
+ api_key: Your MI API key (starts with "mi_sk_")
108
+ org_ulid: Organization ULID (optional, derived from key)
109
+ base_url: API endpoint (default: production)
110
+ timeout: Request timeout in seconds
111
+
112
+ Example:
113
+ mi = MemoryClient(api_key="mi_sk_live_abc123...")
114
+
115
+ # Process with meaning-only retention (default)
116
+ umo = mi.process("Important insight", user_ulid="01ABC...")
117
+
118
+ # Search with explanation
119
+ results = mi.search("What do I know about X?", user_ulid="01ABC...", explain=True)
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ api_key: str,
125
+ org_ulid: Optional[str] = None,
126
+ base_url: str = DEFAULT_API_URL,
127
+ timeout: float = DEFAULT_TIMEOUT,
128
+ ):
129
+ if not api_key or not api_key.startswith("mi_sk_"):
130
+ raise AuthenticationError(
131
+ "Invalid API key format. Keys should start with 'mi_sk_'"
132
+ )
133
+
134
+ self.api_key = api_key
135
+ self.org_ulid = org_ulid
136
+ self.base_url = base_url.rstrip("/")
137
+ self.timeout = timeout
138
+
139
+ # HTTP client with auth headers
140
+ self._client = httpx.Client(
141
+ base_url=self.base_url,
142
+ timeout=self.timeout,
143
+ headers={
144
+ "Authorization": f"Bearer {self.api_key}",
145
+ "X-MI-SDK-Version": SDK_VERSION,
146
+ "Content-Type": "application/json",
147
+ }
148
+ )
149
+
150
+ logger.info(f"MemoryClient initialized (org={org_ulid})")
151
+
152
+ def __enter__(self):
153
+ return self
154
+
155
+ def __exit__(self, exc_type, exc_val, exc_tb):
156
+ self.close()
157
+
158
+ def close(self):
159
+ """Close the HTTP client."""
160
+ self._client.close()
161
+
162
+ # ========================================================================
163
+ # CORE OPERATION 1: PROCESS
164
+ # ========================================================================
165
+
166
+ def process(
167
+ self,
168
+ content: str,
169
+ user_ulid: str,
170
+ *,
171
+ retention_policy: RetentionPolicy = RetentionPolicy.MEANING_ONLY,
172
+ pii_handling: PIIHandling = PIIHandling.EXTRACT_AND_REDACT,
173
+ provenance_mode: ProvenanceMode = ProvenanceMode.STANDARD,
174
+ scope: Scope = Scope.USER,
175
+ scope_id: Optional[str] = None,
176
+ source: str = "api",
177
+ metadata: Optional[Dict[str, Any]] = None,
178
+ ) -> MeaningObject:
179
+ """
180
+ Process raw content into a Meaning Object.
181
+
182
+ This is the core operation: content goes in, meaning comes out,
183
+ raw content is discarded (by default).
184
+
185
+ Args:
186
+ content: Raw text content to process
187
+ user_ulid: Owner's ULID
188
+ retention_policy: What to retain (default: meaning_only)
189
+ pii_handling: How to handle PII (default: extract_and_redact)
190
+ provenance_mode: Provenance tracking level (default: standard)
191
+ scope: Governance scope (default: user)
192
+ scope_id: Scope identifier (e.g., client_ulid for Scope.CLIENT)
193
+ source: Source identifier (e.g., "slack", "email")
194
+ metadata: Additional context
195
+
196
+ Returns:
197
+ MeaningObject with entities, topics, embedding, provenance
198
+
199
+ Raises:
200
+ PIIViolationError: If PII detected and pii_handling=REJECT
201
+ ScopeViolationError: If scope_id required but not provided
202
+ AuthenticationError: Invalid API key
203
+ RateLimitError: Rate limit exceeded
204
+
205
+ Example:
206
+ # Basic processing (meaning-only, PII redacted)
207
+ umo = mi.process("Sarah said budget is approved", user_ulid="01ABC...")
208
+
209
+ # With client scope isolation
210
+ umo = mi.process(
211
+ meeting_notes,
212
+ user_ulid="01ABC...",
213
+ scope=Scope.CLIENT,
214
+ scope_id=client_ulid
215
+ )
216
+
217
+ # With authorship provenance
218
+ umo = mi.process(
219
+ article_text,
220
+ user_ulid="01ABC...",
221
+ provenance_mode=ProvenanceMode.AUTHORSHIP
222
+ )
223
+ """
224
+ # Validate scope_id requirement
225
+ if scope in (Scope.CLIENT, Scope.PROJECT, Scope.TEAM) and not scope_id:
226
+ raise ScopeViolationError(
227
+ f"scope_id required for {scope.value} scope"
228
+ )
229
+
230
+ payload = {
231
+ "content": content,
232
+ "user_ulid": user_ulid,
233
+ "retention_policy": retention_policy.value,
234
+ "pii_handling": pii_handling.value,
235
+ "provenance_mode": provenance_mode.value,
236
+ "scope": scope.value,
237
+ "scope_id": scope_id,
238
+ "source": source,
239
+ "metadata": metadata or {},
240
+ }
241
+
242
+ if self.org_ulid:
243
+ payload["org_ulid"] = self.org_ulid
244
+
245
+ response = self._request("POST", "/v1/process", json=payload)
246
+ return self._parse_meaning_object(response)
247
+
248
+ # ========================================================================
249
+ # CORE OPERATION 2: SEARCH
250
+ # ========================================================================
251
+
252
+ def search(
253
+ self,
254
+ query: str,
255
+ user_ulid: str,
256
+ *,
257
+ scope: Scope = Scope.USER,
258
+ scope_id: Optional[str] = None,
259
+ explain: Union[bool, ExplainLevel] = False,
260
+ limit: int = 10,
261
+ offset: int = 0,
262
+ date_from: Optional[datetime] = None,
263
+ date_to: Optional[datetime] = None,
264
+ topics: Optional[List[str]] = None,
265
+ entities: Optional[List[str]] = None,
266
+ budget_tokens: Optional[int] = None,
267
+ ) -> SearchResponse:
268
+ """
269
+ Search for relevant meaning objects.
270
+
271
+ Multi-signal ranking: semantic + temporal + entity + graph.
272
+ Returns compressed meaning (95% smaller than raw).
273
+
274
+ Args:
275
+ query: Natural language search query
276
+ user_ulid: Searcher's ULID
277
+ scope: Search scope (default: user)
278
+ scope_id: Scope identifier for scoped searches
279
+ explain: Include explanation (default: False)
280
+ limit: Maximum results (default: 10)
281
+ offset: Pagination offset
282
+ date_from: Filter by date range start
283
+ date_to: Filter by date range end
284
+ topics: Filter by topics
285
+ entities: Filter by entities
286
+ budget_tokens: Maximum tokens in response (for cost control)
287
+
288
+ Returns:
289
+ SearchResponse with ranked results and explanations
290
+
291
+ Example:
292
+ # Basic search
293
+ results = mi.search("budget discussions", user_ulid="01ABC...")
294
+
295
+ # Scoped search with explanation
296
+ results = mi.search(
297
+ "What are Acme's concerns?",
298
+ user_ulid="01ABC...",
299
+ scope=Scope.CLIENT,
300
+ scope_id=acme_ulid,
301
+ explain=True
302
+ )
303
+
304
+ # Token-budgeted search
305
+ results = mi.search(
306
+ "summarize last month",
307
+ user_ulid="01ABC...",
308
+ budget_tokens=500
309
+ )
310
+ """
311
+ if scope in (Scope.CLIENT, Scope.PROJECT, Scope.TEAM) and not scope_id:
312
+ raise ScopeViolationError(
313
+ f"scope_id required for {scope.value} scope"
314
+ )
315
+
316
+ # Normalize explain parameter
317
+ if isinstance(explain, bool):
318
+ explain_level = ExplainLevel.FULL if explain else ExplainLevel.NONE
319
+ else:
320
+ explain_level = explain
321
+
322
+ payload = {
323
+ "query": query,
324
+ "user_ulid": user_ulid,
325
+ "scope": scope.value,
326
+ "scope_id": scope_id,
327
+ "explain": explain_level.value,
328
+ "limit": limit,
329
+ "offset": offset,
330
+ "budget_tokens": budget_tokens,
331
+ }
332
+
333
+ if date_from:
334
+ payload["date_from"] = date_from.isoformat()
335
+ if date_to:
336
+ payload["date_to"] = date_to.isoformat()
337
+ if topics:
338
+ payload["topics"] = topics
339
+ if entities:
340
+ payload["entities"] = entities
341
+ if self.org_ulid:
342
+ payload["org_ulid"] = self.org_ulid
343
+
344
+ response = self._request("POST", "/v1/search", json=payload)
345
+ return self._parse_search_response(response, query, scope)
346
+
347
+ # ========================================================================
348
+ # CORE OPERATION 3: MATCH
349
+ # ========================================================================
350
+
351
+ def match(
352
+ self,
353
+ source_ulid: str,
354
+ candidate_ulid: str,
355
+ *,
356
+ explain: Union[bool, ExplainLevel] = False,
357
+ threshold: float = 0.7,
358
+ ) -> MatchResult:
359
+ """
360
+ Compare two meaning objects for relevance.
361
+
362
+ Used for recommendations: "Is this content relevant to this user?"
363
+
364
+ Args:
365
+ source_ulid: User or memory ULID (the "who")
366
+ candidate_ulid: Candidate memory ULID (the "what")
367
+ explain: Include explanation (default: False)
368
+ threshold: Match threshold (default: 0.7)
369
+
370
+ Returns:
371
+ MatchResult with score and explanation
372
+
373
+ Example:
374
+ # Check if post is relevant to user
375
+ match = mi.match(user_ulid, post_ulid, explain=True)
376
+
377
+ if match.match:
378
+ print(f"Relevant! {match.explain.human.summary}")
379
+ """
380
+ if isinstance(explain, bool):
381
+ explain_level = ExplainLevel.FULL if explain else ExplainLevel.NONE
382
+ else:
383
+ explain_level = explain
384
+
385
+ payload = {
386
+ "source_ulid": source_ulid,
387
+ "candidate_ulid": candidate_ulid,
388
+ "explain": explain_level.value,
389
+ "threshold": threshold,
390
+ }
391
+
392
+ if self.org_ulid:
393
+ payload["org_ulid"] = self.org_ulid
394
+
395
+ response = self._request("POST", "/v1/match", json=payload)
396
+ return self._parse_match_result(response, source_ulid, candidate_ulid)
397
+
398
+ # ========================================================================
399
+ # CORE OPERATION 4: EXPLAIN
400
+ # ========================================================================
401
+
402
+ def explain(
403
+ self,
404
+ umo_id: str,
405
+ *,
406
+ level: ExplainLevel = ExplainLevel.FULL,
407
+ ) -> Explanation:
408
+ """
409
+ Get detailed explanation for any meaning object.
410
+
411
+ Returns human-readable + machine-verifiable explanation.
412
+
413
+ Args:
414
+ umo_id: ULID of the meaning object
415
+ level: Explanation level (default: full)
416
+
417
+ Returns:
418
+ Explanation with human and audit components
419
+
420
+ Example:
421
+ # Get explanation for a recommendation
422
+ explanation = mi.explain(umo_id)
423
+ print(explanation.human.summary)
424
+ print(explanation.audit.hash_chain)
425
+ """
426
+ payload = {
427
+ "umo_id": umo_id,
428
+ "level": level.value,
429
+ }
430
+
431
+ if self.org_ulid:
432
+ payload["org_ulid"] = self.org_ulid
433
+
434
+ response = self._request("GET", f"/v1/explain/{umo_id}", params=payload)
435
+ return self._parse_explanation(response)
436
+
437
+ # ========================================================================
438
+ # CORE OPERATION 5: DELETE
439
+ # ========================================================================
440
+
441
+ def delete(
442
+ self,
443
+ user_ulid: str,
444
+ *,
445
+ scope: Scope = Scope.ALL,
446
+ scope_id: Optional[str] = None,
447
+ ) -> DeleteResult:
448
+ """
449
+ Delete all meaning for a user/scope.
450
+
451
+ GDPR compliance in one API call. Provenance proves deletion.
452
+
453
+ Args:
454
+ user_ulid: User whose data to delete
455
+ scope: Scope to delete (default: ALL)
456
+ scope_id: Specific scope to delete (for partial deletion)
457
+
458
+ Returns:
459
+ DeleteResult with count and audit proof
460
+
461
+ Example:
462
+ # Full GDPR deletion
463
+ result = mi.delete(user_ulid="01ABC...")
464
+ print(f"Deleted {result.deleted_count} memories")
465
+
466
+ # Delete only one client's data
467
+ result = mi.delete(
468
+ user_ulid="01ABC...",
469
+ scope=Scope.CLIENT,
470
+ scope_id=client_ulid
471
+ )
472
+ """
473
+ payload = {
474
+ "user_ulid": user_ulid,
475
+ "scope": scope.value,
476
+ "scope_id": scope_id,
477
+ }
478
+
479
+ if self.org_ulid:
480
+ payload["org_ulid"] = self.org_ulid
481
+
482
+ response = self._request("DELETE", "/v1/delete", json=payload)
483
+
484
+ return DeleteResult(
485
+ deleted_count=response.get("deleted_count", 0),
486
+ user_ulid=user_ulid,
487
+ scope=scope,
488
+ scope_id=scope_id,
489
+ audit_proof=response.get("audit_proof", {}),
490
+ )
491
+
492
+ # ========================================================================
493
+ # CORE OPERATION 6: VERIFY PROVENANCE
494
+ # ========================================================================
495
+
496
+ def verify_provenance(
497
+ self,
498
+ content_hash: str,
499
+ ) -> VerifyResult:
500
+ """
501
+ Verify provenance of content.
502
+
503
+ Check who created content first, verify hash chain integrity.
504
+
505
+ Args:
506
+ content_hash: Semantic hash of content to verify
507
+
508
+ Returns:
509
+ VerifyResult with authorship and timestamp proof
510
+
511
+ Example:
512
+ # Verify authorship before using content
513
+ result = mi.verify_provenance(content_hash)
514
+
515
+ if result.valid:
516
+ print(f"Original author: {result.original_author_ulid}")
517
+ print(f"First published: {result.first_published}")
518
+ """
519
+ response = self._request(
520
+ "GET",
521
+ f"/v1/provenance/verify/{content_hash}"
522
+ )
523
+
524
+ return VerifyResult(
525
+ valid=response.get("valid", False),
526
+ semantic_hash=response.get("semantic_hash", content_hash),
527
+ timestamp_anchor=datetime.fromisoformat(
528
+ response.get("timestamp_anchor", datetime.utcnow().isoformat())
529
+ ),
530
+ original_author_ulid=response.get("original_author_ulid"),
531
+ first_published=datetime.fromisoformat(response["first_published"])
532
+ if response.get("first_published") else None,
533
+ hash_chain_valid=response.get("hash_chain_valid", True),
534
+ audit_proof=response.get("audit_proof", {}),
535
+ )
536
+
537
+ # ========================================================================
538
+ # INTERNAL METHODS
539
+ # ========================================================================
540
+
541
+ def _request(
542
+ self,
543
+ method: str,
544
+ path: str,
545
+ **kwargs
546
+ ) -> Dict[str, Any]:
547
+ """Make authenticated request to MI API."""
548
+ try:
549
+ response = self._client.request(method, path, **kwargs)
550
+
551
+ if response.status_code == 401:
552
+ raise AuthenticationError("Invalid or expired API key")
553
+ elif response.status_code == 429:
554
+ error = RateLimitError("Rate limit exceeded")
555
+ error.retry_after = int(response.headers.get("Retry-After", 60))
556
+ raise error
557
+ elif response.status_code == 403:
558
+ raise ScopeViolationError(
559
+ response.json().get("detail", "Access denied")
560
+ )
561
+
562
+ response.raise_for_status()
563
+ return response.json()
564
+
565
+ except httpx.HTTPStatusError as e:
566
+ raise MIError(f"API error: {e}")
567
+ except httpx.RequestError as e:
568
+ raise MIError(f"Request failed: {e}")
569
+
570
+ def _parse_meaning_object(self, data: Dict[str, Any]) -> MeaningObject:
571
+ """Parse API response into MeaningObject."""
572
+ return MeaningObject(
573
+ umo_id=data["umo_id"],
574
+ user_ulid=data["user_ulid"],
575
+ entities=[
576
+ Entity(
577
+ text=e["text"],
578
+ type=e["type"],
579
+ confidence=e.get("confidence", 1.0),
580
+ first_seen=datetime.fromisoformat(e["first_seen"]) if e.get("first_seen") else None,
581
+ resolved_ulid=e.get("resolved_ulid"),
582
+ )
583
+ for e in data.get("entities", [])
584
+ ],
585
+ topics=[
586
+ Topic(
587
+ name=t["name"],
588
+ confidence=t.get("confidence", 1.0),
589
+ parent=t.get("parent"),
590
+ )
591
+ for t in data.get("topics", [])
592
+ ],
593
+ svo_triples=[
594
+ SVOTriple(
595
+ subject=s["subject"],
596
+ verb=s["verb"],
597
+ object=s["object"],
598
+ confidence=s.get("confidence", 1.0),
599
+ )
600
+ for s in data.get("svo_triples", [])
601
+ ],
602
+ key_phrases=data.get("key_phrases", []),
603
+ summary=data.get("summary"),
604
+ embedding=data.get("embedding"),
605
+ embedding_model=data.get("embedding_model", ""),
606
+ sentiment_label=data.get("sentiment_label"),
607
+ sentiment_score=data.get("sentiment_score", 0.0),
608
+ timestamp=datetime.fromisoformat(data["timestamp"]) if data.get("timestamp") else None,
609
+ ingested_at=datetime.fromisoformat(data.get("ingested_at", datetime.utcnow().isoformat())),
610
+ recency_score=data.get("recency_score", 1.0),
611
+ quality_score=data.get("quality_score", 0.0),
612
+ validation_status=data.get("validation_status", "pending"),
613
+ provenance=Provenance(
614
+ semantic_hash=data["provenance"]["semantic_hash"],
615
+ timestamp_anchor=datetime.fromisoformat(data["provenance"]["timestamp_anchor"]),
616
+ hash_chain=data["provenance"]["hash_chain"],
617
+ lineage=data["provenance"].get("lineage", []),
618
+ model_version=data["provenance"].get("model_version", ""),
619
+ ) if data.get("provenance") else None,
620
+ pii=PIIDetection(
621
+ detected=data["pii"]["detected"],
622
+ types=data["pii"].get("types", []),
623
+ count=data["pii"].get("count", 0),
624
+ handling_applied=PIIHandling(data["pii"].get("handling_applied", "detect_only")),
625
+ ) if data.get("pii") else None,
626
+ scope=Scope(data.get("scope", "user")),
627
+ scope_id=data.get("scope_id"),
628
+ )
629
+
630
+ def _parse_search_response(
631
+ self,
632
+ data: Dict[str, Any],
633
+ query: str,
634
+ scope: Scope
635
+ ) -> SearchResponse:
636
+ """Parse API response into SearchResponse."""
637
+ results = []
638
+ for r in data.get("results", []):
639
+ umo = self._parse_meaning_object(r["umo"])
640
+ explain = self._parse_explanation(r["explain"]) if r.get("explain") else None
641
+ results.append(SearchResult(
642
+ umo=umo,
643
+ score=r["score"],
644
+ explain=explain,
645
+ ))
646
+
647
+ return SearchResponse(
648
+ results=results,
649
+ query=query,
650
+ scope=scope,
651
+ total_count=data.get("total_count", len(results)),
652
+ audit_proof=data.get("audit_proof"),
653
+ )
654
+
655
+ def _parse_match_result(
656
+ self,
657
+ data: Dict[str, Any],
658
+ source_ulid: str,
659
+ candidate_ulid: str
660
+ ) -> MatchResult:
661
+ """Parse API response into MatchResult."""
662
+ return MatchResult(
663
+ score=data["score"],
664
+ match=data["match"],
665
+ source_ulid=source_ulid,
666
+ candidate_ulid=candidate_ulid,
667
+ explain=self._parse_explanation(data["explain"]) if data.get("explain") else None,
668
+ )
669
+
670
+ def _parse_explanation(self, data: Dict[str, Any]) -> Explanation:
671
+ """Parse explanation data."""
672
+ return Explanation(
673
+ human=ExplainHuman(
674
+ summary=data.get("human", {}).get("summary", ""),
675
+ key_reasons=data.get("human", {}).get("key_reasons", []),
676
+ what_changed=data.get("human", {}).get("what_changed"),
677
+ ),
678
+ audit=ExplainAudit(
679
+ semantic_score=data.get("audit", {}).get("semantic_score", 0.0),
680
+ temporal_score=data.get("audit", {}).get("temporal_score", 0.0),
681
+ entity_score=data.get("audit", {}).get("entity_score", 0.0),
682
+ graph_score=data.get("audit", {}).get("graph_score", 0.0),
683
+ topic_match=data.get("audit", {}).get("topic_match", []),
684
+ model_version=data.get("audit", {}).get("model_version", ""),
685
+ hash_chain=data.get("audit", {}).get("hash_chain", ""),
686
+ reproducible=data.get("audit", {}).get("reproducible", True),
687
+ ),
688
+ )