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.
- vault_graph/__init__.py +1 -0
- vault_graph/answer/__init__.py +90 -0
- vault_graph/answer/answer_composer.py +76 -0
- vault_graph/answer/answer_plan.py +113 -0
- vault_graph/answer/answer_renderer.py +106 -0
- vault_graph/answer/answer_response.py +237 -0
- vault_graph/answer/citation_guard.py +36 -0
- vault_graph/answer/evidence_planner.py +475 -0
- vault_graph/app/__init__.py +1 -0
- vault_graph/app/answer_service.py +63 -0
- vault_graph/app/catalog_service.py +65 -0
- vault_graph/app/graph_readiness_service.py +460 -0
- vault_graph/app/graph_resource_service.py +240 -0
- vault_graph/app/graph_retrieval_service.py +1025 -0
- vault_graph/app/index_service.py +471 -0
- vault_graph/app/local_index_service_factory.py +86 -0
- vault_graph/app/path_guard.py +40 -0
- vault_graph/app/query_scope_resolution.py +3 -0
- vault_graph/app/read_only_service_factory.py +337 -0
- vault_graph/app/search_readiness_service.py +146 -0
- vault_graph/app/setup_service.py +127 -0
- vault_graph/app/watch_service.py +92 -0
- vault_graph/cli/__init__.py +1 -0
- vault_graph/cli/main.py +1328 -0
- vault_graph/context/__init__.py +125 -0
- vault_graph/context/context_pack.py +356 -0
- vault_graph/context/context_pack_builder.py +633 -0
- vault_graph/context/context_pack_renderer.py +221 -0
- vault_graph/context/context_pack_serialization.py +107 -0
- vault_graph/context/context_pack_warnings.py +157 -0
- vault_graph/embeddings/__init__.py +21 -0
- vault_graph/embeddings/fastembed_text_embeddings.py +173 -0
- vault_graph/embeddings/text_embeddings.py +57 -0
- vault_graph/errors.py +94 -0
- vault_graph/extraction/__init__.py +1 -0
- vault_graph/extraction/entity_extractor.py +308 -0
- vault_graph/extraction/graph_occurrences.py +119 -0
- vault_graph/extraction/graph_source_store.py +130 -0
- vault_graph/extraction/relationship_extractor.py +175 -0
- vault_graph/graph/__init__.py +49 -0
- vault_graph/graph/graph_contracts.py +416 -0
- vault_graph/graph/graph_identity.py +78 -0
- vault_graph/graph/graph_query.py +114 -0
- vault_graph/graph/graph_readiness.py +56 -0
- vault_graph/http/__init__.py +1 -0
- vault_graph/http/http_errors.py +119 -0
- vault_graph/http/http_explanation_serialization.py +612 -0
- vault_graph/http/http_serialization.py +58 -0
- vault_graph/http/http_server.py +297 -0
- vault_graph/http/http_service_factory.py +48 -0
- vault_graph/indexing/__init__.py +1 -0
- vault_graph/indexing/graph_indexer.py +732 -0
- vault_graph/indexing/metadata_indexer.py +155 -0
- vault_graph/indexing/revision_planner.py +21 -0
- vault_graph/indexing/vector_indexer.py +321 -0
- vault_graph/ingestion/__init__.py +1 -0
- vault_graph/ingestion/document_normalizer.py +92 -0
- vault_graph/ingestion/markdown_parser.py +40 -0
- vault_graph/ingestion/query_scope_resolution.py +30 -0
- vault_graph/ingestion/vault_catalog.py +188 -0
- vault_graph/ingestion/vault_frontmatter_reader.py +31 -0
- vault_graph/ingestion/vault_loader.py +66 -0
- vault_graph/mcp/__init__.py +173 -0
- vault_graph/mcp/context_pack_resource_cache.py +79 -0
- vault_graph/mcp/graph_resource_reader.py +176 -0
- vault_graph/mcp/mcp_answer_serialization.py +142 -0
- vault_graph/mcp/mcp_config_examples.py +36 -0
- vault_graph/mcp/mcp_config_registration.py +153 -0
- vault_graph/mcp/mcp_errors.py +197 -0
- vault_graph/mcp/mcp_memory_serialization.py +418 -0
- vault_graph/mcp/mcp_prompts.py +235 -0
- vault_graph/mcp/mcp_resources.py +431 -0
- vault_graph/mcp/mcp_scope.py +76 -0
- vault_graph/mcp/mcp_server.py +127 -0
- vault_graph/mcp/mcp_service_factory.py +92 -0
- vault_graph/mcp/mcp_tool_serialization.py +895 -0
- vault_graph/mcp/mcp_tools.py +1108 -0
- vault_graph/mcp/mcp_uri.py +220 -0
- vault_graph/mcp/metadata_resource_reader.py +234 -0
- vault_graph/mcp/result_explanation_cache.py +5 -0
- vault_graph/memory/__init__.py +263 -0
- vault_graph/memory/decision_memory.py +395 -0
- vault_graph/memory/health_explorer.py +292 -0
- vault_graph/memory/issue_memory.py +293 -0
- vault_graph/memory/memory_models.py +300 -0
- vault_graph/memory/memory_request_context.py +69 -0
- vault_graph/memory/memory_source_reader.py +159 -0
- vault_graph/memory/project_memory.py +404 -0
- vault_graph/memory/result_explanation.py +232 -0
- vault_graph/memory/result_explanation_cache.py +58 -0
- vault_graph/memory/timeline_memory.py +648 -0
- vault_graph/projection/__init__.py +51 -0
- vault_graph/projection/graph_projection.py +105 -0
- vault_graph/projection/rustworkx_projection.py +249 -0
- vault_graph/retrieval/__init__.py +73 -0
- vault_graph/retrieval/graph_candidates.py +96 -0
- vault_graph/retrieval/graph_retrieval.py +158 -0
- vault_graph/retrieval/retrieval_candidate.py +24 -0
- vault_graph/retrieval/retrieval_result.py +113 -0
- vault_graph/retrieval/retrieval_service.py +474 -0
- vault_graph/retrieval/search_readiness.py +30 -0
- vault_graph/retrieval/search_response.py +101 -0
- vault_graph/storage/__init__.py +1 -0
- vault_graph/storage/interfaces/__init__.py +27 -0
- vault_graph/storage/interfaces/graph_store.py +73 -0
- vault_graph/storage/interfaces/keyword_index.py +57 -0
- vault_graph/storage/interfaces/metadata_store.py +83 -0
- vault_graph/storage/interfaces/store_health.py +10 -0
- vault_graph/storage/interfaces/vector_store.py +150 -0
- vault_graph/storage/local/__init__.py +5 -0
- vault_graph/storage/local/chroma_vector_store.py +531 -0
- vault_graph/storage/local/graph_status_store.py +99 -0
- vault_graph/storage/local/sqlite_graph_store.py +1822 -0
- vault_graph/storage/local/sqlite_keyword_index.py +283 -0
- vault_graph/storage/local/sqlite_metadata_store.py +545 -0
- vault_graph/storage/local/vector_status_store.py +98 -0
- vault_graph-0.1.0.dist-info/METADATA +222 -0
- vault_graph-0.1.0.dist-info/RECORD +121 -0
- vault_graph-0.1.0.dist-info/WHEEL +4 -0
- vault_graph-0.1.0.dist-info/entry_points.txt +2 -0
- vault_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
vault_graph/__init__.py
ADDED
|
@@ -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
|