vault-graph 0.1.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.
Files changed (121) hide show
  1. vault_graph/__init__.py +1 -0
  2. vault_graph/answer/__init__.py +90 -0
  3. vault_graph/answer/answer_composer.py +76 -0
  4. vault_graph/answer/answer_plan.py +113 -0
  5. vault_graph/answer/answer_renderer.py +106 -0
  6. vault_graph/answer/answer_response.py +237 -0
  7. vault_graph/answer/citation_guard.py +36 -0
  8. vault_graph/answer/evidence_planner.py +475 -0
  9. vault_graph/app/__init__.py +1 -0
  10. vault_graph/app/answer_service.py +63 -0
  11. vault_graph/app/catalog_service.py +65 -0
  12. vault_graph/app/graph_readiness_service.py +460 -0
  13. vault_graph/app/graph_resource_service.py +240 -0
  14. vault_graph/app/graph_retrieval_service.py +1025 -0
  15. vault_graph/app/index_service.py +471 -0
  16. vault_graph/app/local_index_service_factory.py +86 -0
  17. vault_graph/app/path_guard.py +40 -0
  18. vault_graph/app/query_scope_resolution.py +3 -0
  19. vault_graph/app/read_only_service_factory.py +337 -0
  20. vault_graph/app/search_readiness_service.py +146 -0
  21. vault_graph/app/setup_service.py +127 -0
  22. vault_graph/app/watch_service.py +92 -0
  23. vault_graph/cli/__init__.py +1 -0
  24. vault_graph/cli/main.py +1328 -0
  25. vault_graph/context/__init__.py +125 -0
  26. vault_graph/context/context_pack.py +356 -0
  27. vault_graph/context/context_pack_builder.py +633 -0
  28. vault_graph/context/context_pack_renderer.py +221 -0
  29. vault_graph/context/context_pack_serialization.py +107 -0
  30. vault_graph/context/context_pack_warnings.py +157 -0
  31. vault_graph/embeddings/__init__.py +21 -0
  32. vault_graph/embeddings/fastembed_text_embeddings.py +173 -0
  33. vault_graph/embeddings/text_embeddings.py +57 -0
  34. vault_graph/errors.py +94 -0
  35. vault_graph/extraction/__init__.py +1 -0
  36. vault_graph/extraction/entity_extractor.py +308 -0
  37. vault_graph/extraction/graph_occurrences.py +119 -0
  38. vault_graph/extraction/graph_source_store.py +130 -0
  39. vault_graph/extraction/relationship_extractor.py +175 -0
  40. vault_graph/graph/__init__.py +49 -0
  41. vault_graph/graph/graph_contracts.py +416 -0
  42. vault_graph/graph/graph_identity.py +78 -0
  43. vault_graph/graph/graph_query.py +114 -0
  44. vault_graph/graph/graph_readiness.py +56 -0
  45. vault_graph/http/__init__.py +1 -0
  46. vault_graph/http/http_errors.py +119 -0
  47. vault_graph/http/http_explanation_serialization.py +612 -0
  48. vault_graph/http/http_serialization.py +58 -0
  49. vault_graph/http/http_server.py +297 -0
  50. vault_graph/http/http_service_factory.py +48 -0
  51. vault_graph/indexing/__init__.py +1 -0
  52. vault_graph/indexing/graph_indexer.py +732 -0
  53. vault_graph/indexing/metadata_indexer.py +155 -0
  54. vault_graph/indexing/revision_planner.py +21 -0
  55. vault_graph/indexing/vector_indexer.py +321 -0
  56. vault_graph/ingestion/__init__.py +1 -0
  57. vault_graph/ingestion/document_normalizer.py +92 -0
  58. vault_graph/ingestion/markdown_parser.py +40 -0
  59. vault_graph/ingestion/query_scope_resolution.py +30 -0
  60. vault_graph/ingestion/vault_catalog.py +188 -0
  61. vault_graph/ingestion/vault_frontmatter_reader.py +31 -0
  62. vault_graph/ingestion/vault_loader.py +66 -0
  63. vault_graph/mcp/__init__.py +173 -0
  64. vault_graph/mcp/context_pack_resource_cache.py +79 -0
  65. vault_graph/mcp/graph_resource_reader.py +176 -0
  66. vault_graph/mcp/mcp_answer_serialization.py +142 -0
  67. vault_graph/mcp/mcp_config_examples.py +36 -0
  68. vault_graph/mcp/mcp_config_registration.py +153 -0
  69. vault_graph/mcp/mcp_errors.py +197 -0
  70. vault_graph/mcp/mcp_memory_serialization.py +418 -0
  71. vault_graph/mcp/mcp_prompts.py +235 -0
  72. vault_graph/mcp/mcp_resources.py +431 -0
  73. vault_graph/mcp/mcp_scope.py +76 -0
  74. vault_graph/mcp/mcp_server.py +127 -0
  75. vault_graph/mcp/mcp_service_factory.py +92 -0
  76. vault_graph/mcp/mcp_tool_serialization.py +895 -0
  77. vault_graph/mcp/mcp_tools.py +1108 -0
  78. vault_graph/mcp/mcp_uri.py +220 -0
  79. vault_graph/mcp/metadata_resource_reader.py +234 -0
  80. vault_graph/mcp/result_explanation_cache.py +5 -0
  81. vault_graph/memory/__init__.py +263 -0
  82. vault_graph/memory/decision_memory.py +395 -0
  83. vault_graph/memory/health_explorer.py +292 -0
  84. vault_graph/memory/issue_memory.py +293 -0
  85. vault_graph/memory/memory_models.py +300 -0
  86. vault_graph/memory/memory_request_context.py +69 -0
  87. vault_graph/memory/memory_source_reader.py +159 -0
  88. vault_graph/memory/project_memory.py +404 -0
  89. vault_graph/memory/result_explanation.py +232 -0
  90. vault_graph/memory/result_explanation_cache.py +58 -0
  91. vault_graph/memory/timeline_memory.py +648 -0
  92. vault_graph/projection/__init__.py +51 -0
  93. vault_graph/projection/graph_projection.py +105 -0
  94. vault_graph/projection/rustworkx_projection.py +249 -0
  95. vault_graph/retrieval/__init__.py +73 -0
  96. vault_graph/retrieval/graph_candidates.py +96 -0
  97. vault_graph/retrieval/graph_retrieval.py +158 -0
  98. vault_graph/retrieval/retrieval_candidate.py +24 -0
  99. vault_graph/retrieval/retrieval_result.py +113 -0
  100. vault_graph/retrieval/retrieval_service.py +474 -0
  101. vault_graph/retrieval/search_readiness.py +30 -0
  102. vault_graph/retrieval/search_response.py +101 -0
  103. vault_graph/storage/__init__.py +1 -0
  104. vault_graph/storage/interfaces/__init__.py +27 -0
  105. vault_graph/storage/interfaces/graph_store.py +73 -0
  106. vault_graph/storage/interfaces/keyword_index.py +57 -0
  107. vault_graph/storage/interfaces/metadata_store.py +83 -0
  108. vault_graph/storage/interfaces/store_health.py +10 -0
  109. vault_graph/storage/interfaces/vector_store.py +150 -0
  110. vault_graph/storage/local/__init__.py +5 -0
  111. vault_graph/storage/local/chroma_vector_store.py +531 -0
  112. vault_graph/storage/local/graph_status_store.py +99 -0
  113. vault_graph/storage/local/sqlite_graph_store.py +1822 -0
  114. vault_graph/storage/local/sqlite_keyword_index.py +283 -0
  115. vault_graph/storage/local/sqlite_metadata_store.py +545 -0
  116. vault_graph/storage/local/vector_status_store.py +98 -0
  117. vault_graph-0.1.0.dist-info/METADATA +222 -0
  118. vault_graph-0.1.0.dist-info/RECORD +121 -0
  119. vault_graph-0.1.0.dist-info/WHEEL +4 -0
  120. vault_graph-0.1.0.dist-info/entry_points.txt +2 -0
  121. vault_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ __all__ = [
6
+ "AnswerClaim",
7
+ "AnswerDraft",
8
+ "AnswerEvidence",
9
+ "AnswerPlan",
10
+ "AnswerReasoningStep",
11
+ "AnswerRequest",
12
+ "AnswerResponse",
13
+ "AnswerSignal",
14
+ "AnswerStoreRevision",
15
+ "AnswerWarning",
16
+ "CitationGuard",
17
+ "DefaultAnswerRenderer",
18
+ "EvidencePlanner",
19
+ "EvidencePlanStep",
20
+ "ExtractiveAnswerComposer",
21
+ "PlannedEvidence",
22
+ "answer_id_for",
23
+ ]
24
+
25
+
26
+ def __getattr__(name: str) -> Any:
27
+ if name in {
28
+ "AnswerClaim",
29
+ "AnswerDraft",
30
+ "AnswerEvidence",
31
+ "AnswerReasoningStep",
32
+ "AnswerResponse",
33
+ "AnswerSignal",
34
+ "AnswerStoreRevision",
35
+ "AnswerWarning",
36
+ }:
37
+ from vault_graph.answer.answer_response import (
38
+ AnswerClaim,
39
+ AnswerDraft,
40
+ AnswerEvidence,
41
+ AnswerReasoningStep,
42
+ AnswerResponse,
43
+ AnswerSignal,
44
+ AnswerStoreRevision,
45
+ AnswerWarning,
46
+ )
47
+
48
+ return {
49
+ "AnswerClaim": AnswerClaim,
50
+ "AnswerDraft": AnswerDraft,
51
+ "AnswerEvidence": AnswerEvidence,
52
+ "AnswerReasoningStep": AnswerReasoningStep,
53
+ "AnswerResponse": AnswerResponse,
54
+ "AnswerSignal": AnswerSignal,
55
+ "AnswerStoreRevision": AnswerStoreRevision,
56
+ "AnswerWarning": AnswerWarning,
57
+ }[name]
58
+ if name in {"AnswerPlan", "AnswerRequest", "EvidencePlanStep", "PlannedEvidence", "answer_id_for"}:
59
+ from vault_graph.answer.answer_plan import (
60
+ AnswerPlan,
61
+ AnswerRequest,
62
+ EvidencePlanStep,
63
+ PlannedEvidence,
64
+ answer_id_for,
65
+ )
66
+
67
+ return {
68
+ "AnswerPlan": AnswerPlan,
69
+ "AnswerRequest": AnswerRequest,
70
+ "EvidencePlanStep": EvidencePlanStep,
71
+ "PlannedEvidence": PlannedEvidence,
72
+ "answer_id_for": answer_id_for,
73
+ }[name]
74
+ if name == "CitationGuard":
75
+ from vault_graph.answer.citation_guard import CitationGuard
76
+
77
+ return CitationGuard
78
+ if name == "DefaultAnswerRenderer":
79
+ from vault_graph.answer.answer_renderer import DefaultAnswerRenderer
80
+
81
+ return DefaultAnswerRenderer
82
+ if name == "EvidencePlanner":
83
+ from vault_graph.answer.evidence_planner import EvidencePlanner
84
+
85
+ return EvidencePlanner
86
+ if name == "ExtractiveAnswerComposer":
87
+ from vault_graph.answer.answer_composer import ExtractiveAnswerComposer
88
+
89
+ return ExtractiveAnswerComposer
90
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from vault_graph.answer.answer_plan import AnswerRequest, PlannedEvidence
6
+ from vault_graph.answer.answer_response import AnswerClaim, AnswerClaimStatus, AnswerDraft, AnswerStatus
7
+
8
+
9
+ class AnswerComposer(Protocol):
10
+ def compose(self, request: AnswerRequest, evidence: PlannedEvidence) -> AnswerDraft: ...
11
+
12
+
13
+ class ExtractiveAnswerComposer:
14
+ def compose(self, request: AnswerRequest, evidence: PlannedEvidence) -> AnswerDraft:
15
+ if not evidence.evidence:
16
+ return AnswerDraft(
17
+ answer="Vault Graph does not have enough indexed evidence to answer this question.",
18
+ answer_status="insufficient_evidence",
19
+ claims=(
20
+ AnswerClaim(
21
+ claim_id="claim-1",
22
+ text=f"No indexed evidence directly answers: {request.question.strip()}",
23
+ status="missing",
24
+ evidence_ids=(),
25
+ warnings=(),
26
+ ),
27
+ ),
28
+ warnings=evidence.warnings,
29
+ suggested_follow_up="Run vg index for the selected Vault, then retry the question.",
30
+ )
31
+
32
+ claims: list[AnswerClaim] = []
33
+ for index, item in enumerate(evidence.evidence[:5], start=1):
34
+ status = _claim_status_for_evidence(item.relationship_status)
35
+ prefix = "Evidence" if status == "supported" else "Vault Graph found partial evidence"
36
+ claims.append(
37
+ AnswerClaim(
38
+ claim_id=f"claim-{index}",
39
+ text=f"{prefix} from {item.path} indicates: {item.excerpt}",
40
+ status=status,
41
+ evidence_ids=(item.evidence_id,),
42
+ warnings=(),
43
+ )
44
+ )
45
+
46
+ if any(warning.severity in {"warning", "error"} for warning in evidence.warnings):
47
+ answer_status: AnswerStatus = "partial"
48
+ answer = "Vault Graph found partial evidence; review the labeled claims and warnings."
49
+ elif any(claim.status == "supported" for claim in claims):
50
+ answer_status = "supported"
51
+ answer = "Vault Graph found indexed evidence that answers the question."
52
+ else:
53
+ answer_status = "partial"
54
+ answer = "Vault Graph found partial evidence; review the labeled claims and warnings."
55
+
56
+ return AnswerDraft(
57
+ answer=answer,
58
+ answer_status=answer_status,
59
+ claims=tuple(claims),
60
+ warnings=evidence.warnings,
61
+ suggested_follow_up=(
62
+ "Review the cited Vault evidence and update durable knowledge through the Vault workflow if needed."
63
+ if answer_status == "partial"
64
+ else None
65
+ ),
66
+ )
67
+
68
+
69
+ def _claim_status_for_evidence(relationship_status: str | None) -> AnswerClaimStatus:
70
+ if relationship_status == "contested":
71
+ return "contested"
72
+ if relationship_status == "deprecated":
73
+ return "deprecated"
74
+ if relationship_status == "inferred":
75
+ return "inferred"
76
+ return "supported"
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+
7
+ from vault_graph.answer.answer_response import (
8
+ AnswerEvidence,
9
+ AnswerMode,
10
+ AnswerReasoningStep,
11
+ AnswerWarning,
12
+ )
13
+ from vault_graph.errors import AnswerError
14
+ from vault_graph.ingestion.vault_catalog import QueryScope
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AnswerRequest:
19
+ question: str
20
+ requested_scope: QueryScope
21
+ mode: AnswerMode = "evidence-first"
22
+ include_graph: bool = False
23
+ include_cross_vault: bool = False
24
+ retrieval_limit: int = 10
25
+ max_evidence_tokens: int = 8000
26
+
27
+ def __post_init__(self) -> None:
28
+ if not self.question.strip():
29
+ raise AnswerError("question is required")
30
+ if self.mode != "evidence-first":
31
+ raise AnswerError(f"unsupported answer mode: {self.mode}")
32
+ if not 1 <= self.retrieval_limit <= 50:
33
+ raise AnswerError("retrieval_limit must be between 1 and 50")
34
+ if not 1000 <= self.max_evidence_tokens <= 24000:
35
+ raise AnswerError("max_evidence_tokens must be between 1000 and 24000")
36
+ if self.include_cross_vault and not self.include_graph:
37
+ raise AnswerError("include_cross_vault requires include_graph")
38
+ if self.include_cross_vault and len(self.requested_scope.vault_ids) <= 1:
39
+ raise AnswerError("include_cross_vault requires more than one vault_id")
40
+ if self.include_cross_vault != self.requested_scope.include_cross_vault:
41
+ raise AnswerError("requested_scope.include_cross_vault must match include_cross_vault")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class EvidencePlanStep:
46
+ step_id: str
47
+ kind: str
48
+ query: str
49
+ required: bool
50
+ include_graph: bool = False
51
+ include_cross_vault: bool = False
52
+ limit: int = 10
53
+
54
+ def __post_init__(self) -> None:
55
+ for field_name in ("step_id", "kind", "query"):
56
+ if not str(getattr(self, field_name)).strip():
57
+ raise AnswerError(f"{field_name} is required")
58
+ if self.limit <= 0:
59
+ raise AnswerError("plan step limit must be positive")
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class AnswerPlan:
64
+ request: AnswerRequest
65
+ steps: tuple[EvidencePlanStep, ...]
66
+
67
+ def __post_init__(self) -> None:
68
+ if not isinstance(self.steps, tuple):
69
+ raise AnswerError("steps must be an immutable tuple")
70
+ if not self.steps:
71
+ raise AnswerError("answer plan requires at least one step")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class PlannedEvidence:
76
+ plan: AnswerPlan
77
+ actual_scopes: tuple[QueryScope, ...]
78
+ evidence: tuple[AnswerEvidence, ...]
79
+ reasoning_trace: tuple[AnswerReasoningStep, ...]
80
+ warnings: tuple[AnswerWarning, ...]
81
+ dropped_evidence_count: int = 0
82
+
83
+ def __post_init__(self) -> None:
84
+ for field_name in ("actual_scopes", "evidence", "reasoning_trace", "warnings"):
85
+ if not isinstance(getattr(self, field_name), tuple):
86
+ raise AnswerError(f"{field_name} must be an immutable tuple")
87
+ if not self.actual_scopes:
88
+ raise AnswerError("actual_scopes is required")
89
+ if self.dropped_evidence_count < 0:
90
+ raise AnswerError("dropped_evidence_count must not be negative")
91
+
92
+
93
+ def answer_id_for(
94
+ *,
95
+ question: str,
96
+ mode: AnswerMode,
97
+ requested_scope: QueryScope,
98
+ evidence_ids: tuple[str, ...],
99
+ generated_at: str,
100
+ ) -> str:
101
+ payload = {
102
+ "question": " ".join(question.casefold().split()),
103
+ "mode": mode,
104
+ "requested_scope": {
105
+ "vault_ids": list(requested_scope.vault_ids),
106
+ "content_scopes": list(requested_scope.content_scopes),
107
+ "include_cross_vault": requested_scope.include_cross_vault,
108
+ },
109
+ "evidence_ids": list(evidence_ids),
110
+ "generated_at": generated_at,
111
+ }
112
+ digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
113
+ return f"answer:{digest[:24]}"
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from vault_graph.answer.answer_response import (
10
+ AnswerClaim,
11
+ AnswerDraft,
12
+ AnswerEvidence,
13
+ AnswerReasoningStep,
14
+ AnswerResponse,
15
+ AnswerSignal,
16
+ AnswerStoreRevision,
17
+ AnswerWarning,
18
+ )
19
+ from vault_graph.errors import AnswerError
20
+ from vault_graph.ingestion.vault_catalog import QueryScope
21
+
22
+ _DTO_TYPES = {
23
+ AnswerClaim,
24
+ AnswerDraft,
25
+ AnswerEvidence,
26
+ AnswerReasoningStep,
27
+ AnswerResponse,
28
+ AnswerSignal,
29
+ AnswerStoreRevision,
30
+ AnswerWarning,
31
+ }
32
+
33
+
34
+ class AnswerRenderer(Protocol):
35
+ def render_text(self, response: AnswerResponse) -> str: ...
36
+ def render_json(self, response: AnswerResponse) -> str: ...
37
+
38
+
39
+ class DefaultAnswerRenderer:
40
+ def render_text(self, response: AnswerResponse) -> str:
41
+ lines = [
42
+ f"status: {response.answer_status}",
43
+ "answer:",
44
+ response.answer,
45
+ "claims:",
46
+ ]
47
+ for claim in response.claims:
48
+ claim_evidence_ids = ",".join(claim.evidence_ids) if claim.evidence_ids else "none"
49
+ lines.append(f"- {claim.claim_id} [{claim.status}] {claim.text} (evidence: {claim_evidence_ids})")
50
+ lines.append("evidence:")
51
+ for evidence in response.evidence:
52
+ suffix = evidence.anchor or evidence.section
53
+ path = f"{evidence.path}#{suffix}" if suffix else evidence.path
54
+ lines.append(f"- {evidence.evidence_id} [{evidence.vault_id}] {path}")
55
+ lines.append("warnings:")
56
+ if response.warnings:
57
+ for warning in response.warnings:
58
+ lines.append(f"- {warning.code} [{warning.severity}] {warning.message}")
59
+ else:
60
+ lines.append("- none")
61
+ lines.append("reasoning:")
62
+ for step in response.reasoning_trace:
63
+ kept_count = len(step.kept_evidence_ids)
64
+ lines.append(f"- {step.step_id} {step.service} results={step.result_count} kept={kept_count}")
65
+ if response.suggested_follow_up:
66
+ lines.append("follow_up:")
67
+ lines.append(response.suggested_follow_up)
68
+ return "\n".join(lines) + "\n"
69
+
70
+ def render_json(self, response: AnswerResponse) -> str:
71
+ return (
72
+ json.dumps(answer_response_to_dict(response), ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False)
73
+ + "\n"
74
+ )
75
+
76
+
77
+ def answer_response_to_dict(response: AnswerResponse) -> dict[str, object]:
78
+ converted = _to_json_value(response)
79
+ if not isinstance(converted, dict):
80
+ raise AnswerError("answer response serialization produced a non-object")
81
+ return converted
82
+
83
+
84
+ def _to_json_value(value: object) -> object:
85
+ if isinstance(value, QueryScope):
86
+ return {
87
+ "vault_ids": list(value.vault_ids),
88
+ "content_scopes": list(value.content_scopes),
89
+ "include_cross_vault": value.include_cross_vault,
90
+ }
91
+ if dataclasses.is_dataclass(value):
92
+ value_type = type(value)
93
+ if value_type not in _DTO_TYPES:
94
+ raise AnswerError(f"unsupported dataclass in answer serialization: {value_type.__name__}")
95
+ return {field.name: _to_json_value(getattr(value, field.name)) for field in dataclasses.fields(value)}
96
+ if isinstance(value, tuple):
97
+ return [_to_json_value(item) for item in value]
98
+ if isinstance(value, float):
99
+ if not math.isfinite(value):
100
+ raise AnswerError("non-finite float values are not supported in answer JSON")
101
+ return value
102
+ if isinstance(value, str | int | bool) or value is None:
103
+ return value
104
+ if isinstance(value, Path | bytes | bytearray | list | dict | set):
105
+ raise AnswerError(f"unsupported value in answer serialization: {type(value).__name__}")
106
+ raise AnswerError(f"unsupported value in answer serialization: {type(value).__name__}")
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from vault_graph.errors import AnswerError
7
+ from vault_graph.ingestion.vault_catalog import QueryScope
8
+
9
+ AnswerMode = Literal["evidence-first"]
10
+ AnswerStatus = Literal["supported", "partial", "insufficient_evidence"]
11
+ AnswerClaimStatus = Literal[
12
+ "supported",
13
+ "inferred",
14
+ "partial",
15
+ "unsupported",
16
+ "missing",
17
+ "contested",
18
+ "stale",
19
+ "deprecated",
20
+ ]
21
+ AnswerWarningSeverity = Literal["info", "warning", "error"]
22
+ AnswerEvidenceSourceKind = Literal[
23
+ "search_result",
24
+ "graph_related",
25
+ "decision_trace",
26
+ "context_pack",
27
+ "project_memory",
28
+ "open_question",
29
+ "recent_change",
30
+ ]
31
+
32
+ _EVIDENCE_REQUIRED_CLAIM_STATUSES = {
33
+ "supported",
34
+ "inferred",
35
+ "partial",
36
+ "contested",
37
+ "stale",
38
+ "deprecated",
39
+ }
40
+ _USABLE_CLAIM_STATUSES = _EVIDENCE_REQUIRED_CLAIM_STATUSES
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AnswerSignal:
45
+ kind: str
46
+ rank: int
47
+ score: float
48
+ backend: str
49
+ index_revision: str
50
+ explanation: str = ""
51
+
52
+ def __post_init__(self) -> None:
53
+ _require_non_empty(self.kind, "signal kind")
54
+ _require_non_empty(self.backend, "signal backend")
55
+ _require_non_empty(self.index_revision, "signal index_revision")
56
+ if self.rank <= 0:
57
+ raise AnswerError("signal rank must be positive")
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AnswerStoreRevision:
62
+ kind: str
63
+ revision: str
64
+ scope_key: str
65
+ vault_id: str | None = None
66
+
67
+ def __post_init__(self) -> None:
68
+ _require_non_empty(self.kind, "store revision kind")
69
+ _require_non_empty(self.revision, "store revision")
70
+ _require_non_empty(self.scope_key, "store revision scope_key")
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class AnswerEvidence:
75
+ evidence_id: str
76
+ source_kind: AnswerEvidenceSourceKind
77
+ result_id: str
78
+ vault_id: str
79
+ document_id: str
80
+ chunk_id: str
81
+ path: str
82
+ section: str | None
83
+ anchor: str | None
84
+ content_hash: str
85
+ raw_sha256: str | None
86
+ metadata_index_revision: str | None
87
+ vault_revision: str | None
88
+ excerpt: str
89
+ retrieval_reason: str
90
+ relationship_status: str | None
91
+ signals: tuple[AnswerSignal, ...]
92
+ store_revisions: tuple[AnswerStoreRevision, ...]
93
+
94
+ def __post_init__(self) -> None:
95
+ for field_name in (
96
+ "evidence_id",
97
+ "result_id",
98
+ "vault_id",
99
+ "document_id",
100
+ "chunk_id",
101
+ "path",
102
+ "content_hash",
103
+ "retrieval_reason",
104
+ ):
105
+ _require_non_empty(getattr(self, field_name), field_name)
106
+ _require_tuple(self.signals, "signals")
107
+ _require_tuple(self.store_revisions, "store_revisions")
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class AnswerWarning:
112
+ code: str
113
+ message: str
114
+ severity: AnswerWarningSeverity
115
+ affected_vault_ids: tuple[str, ...]
116
+ recovery_hint: str | None = None
117
+ evidence_ids: tuple[str, ...] = ()
118
+
119
+ def __post_init__(self) -> None:
120
+ _require_non_empty(self.code, "warning code")
121
+ _require_non_empty(self.message, "warning message")
122
+ _require_tuple(self.affected_vault_ids, "affected_vault_ids")
123
+ if not self.affected_vault_ids:
124
+ raise AnswerError("affected_vault_ids is required")
125
+ _require_tuple(self.evidence_ids, "warning evidence_ids")
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class AnswerClaim:
130
+ claim_id: str
131
+ text: str
132
+ status: AnswerClaimStatus
133
+ evidence_ids: tuple[str, ...]
134
+ warnings: tuple[AnswerWarning, ...]
135
+
136
+ def __post_init__(self) -> None:
137
+ _require_non_empty(self.claim_id, "claim_id")
138
+ _require_non_empty(self.text, "claim text")
139
+ _require_tuple(self.evidence_ids, "claim evidence_ids")
140
+ _require_tuple(self.warnings, "claim warnings")
141
+ if self.status in _EVIDENCE_REQUIRED_CLAIM_STATUSES and not self.evidence_ids:
142
+ raise AnswerError("claim evidence is required for cited claim statuses")
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class AnswerReasoningStep:
147
+ step_id: str
148
+ kind: str
149
+ service: str
150
+ status: str
151
+ query: str
152
+ result_count: int
153
+ kept_evidence_ids: tuple[str, ...]
154
+ dropped_count: int
155
+ warning_codes: tuple[str, ...]
156
+
157
+ def __post_init__(self) -> None:
158
+ for field_name in ("step_id", "kind", "service", "status", "query"):
159
+ _require_non_empty(getattr(self, field_name), field_name)
160
+ if self.result_count < 0:
161
+ raise AnswerError("result_count must not be negative")
162
+ if self.dropped_count < 0:
163
+ raise AnswerError("dropped_count must not be negative")
164
+ _require_tuple(self.kept_evidence_ids, "kept_evidence_ids")
165
+ _require_tuple(self.warning_codes, "warning_codes")
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class AnswerDraft:
170
+ answer: str
171
+ answer_status: AnswerStatus
172
+ claims: tuple[AnswerClaim, ...]
173
+ warnings: tuple[AnswerWarning, ...]
174
+ suggested_follow_up: str | None
175
+
176
+ def __post_init__(self) -> None:
177
+ _require_non_empty(self.answer, "answer")
178
+ _require_tuple(self.claims, "claims")
179
+ _require_tuple(self.warnings, "warnings")
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class AnswerResponse:
184
+ answer_id: str
185
+ question: str
186
+ requested_scope: QueryScope
187
+ actual_scopes: tuple[QueryScope, ...]
188
+ answer: str
189
+ answer_status: AnswerStatus
190
+ claims: tuple[AnswerClaim, ...]
191
+ evidence: tuple[AnswerEvidence, ...]
192
+ reasoning_trace: tuple[AnswerReasoningStep, ...]
193
+ warnings: tuple[AnswerWarning, ...]
194
+ suggested_follow_up: str | None
195
+ generated_at: str
196
+
197
+ def __post_init__(self) -> None:
198
+ _require_non_empty(self.answer_id, "answer_id")
199
+ _require_non_empty(self.question, "question")
200
+ _require_non_empty(self.answer, "answer")
201
+ _require_non_empty(self.generated_at, "generated_at")
202
+ _require_tuple(self.actual_scopes, "actual_scopes")
203
+ if not self.actual_scopes:
204
+ raise AnswerError("actual_scopes is required")
205
+ _require_tuple(self.claims, "claims")
206
+ _require_tuple(self.evidence, "evidence")
207
+ _require_tuple(self.reasoning_trace, "reasoning_trace")
208
+ _require_tuple(self.warnings, "warnings")
209
+ evidence_ids = _unique_ids(tuple(item.evidence_id for item in self.evidence), "evidence_id")
210
+ _unique_ids(tuple(claim.claim_id for claim in self.claims), "claim_id")
211
+ for claim in self.claims:
212
+ for evidence_id in claim.evidence_ids:
213
+ if evidence_id not in evidence_ids:
214
+ raise AnswerError(f"unknown evidence_id for claim: {evidence_id}")
215
+ if self.answer_status == "supported":
216
+ if not any(claim.status == "supported" for claim in self.claims):
217
+ raise AnswerError("supported answer requires at least one supported claim")
218
+
219
+
220
+ def _require_non_empty(value: str, field_name: str) -> None:
221
+ if not value.strip():
222
+ raise AnswerError(f"{field_name} is required")
223
+
224
+
225
+ def _require_tuple(value: object, field_name: str) -> None:
226
+ if not isinstance(value, tuple):
227
+ raise AnswerError(f"{field_name} must be an immutable tuple")
228
+
229
+
230
+ def _unique_ids(values: tuple[str, ...], field_name: str) -> set[str]:
231
+ seen: set[str] = set()
232
+ for value in values:
233
+ _require_non_empty(value, field_name)
234
+ if value in seen:
235
+ raise AnswerError(f"duplicate {field_name}: {value}")
236
+ seen.add(value)
237
+ return seen
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from vault_graph.answer.answer_response import AnswerResponse
6
+ from vault_graph.errors import AnswerError
7
+
8
+ _USABLE_STATUSES = {"supported", "inferred", "partial", "contested", "stale", "deprecated"}
9
+
10
+
11
+ class CitationGuard:
12
+ def validate(self, response: AnswerResponse) -> AnswerResponse:
13
+ evidence_ids = _unique(tuple(evidence.evidence_id for evidence in response.evidence), "evidence_id")
14
+ _unique(tuple(claim.claim_id for claim in response.claims), "claim_id")
15
+ for claim in response.claims:
16
+ if claim.status in _USABLE_STATUSES and not claim.evidence_ids:
17
+ raise AnswerError("claim evidence is required for cited claim statuses")
18
+ for evidence_id in claim.evidence_ids:
19
+ if evidence_id not in evidence_ids:
20
+ raise AnswerError(f"unknown evidence_id for claim: {evidence_id}")
21
+
22
+ usable_claims = tuple(claim for claim in response.claims if claim.status in _USABLE_STATUSES)
23
+ if not usable_claims:
24
+ return replace(response, answer_status="insufficient_evidence")
25
+ if response.answer_status == "supported" and any(warning.severity == "error" for warning in response.warnings):
26
+ return replace(response, answer_status="partial")
27
+ return response
28
+
29
+
30
+ def _unique(values: tuple[str, ...], field_name: str) -> set[str]:
31
+ seen: set[str] = set()
32
+ for value in values:
33
+ if value in seen:
34
+ raise AnswerError(f"duplicate {field_name}: {value}")
35
+ seen.add(value)
36
+ return seen