keble-idea 0.3.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.
- keble_idea/__init__.py +66 -0
- keble_idea/application/__init__.py +1 -0
- keble_idea/application/lifecycle.py +251 -0
- keble_idea/application/ports.py +393 -0
- keble_idea/application/publication.py +35 -0
- keble_idea/application/services.py +713 -0
- keble_idea/domain/__init__.py +1 -0
- keble_idea/domain/base.py +134 -0
- keble_idea/domain/concept.py +143 -0
- keble_idea/domain/content.py +340 -0
- keble_idea/domain/errors.py +117 -0
- keble_idea/domain/generation.py +145 -0
- keble_idea/domain/idea.py +315 -0
- keble_idea/domain/job.py +799 -0
- keble_idea/domain/relationship.py +155 -0
- keble_idea/domain/search.py +288 -0
- keble_idea/domain/source.py +71 -0
- keble_idea/processors/__init__.py +1 -0
- keble_idea/processors/concepts.py +88 -0
- keble_idea/processors/confidence.py +85 -0
- keble_idea/processors/generations.py +66 -0
- keble_idea/processors/identity.py +78 -0
- keble_idea/processors/ranking.py +48 -0
- keble_idea/processors/relationships.py +116 -0
- keble_idea/testing/__init__.py +13 -0
- keble_idea/testing/repositories.py +353 -0
- keble_idea-0.3.0.dist-info/METADATA +187 -0
- keble_idea-0.3.0.dist-info/RECORD +29 -0
- keble_idea-0.3.0.dist-info/WHEEL +4 -0
keble_idea/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Public provider-neutral contracts for Keble Idea."""
|
|
2
|
+
|
|
3
|
+
from .application.services import (
|
|
4
|
+
ConceptAliasService,
|
|
5
|
+
ExtractionService,
|
|
6
|
+
IdeaSearchService,
|
|
7
|
+
)
|
|
8
|
+
from .domain.content import ContentCreate, ContentMongoObject
|
|
9
|
+
from .domain.idea import IdeaCreate, IdeaMongoObject
|
|
10
|
+
from .domain.job import (
|
|
11
|
+
ActiveProviderRecoveryProfilePointer,
|
|
12
|
+
EnvelopeManualRetryCommand,
|
|
13
|
+
EnvelopeProcessResult,
|
|
14
|
+
IdeaJobStatus,
|
|
15
|
+
IdeaJobType,
|
|
16
|
+
JobFailureSnapshot,
|
|
17
|
+
JobRecoveryDecision,
|
|
18
|
+
JobRecoveryDecisionReason,
|
|
19
|
+
ProviderPaymentEvidenceSource,
|
|
20
|
+
ProviderRecoveryProfileActivationCommand,
|
|
21
|
+
ProviderRecoveryProfileCreate,
|
|
22
|
+
ProviderRecoveryProfileMongoObject,
|
|
23
|
+
ProviderRecoveryProfileSnapshot,
|
|
24
|
+
QueuedEnvelopeCreate,
|
|
25
|
+
QueuedEnvelopeMongoObject,
|
|
26
|
+
UpstreamFailureEvidence,
|
|
27
|
+
UpstreamFailureEvidenceSource,
|
|
28
|
+
default_provider_recovery_profile_snapshot,
|
|
29
|
+
recovery_decision_for_failure,
|
|
30
|
+
recovery_profile_for_manual_retry,
|
|
31
|
+
retry_at_for_failure,
|
|
32
|
+
)
|
|
33
|
+
from .domain.search import IdeaSearchRequest, IdeaSearchResponse
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ActiveProviderRecoveryProfilePointer",
|
|
37
|
+
"ConceptAliasService",
|
|
38
|
+
"ContentCreate",
|
|
39
|
+
"ContentMongoObject",
|
|
40
|
+
"ExtractionService",
|
|
41
|
+
"EnvelopeManualRetryCommand",
|
|
42
|
+
"EnvelopeProcessResult",
|
|
43
|
+
"IdeaCreate",
|
|
44
|
+
"IdeaMongoObject",
|
|
45
|
+
"IdeaJobStatus",
|
|
46
|
+
"IdeaJobType",
|
|
47
|
+
"IdeaSearchRequest",
|
|
48
|
+
"IdeaSearchResponse",
|
|
49
|
+
"IdeaSearchService",
|
|
50
|
+
"JobFailureSnapshot",
|
|
51
|
+
"JobRecoveryDecision",
|
|
52
|
+
"JobRecoveryDecisionReason",
|
|
53
|
+
"ProviderPaymentEvidenceSource",
|
|
54
|
+
"ProviderRecoveryProfileActivationCommand",
|
|
55
|
+
"ProviderRecoveryProfileCreate",
|
|
56
|
+
"ProviderRecoveryProfileMongoObject",
|
|
57
|
+
"ProviderRecoveryProfileSnapshot",
|
|
58
|
+
"QueuedEnvelopeCreate",
|
|
59
|
+
"QueuedEnvelopeMongoObject",
|
|
60
|
+
"UpstreamFailureEvidence",
|
|
61
|
+
"UpstreamFailureEvidenceSource",
|
|
62
|
+
"default_provider_recovery_profile_snapshot",
|
|
63
|
+
"recovery_decision_for_failure",
|
|
64
|
+
"recovery_profile_for_manual_retry",
|
|
65
|
+
"retry_at_for_failure",
|
|
66
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Provider-neutral use cases and caller-owned protocols."""
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Canonical generation cutover and relationship lifecycle use cases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from keble_idea.application.ports import (
|
|
10
|
+
ConceptPostingCompactorProtocol,
|
|
11
|
+
GenerationRegistryProtocol,
|
|
12
|
+
RelationshipInferenceProtocol,
|
|
13
|
+
RelationshipRepositoryProtocol,
|
|
14
|
+
)
|
|
15
|
+
from keble_idea.domain.base import IdeaSchema
|
|
16
|
+
from keble_idea.domain.concept import NeighborSummary
|
|
17
|
+
from keble_idea.domain.errors import IdeaDomainError, IdeaErrorCode, ObjectNotFoundError
|
|
18
|
+
from keble_idea.domain.generation import (
|
|
19
|
+
ActiveGenerationPointer,
|
|
20
|
+
GenerationCutoverCommand,
|
|
21
|
+
GenerationLifecycle,
|
|
22
|
+
GenerationRollbackCommand,
|
|
23
|
+
)
|
|
24
|
+
from keble_idea.domain.idea import IdeaConfidenceBand
|
|
25
|
+
from keble_idea.domain.job import EnvelopeExecutionIdentity
|
|
26
|
+
from keble_idea.domain.relationship import (
|
|
27
|
+
ConceptRelationshipCreate,
|
|
28
|
+
RelationshipCandidate,
|
|
29
|
+
RelationshipCoverage,
|
|
30
|
+
)
|
|
31
|
+
from keble_idea.processors.identity import build_record_key
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RelationshipRunContext(IdeaSchema):
|
|
35
|
+
"""Immutable policy and model identity for one bounded inference batch."""
|
|
36
|
+
|
|
37
|
+
relationship_generation: str = Field(min_length=1, max_length=128)
|
|
38
|
+
embedding_generation: str = Field(min_length=1, max_length=128)
|
|
39
|
+
neighborhood_generation: str = Field(min_length=1, max_length=128)
|
|
40
|
+
model_id: str = Field(min_length=1, max_length=256)
|
|
41
|
+
prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
42
|
+
policy_version: str = Field(min_length=1, max_length=128)
|
|
43
|
+
acceptance_threshold: float = Field(default=0.8, ge=0.65, le=1)
|
|
44
|
+
retirement_absence_sweeps: int = Field(default=2, ge=2, le=10)
|
|
45
|
+
execution: EnvelopeExecutionIdentity
|
|
46
|
+
processed_at: datetime
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RelationshipInferenceService:
|
|
50
|
+
"""Infer exact candidate pairs and persist accepted edges plus coverage.
|
|
51
|
+
|
|
52
|
+
Steps:
|
|
53
|
+
1. enforce unique deterministic concept pairs;
|
|
54
|
+
2. accept null/no-relation outputs as successful evaluations;
|
|
55
|
+
3. persist only outputs above the configured precision threshold;
|
|
56
|
+
4. stamp exact evaluated-neighbor coverage after every pair completes.
|
|
57
|
+
|
|
58
|
+
Side effects if changes:
|
|
59
|
+
- relationship workers and evals share this acceptance behavior;
|
|
60
|
+
- Mongo is canonical and Neo4j reconciliation consumes accepted edges.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
inference: RelationshipInferenceProtocol,
|
|
67
|
+
relationships: RelationshipRepositoryProtocol,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._inference = inference
|
|
70
|
+
self._relationships = relationships
|
|
71
|
+
|
|
72
|
+
async def ainfer(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
concept_key: str,
|
|
76
|
+
candidates: list[RelationshipCandidate],
|
|
77
|
+
context: RelationshipRunContext,
|
|
78
|
+
) -> RelationshipCoverage:
|
|
79
|
+
"""Evaluate each unique pair and persist deterministic accepted relations."""
|
|
80
|
+
|
|
81
|
+
pair_keys = [(row.left_concept_key, row.right_concept_key) for row in candidates]
|
|
82
|
+
if len(pair_keys) != len(set(pair_keys)):
|
|
83
|
+
raise ValueError("relationship candidates must contain unique concept pairs")
|
|
84
|
+
evaluated_neighbors: dict[str, NeighborSummary] = {}
|
|
85
|
+
for candidate in candidates:
|
|
86
|
+
if concept_key not in {candidate.left_concept_key, candidate.right_concept_key}:
|
|
87
|
+
raise ValueError("coverage concept_key must participate in every candidate")
|
|
88
|
+
if (
|
|
89
|
+
candidate.embedding_generation != context.embedding_generation
|
|
90
|
+
or candidate.neighborhood_generation != context.neighborhood_generation
|
|
91
|
+
):
|
|
92
|
+
raise ValueError("candidate generations must match the relationship run context")
|
|
93
|
+
output = await self._inference.ainfer(
|
|
94
|
+
candidate=candidate,
|
|
95
|
+
execution=context.execution,
|
|
96
|
+
)
|
|
97
|
+
if (
|
|
98
|
+
output.left_concept_key != candidate.left_concept_key
|
|
99
|
+
or output.right_concept_key != candidate.right_concept_key
|
|
100
|
+
):
|
|
101
|
+
raise ValueError("relationship inference returned an unknown concept pair")
|
|
102
|
+
neighbor_key = (
|
|
103
|
+
candidate.right_concept_key
|
|
104
|
+
if candidate.left_concept_key == concept_key
|
|
105
|
+
else candidate.left_concept_key
|
|
106
|
+
)
|
|
107
|
+
evaluated_neighbors[neighbor_key] = NeighborSummary(
|
|
108
|
+
concept_key=neighbor_key,
|
|
109
|
+
similarity=candidate.similarity,
|
|
110
|
+
)
|
|
111
|
+
if output.relation_type is None or output.confidence is None:
|
|
112
|
+
continue
|
|
113
|
+
if output.confidence < context.acceptance_threshold:
|
|
114
|
+
continue
|
|
115
|
+
relationship_key = build_record_key(
|
|
116
|
+
prefix="relationship",
|
|
117
|
+
identity={
|
|
118
|
+
"left_concept_key": output.left_concept_key,
|
|
119
|
+
"right_concept_key": output.right_concept_key,
|
|
120
|
+
"relation_type": output.relation_type.value,
|
|
121
|
+
"relationship_generation": context.relationship_generation,
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
confidence_band = (
|
|
125
|
+
IdeaConfidenceBand.HIGH
|
|
126
|
+
if output.confidence >= 0.9
|
|
127
|
+
else IdeaConfidenceBand.MEDIUM
|
|
128
|
+
)
|
|
129
|
+
relationship = ConceptRelationshipCreate(
|
|
130
|
+
record_key=relationship_key,
|
|
131
|
+
pipeline_generation=context.relationship_generation,
|
|
132
|
+
relationship_key=relationship_key,
|
|
133
|
+
left_concept_key=output.left_concept_key,
|
|
134
|
+
right_concept_key=output.right_concept_key,
|
|
135
|
+
relation_type=output.relation_type,
|
|
136
|
+
confidence_score=output.confidence,
|
|
137
|
+
confidence_band=confidence_band,
|
|
138
|
+
inference_basis=candidate.inference_basis,
|
|
139
|
+
evidence_refs=output.evidence_refs,
|
|
140
|
+
embedding_generation=candidate.embedding_generation,
|
|
141
|
+
neighborhood_generation=candidate.neighborhood_generation,
|
|
142
|
+
relationship_generation=context.relationship_generation,
|
|
143
|
+
model_id=context.model_id,
|
|
144
|
+
prompt_hash=context.prompt_hash,
|
|
145
|
+
policy_version=context.policy_version,
|
|
146
|
+
first_observed_at=context.processed_at,
|
|
147
|
+
last_observed_at=context.processed_at,
|
|
148
|
+
)
|
|
149
|
+
await self._relationships.aupsert(relationship=relationship)
|
|
150
|
+
coverage = RelationshipCoverage(
|
|
151
|
+
concept_key=concept_key,
|
|
152
|
+
embedding_generation=context.embedding_generation,
|
|
153
|
+
neighborhood_generation=context.neighborhood_generation,
|
|
154
|
+
relationship_generation=context.relationship_generation,
|
|
155
|
+
evaluated_neighbors=list(evaluated_neighbors.values()),
|
|
156
|
+
retirement_absence_sweeps=context.retirement_absence_sweeps,
|
|
157
|
+
covered_at=context.processed_at,
|
|
158
|
+
)
|
|
159
|
+
await self._relationships.aset_coverage(coverage=coverage)
|
|
160
|
+
return coverage
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class GenerationLifecycleService:
|
|
164
|
+
"""Register, verify, atomically cut over, and rollback immutable generations.
|
|
165
|
+
|
|
166
|
+
Side effects if changes:
|
|
167
|
+
- active search/runtime pointers change only through this service;
|
|
168
|
+
- Qdrant alias switching and projection verification occur in outer adapters;
|
|
169
|
+
- retention preserves the pointer's active and previous generations.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, *, registry: GenerationRegistryProtocol) -> None:
|
|
173
|
+
self._registry = registry
|
|
174
|
+
|
|
175
|
+
async def acutover(self, *, command: GenerationCutoverCommand) -> ActiveGenerationPointer:
|
|
176
|
+
"""Switch one component only after its immutable generation is verified."""
|
|
177
|
+
|
|
178
|
+
generation = await self._registry.aget_generation(
|
|
179
|
+
generation_key=command.generation_key
|
|
180
|
+
)
|
|
181
|
+
if generation is None:
|
|
182
|
+
raise ObjectNotFoundError(
|
|
183
|
+
object_name="Generation",
|
|
184
|
+
object_key=command.generation_key,
|
|
185
|
+
)
|
|
186
|
+
if generation.component is not command.component:
|
|
187
|
+
raise ValueError("generation component does not match cutover component")
|
|
188
|
+
if generation.lifecycle not in {GenerationLifecycle.VERIFIED, GenerationLifecycle.ACTIVE}:
|
|
189
|
+
raise IdeaDomainError(
|
|
190
|
+
code=IdeaErrorCode.GENERATION_NOT_VERIFIED,
|
|
191
|
+
message=f"generation is not verified: {command.generation_key}",
|
|
192
|
+
)
|
|
193
|
+
return await self._registry.acompare_and_switch(command=command)
|
|
194
|
+
|
|
195
|
+
async def arollback(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
command: GenerationRollbackCommand,
|
|
199
|
+
) -> ActiveGenerationPointer:
|
|
200
|
+
"""Restore the immediately previous verified generation atomically."""
|
|
201
|
+
|
|
202
|
+
pointer = await self._registry.aget_pointer(component=command.component)
|
|
203
|
+
if pointer is None or pointer.previous_generation_key is None:
|
|
204
|
+
raise IdeaDomainError(
|
|
205
|
+
code=IdeaErrorCode.GENERATION_NOT_VERIFIED,
|
|
206
|
+
message=f"no rollback generation for {command.component.value}",
|
|
207
|
+
)
|
|
208
|
+
previous = await self._registry.aget_generation(
|
|
209
|
+
generation_key=pointer.previous_generation_key
|
|
210
|
+
)
|
|
211
|
+
if previous is None or previous.verified_at is None:
|
|
212
|
+
raise IdeaDomainError(
|
|
213
|
+
code=IdeaErrorCode.GENERATION_NOT_VERIFIED,
|
|
214
|
+
message=f"rollback generation is not verified: {pointer.previous_generation_key}",
|
|
215
|
+
)
|
|
216
|
+
return await self._registry.acompare_and_rollback(command=command)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class PostingCompactionService:
|
|
220
|
+
"""Rewrite a bounded alias posting page without blocking merge/unmerge."""
|
|
221
|
+
|
|
222
|
+
def __init__(self, *, compactor: ConceptPostingCompactorProtocol) -> None:
|
|
223
|
+
self._compactor = compactor
|
|
224
|
+
|
|
225
|
+
async def acompact(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
source_concept_key: str,
|
|
229
|
+
canonical_concept_key: str,
|
|
230
|
+
after_posting_key: str | None,
|
|
231
|
+
limit: int = 250,
|
|
232
|
+
) -> str | None:
|
|
233
|
+
"""Compact one indexed page and return the next resumable cursor."""
|
|
234
|
+
|
|
235
|
+
if source_concept_key == canonical_concept_key:
|
|
236
|
+
raise ValueError("compaction source and canonical concepts must differ")
|
|
237
|
+
if limit < 1 or limit > 1_000:
|
|
238
|
+
raise ValueError("compaction limit must be between 1 and 1000")
|
|
239
|
+
return await self._compactor.acompact_page(
|
|
240
|
+
source_concept_key=source_concept_key,
|
|
241
|
+
canonical_concept_key=canonical_concept_key,
|
|
242
|
+
after_posting_key=after_posting_key,
|
|
243
|
+
limit=limit,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def aclear(self, *, source_concept_key: str) -> int:
|
|
247
|
+
"""Remove only derived alias rows after unmerge; preserve original postings."""
|
|
248
|
+
|
|
249
|
+
return await self._compactor.aclear_compacted(
|
|
250
|
+
source_concept_key=source_concept_key
|
|
251
|
+
)
|