chronem 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.
- chronem/__init__.py +111 -0
- chronem/answers/__init__.py +18 -0
- chronem/answers/models.py +92 -0
- chronem/answers/service.py +160 -0
- chronem/entities/__init__.py +6 -0
- chronem/entities/normalization.py +15 -0
- chronem/entities/resolver.py +57 -0
- chronem/evaluation/__init__.py +40 -0
- chronem/evaluation/data/__init__.py +1 -0
- chronem/evaluation/data/recall_planning.json +14 -0
- chronem/evaluation/entities.py +174 -0
- chronem/evaluation/grounding.py +305 -0
- chronem/evaluation/lifecycle.py +221 -0
- chronem/evaluation/metrics.py +51 -0
- chronem/evaluation/models.py +221 -0
- chronem/evaluation/planning.py +56 -0
- chronem/evaluation/runner.py +44 -0
- chronem/evaluation/structured_recall.py +49 -0
- chronem/ingestion/__init__.py +18 -0
- chronem/ingestion/models.py +66 -0
- chronem/ingestion/service.py +336 -0
- chronem/interfaces/__init__.py +35 -0
- chronem/interfaces/answers.py +23 -0
- chronem/interfaces/clock.py +13 -0
- chronem/interfaces/entities.py +64 -0
- chronem/interfaces/extraction.py +19 -0
- chronem/interfaces/queries.py +97 -0
- chronem/interfaces/recall.py +22 -0
- chronem/interfaces/repositories.py +109 -0
- chronem/lifecycle/engine.py +260 -0
- chronem/models/_shared.py +63 -0
- chronem/models/candidate.py +92 -0
- chronem/models/capture.py +52 -0
- chronem/models/entity.py +61 -0
- chronem/models/memory.py +154 -0
- chronem/models/types.py +47 -0
- chronem/persistence/__init__.py +1 -0
- chronem/persistence/postgres/__init__.py +22 -0
- chronem/persistence/postgres/mapping.py +99 -0
- chronem/persistence/postgres/migration.py +31 -0
- chronem/persistence/postgres/migrations/__init__.py +1 -0
- chronem/persistence/postgres/migrations/__main__.py +22 -0
- chronem/persistence/postgres/migrations/env.py +61 -0
- chronem/persistence/postgres/migrations/script.py.mako +25 -0
- chronem/persistence/postgres/migrations/versions/20261008_0001_initial_postgres.py +143 -0
- chronem/persistence/postgres/migrations/versions/20261009_0002_structured_recall_index.py +27 -0
- chronem/persistence/postgres/migrations/versions/__init__.py +1 -0
- chronem/persistence/postgres/repositories.py +334 -0
- chronem/persistence/postgres/session.py +14 -0
- chronem/persistence/postgres/tables.py +145 -0
- chronem/providers/__init__.py +1 -0
- chronem/providers/openai/__init__.py +35 -0
- chronem/providers/openai/answer_prompt.py +56 -0
- chronem/providers/openai/answer_schemas.py +30 -0
- chronem/providers/openai/answer_synthesizer.py +82 -0
- chronem/providers/openai/extractor.py +64 -0
- chronem/providers/openai/prompt.py +45 -0
- chronem/providers/openai/recall_planner.py +78 -0
- chronem/providers/openai/recall_prompt.py +53 -0
- chronem/providers/openai/recall_schemas.py +88 -0
- chronem/providers/openai/schemas.py +154 -0
- chronem/py.typed +1 -0
- chronem/recall/__init__.py +34 -0
- chronem/recall/models.py +154 -0
- chronem/recall/orchestration.py +119 -0
- chronem/recall/service.py +168 -0
- chronem-0.1.0.dist-info/METADATA +240 -0
- chronem-0.1.0.dist-info/RECORD +70 -0
- chronem-0.1.0.dist-info/WHEEL +4 -0
- chronem-0.1.0.dist-info/licenses/LICENSE +201 -0
chronem/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Chronem temporal memory engine."""
|
|
2
|
+
|
|
3
|
+
from chronem.answers import (
|
|
4
|
+
NO_EVIDENCE_MESSAGE,
|
|
5
|
+
RecallAnswerInvariantError,
|
|
6
|
+
RecallAnswerOutcome,
|
|
7
|
+
RecallAnswerResult,
|
|
8
|
+
RecallAnswerService,
|
|
9
|
+
SynthesizedAnswer,
|
|
10
|
+
)
|
|
11
|
+
from chronem.entities import DeterministicEntityResolver, normalize_entity_name
|
|
12
|
+
from chronem.ingestion import (
|
|
13
|
+
CandidateIngestionOutcome,
|
|
14
|
+
CandidateIngestionResult,
|
|
15
|
+
EntityMentionRole,
|
|
16
|
+
IngestionInvariantError,
|
|
17
|
+
IngestionResult,
|
|
18
|
+
IngestionService,
|
|
19
|
+
)
|
|
20
|
+
from chronem.interfaces import (
|
|
21
|
+
AnswerSynthesizer,
|
|
22
|
+
CaptureRepository,
|
|
23
|
+
Clock,
|
|
24
|
+
EntityRepository,
|
|
25
|
+
EntityResolution,
|
|
26
|
+
EntityResolutionOutcome,
|
|
27
|
+
EntityResolver,
|
|
28
|
+
Extractor,
|
|
29
|
+
MemoryQuery,
|
|
30
|
+
MemoryQueryOrder,
|
|
31
|
+
MemoryQueryRepository,
|
|
32
|
+
MemoryRepository,
|
|
33
|
+
RecallPlanner,
|
|
34
|
+
RepositoryInvariantError,
|
|
35
|
+
)
|
|
36
|
+
from chronem.lifecycle.engine import LifecycleAction, LifecycleDecision, LifecycleEngine
|
|
37
|
+
from chronem.models.candidate import CandidateMemory
|
|
38
|
+
from chronem.models.capture import Capture, SourceType
|
|
39
|
+
from chronem.models.entity import Entity, EntityType
|
|
40
|
+
from chronem.models.memory import Memory
|
|
41
|
+
from chronem.models.types import MemoryKind, MemoryStatus
|
|
42
|
+
from chronem.recall import (
|
|
43
|
+
RecallExecutionOutcome,
|
|
44
|
+
RecallExecutionResult,
|
|
45
|
+
RecallInvariantError,
|
|
46
|
+
RecallMode,
|
|
47
|
+
RecallOrchestrationInvariantError,
|
|
48
|
+
RecallOrchestrationService,
|
|
49
|
+
RecallOutcome,
|
|
50
|
+
RecallPlan,
|
|
51
|
+
RecallPlanOutcome,
|
|
52
|
+
RecallRequestExecution,
|
|
53
|
+
StructuredRecallRequest,
|
|
54
|
+
StructuredRecallResult,
|
|
55
|
+
StructuredRecallService,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AnswerSynthesizer",
|
|
60
|
+
"CandidateMemory",
|
|
61
|
+
"CandidateIngestionOutcome",
|
|
62
|
+
"CandidateIngestionResult",
|
|
63
|
+
"Capture",
|
|
64
|
+
"CaptureRepository",
|
|
65
|
+
"Clock",
|
|
66
|
+
"DeterministicEntityResolver",
|
|
67
|
+
"Entity",
|
|
68
|
+
"EntityResolution",
|
|
69
|
+
"EntityResolutionOutcome",
|
|
70
|
+
"EntityRepository",
|
|
71
|
+
"EntityResolver",
|
|
72
|
+
"EntityType",
|
|
73
|
+
"EntityMentionRole",
|
|
74
|
+
"Extractor",
|
|
75
|
+
"IngestionInvariantError",
|
|
76
|
+
"IngestionResult",
|
|
77
|
+
"IngestionService",
|
|
78
|
+
"LifecycleAction",
|
|
79
|
+
"LifecycleDecision",
|
|
80
|
+
"LifecycleEngine",
|
|
81
|
+
"Memory",
|
|
82
|
+
"MemoryKind",
|
|
83
|
+
"MemoryRepository",
|
|
84
|
+
"MemoryQuery",
|
|
85
|
+
"MemoryQueryOrder",
|
|
86
|
+
"MemoryQueryRepository",
|
|
87
|
+
"MemoryStatus",
|
|
88
|
+
"NO_EVIDENCE_MESSAGE",
|
|
89
|
+
"RepositoryInvariantError",
|
|
90
|
+
"RecallInvariantError",
|
|
91
|
+
"RecallAnswerInvariantError",
|
|
92
|
+
"RecallAnswerOutcome",
|
|
93
|
+
"RecallAnswerResult",
|
|
94
|
+
"RecallAnswerService",
|
|
95
|
+
"RecallExecutionOutcome",
|
|
96
|
+
"RecallExecutionResult",
|
|
97
|
+
"RecallMode",
|
|
98
|
+
"RecallOutcome",
|
|
99
|
+
"RecallOrchestrationInvariantError",
|
|
100
|
+
"RecallOrchestrationService",
|
|
101
|
+
"RecallPlan",
|
|
102
|
+
"RecallPlanner",
|
|
103
|
+
"RecallPlanOutcome",
|
|
104
|
+
"RecallRequestExecution",
|
|
105
|
+
"SourceType",
|
|
106
|
+
"StructuredRecallRequest",
|
|
107
|
+
"StructuredRecallResult",
|
|
108
|
+
"StructuredRecallService",
|
|
109
|
+
"SynthesizedAnswer",
|
|
110
|
+
"normalize_entity_name",
|
|
111
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Grounded answer synthesis public API."""
|
|
2
|
+
|
|
3
|
+
from chronem.answers.models import (
|
|
4
|
+
NO_EVIDENCE_MESSAGE,
|
|
5
|
+
RecallAnswerOutcome,
|
|
6
|
+
RecallAnswerResult,
|
|
7
|
+
SynthesizedAnswer,
|
|
8
|
+
)
|
|
9
|
+
from chronem.answers.service import RecallAnswerInvariantError, RecallAnswerService
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"NO_EVIDENCE_MESSAGE",
|
|
13
|
+
"RecallAnswerInvariantError",
|
|
14
|
+
"RecallAnswerOutcome",
|
|
15
|
+
"RecallAnswerResult",
|
|
16
|
+
"RecallAnswerService",
|
|
17
|
+
"SynthesizedAnswer",
|
|
18
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Provider-independent models for grounded recall answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from chronem.models._shared import require_non_blank
|
|
11
|
+
from chronem.models.memory import Memory
|
|
12
|
+
from chronem.recall.models import RecallExecutionOutcome, RecallExecutionResult
|
|
13
|
+
|
|
14
|
+
NO_EVIDENCE_MESSAGE = "I don't have that saved."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SynthesizedAnswer(BaseModel):
|
|
18
|
+
"""User-facing wording and the grounded memory ids supporting it."""
|
|
19
|
+
|
|
20
|
+
text: str = Field(min_length=1)
|
|
21
|
+
cited_memory_ids: tuple[UUID, ...] = Field(min_length=1)
|
|
22
|
+
|
|
23
|
+
@field_validator("text")
|
|
24
|
+
@classmethod
|
|
25
|
+
def _validate_text(cls, value: str) -> str:
|
|
26
|
+
return require_non_blank(value, field_name="answer text")
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def _reject_duplicate_citations(self) -> SynthesizedAnswer:
|
|
30
|
+
if len(self.cited_memory_ids) != len(set(self.cited_memory_ids)):
|
|
31
|
+
raise ValueError("cited_memory_ids must not contain duplicates")
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RecallAnswerOutcome(StrEnum):
|
|
36
|
+
"""Outcome of deterministic recall plus optional grounded phrasing."""
|
|
37
|
+
|
|
38
|
+
ANSWERED = "ANSWERED"
|
|
39
|
+
PARTIAL = "PARTIAL"
|
|
40
|
+
NOT_FOUND = "NOT_FOUND"
|
|
41
|
+
UNSUPPORTED = "UNSUPPORTED"
|
|
42
|
+
AMBIGUOUS_PLAN = "AMBIGUOUS_PLAN"
|
|
43
|
+
AMBIGUOUS_SUBJECT = "AMBIGUOUS_SUBJECT"
|
|
44
|
+
SUBJECT_TYPE_CONFLICT = "SUBJECT_TYPE_CONFLICT"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RecallAnswerResult(BaseModel):
|
|
48
|
+
"""Complete recall evidence and its validated user-facing answer, if any."""
|
|
49
|
+
|
|
50
|
+
outcome: RecallAnswerOutcome
|
|
51
|
+
text: str | None = None
|
|
52
|
+
cited_memory_ids: tuple[UUID, ...] = ()
|
|
53
|
+
recall_result: RecallExecutionResult
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def evidence(self) -> tuple[Memory, ...]:
|
|
57
|
+
"""Return evidence unchanged in deterministic recall order."""
|
|
58
|
+
return self.recall_result.memories
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="after")
|
|
61
|
+
def _validate_shape(self) -> RecallAnswerResult:
|
|
62
|
+
if self.outcome in (RecallAnswerOutcome.ANSWERED, RecallAnswerOutcome.PARTIAL):
|
|
63
|
+
if self.text is None or not self.text.strip():
|
|
64
|
+
raise ValueError("answered results require nonblank text")
|
|
65
|
+
if not self.cited_memory_ids:
|
|
66
|
+
raise ValueError("answered results require at least one citation")
|
|
67
|
+
elif self.cited_memory_ids:
|
|
68
|
+
raise ValueError("non-answered results cannot contain citations")
|
|
69
|
+
|
|
70
|
+
if self.outcome == RecallAnswerOutcome.NOT_FOUND:
|
|
71
|
+
if self.text != NO_EVIDENCE_MESSAGE:
|
|
72
|
+
raise ValueError("NOT_FOUND requires the deterministic no-evidence message")
|
|
73
|
+
elif self.outcome not in (
|
|
74
|
+
RecallAnswerOutcome.ANSWERED,
|
|
75
|
+
RecallAnswerOutcome.PARTIAL,
|
|
76
|
+
) and self.text is not None:
|
|
77
|
+
raise ValueError("abstained results cannot contain answer text")
|
|
78
|
+
|
|
79
|
+
expected_recall_outcome = {
|
|
80
|
+
RecallAnswerOutcome.ANSWERED: RecallExecutionOutcome.FOUND,
|
|
81
|
+
RecallAnswerOutcome.PARTIAL: RecallExecutionOutcome.PARTIAL,
|
|
82
|
+
RecallAnswerOutcome.NOT_FOUND: RecallExecutionOutcome.NOT_FOUND,
|
|
83
|
+
RecallAnswerOutcome.UNSUPPORTED: RecallExecutionOutcome.UNSUPPORTED,
|
|
84
|
+
RecallAnswerOutcome.AMBIGUOUS_PLAN: RecallExecutionOutcome.AMBIGUOUS_PLAN,
|
|
85
|
+
RecallAnswerOutcome.AMBIGUOUS_SUBJECT: RecallExecutionOutcome.AMBIGUOUS_SUBJECT,
|
|
86
|
+
RecallAnswerOutcome.SUBJECT_TYPE_CONFLICT: (
|
|
87
|
+
RecallExecutionOutcome.SUBJECT_TYPE_CONFLICT
|
|
88
|
+
),
|
|
89
|
+
}[self.outcome]
|
|
90
|
+
if self.recall_result.outcome != expected_recall_outcome:
|
|
91
|
+
raise ValueError("answer outcome must match the deterministic recall outcome")
|
|
92
|
+
return self
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Provider-independent orchestration from grounded recall to an answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from chronem.answers.models import (
|
|
10
|
+
NO_EVIDENCE_MESSAGE,
|
|
11
|
+
RecallAnswerOutcome,
|
|
12
|
+
RecallAnswerResult,
|
|
13
|
+
SynthesizedAnswer,
|
|
14
|
+
)
|
|
15
|
+
from chronem.models._shared import require_non_blank, require_timezone_aware
|
|
16
|
+
from chronem.models.memory import Memory
|
|
17
|
+
from chronem.models.types import MemoryKind
|
|
18
|
+
from chronem.recall.models import RecallExecutionOutcome, RecallExecutionResult
|
|
19
|
+
from chronem.recall.orchestration import RecallOrchestrationService
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from chronem.interfaces.answers import AnswerSynthesizer
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RecallAnswerInvariantError(ValueError):
|
|
26
|
+
"""Raised when grounded synthesis violates the answer boundary."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RecallAnswerService:
|
|
30
|
+
"""Phrase deterministic recall evidence without adding retrieval authority."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
recall_orchestration_service: RecallOrchestrationService,
|
|
36
|
+
answer_synthesizer: AnswerSynthesizer,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._recall_orchestration_service = recall_orchestration_service
|
|
39
|
+
self._answer_synthesizer = answer_synthesizer
|
|
40
|
+
|
|
41
|
+
def answer(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
user_id: str,
|
|
45
|
+
question: str,
|
|
46
|
+
asked_at: datetime,
|
|
47
|
+
) -> RecallAnswerResult:
|
|
48
|
+
scoped_user_id = require_non_blank(user_id, field_name="user_id")
|
|
49
|
+
question_text = require_non_blank(question, field_name="question")
|
|
50
|
+
reference_time = require_timezone_aware(asked_at, field_name="asked_at")
|
|
51
|
+
recall_result = self._recall_orchestration_service.recall(
|
|
52
|
+
user_id=scoped_user_id,
|
|
53
|
+
question=question_text,
|
|
54
|
+
asked_at=reference_time,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
deterministic_outcome = _non_synthesis_outcome(recall_result.outcome)
|
|
58
|
+
if deterministic_outcome is not None:
|
|
59
|
+
return RecallAnswerResult(
|
|
60
|
+
outcome=deterministic_outcome,
|
|
61
|
+
text=(
|
|
62
|
+
NO_EVIDENCE_MESSAGE
|
|
63
|
+
if deterministic_outcome == RecallAnswerOutcome.NOT_FOUND
|
|
64
|
+
else None
|
|
65
|
+
),
|
|
66
|
+
recall_result=recall_result,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
evidence = recall_result.memories
|
|
70
|
+
self._validate_evidence(user_id=scoped_user_id, evidence=evidence)
|
|
71
|
+
synthesized = self._answer_synthesizer.synthesize(
|
|
72
|
+
question=question_text,
|
|
73
|
+
evidence=evidence,
|
|
74
|
+
asked_at=reference_time,
|
|
75
|
+
)
|
|
76
|
+
self._validate_synthesis(
|
|
77
|
+
synthesized=synthesized,
|
|
78
|
+
evidence=evidence,
|
|
79
|
+
recall_result=recall_result,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return RecallAnswerResult(
|
|
83
|
+
outcome=(
|
|
84
|
+
RecallAnswerOutcome.ANSWERED
|
|
85
|
+
if recall_result.outcome == RecallExecutionOutcome.FOUND
|
|
86
|
+
else RecallAnswerOutcome.PARTIAL
|
|
87
|
+
),
|
|
88
|
+
text=synthesized.text,
|
|
89
|
+
cited_memory_ids=synthesized.cited_memory_ids,
|
|
90
|
+
recall_result=recall_result,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _validate_evidence(*, user_id: str, evidence: tuple[Memory, ...]) -> None:
|
|
95
|
+
if not evidence:
|
|
96
|
+
raise RecallAnswerInvariantError(
|
|
97
|
+
"FOUND and PARTIAL recall outcomes require grounded evidence"
|
|
98
|
+
)
|
|
99
|
+
if any(memory.user_id != user_id for memory in evidence):
|
|
100
|
+
raise RecallAnswerInvariantError(
|
|
101
|
+
"recall evidence contains a memory from another user scope"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _validate_synthesis(
|
|
106
|
+
*,
|
|
107
|
+
synthesized: SynthesizedAnswer,
|
|
108
|
+
evidence: tuple[Memory, ...],
|
|
109
|
+
recall_result: RecallExecutionResult,
|
|
110
|
+
) -> None:
|
|
111
|
+
cited_ids = synthesized.cited_memory_ids
|
|
112
|
+
if not cited_ids:
|
|
113
|
+
raise RecallAnswerInvariantError("a grounded answer requires at least one citation")
|
|
114
|
+
if len(cited_ids) != len(set(cited_ids)):
|
|
115
|
+
raise RecallAnswerInvariantError(
|
|
116
|
+
"grounded answer citations must not contain duplicates"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
evidence_ids = {memory.id for memory in evidence}
|
|
120
|
+
unknown_ids = set(cited_ids) - evidence_ids
|
|
121
|
+
if unknown_ids:
|
|
122
|
+
raise RecallAnswerInvariantError(
|
|
123
|
+
"grounded answer cites memory ids outside the supplied evidence"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
required_fact_ids = _multi_fact_request_ids(recall_result)
|
|
127
|
+
if not required_fact_ids.issubset(cited_ids):
|
|
128
|
+
raise RecallAnswerInvariantError(
|
|
129
|
+
"grounded answer must cite every FACT memory returned by a "
|
|
130
|
+
"multi-memory FACT request"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _non_synthesis_outcome(
|
|
135
|
+
outcome: RecallExecutionOutcome,
|
|
136
|
+
) -> RecallAnswerOutcome | None:
|
|
137
|
+
return {
|
|
138
|
+
RecallExecutionOutcome.NOT_FOUND: RecallAnswerOutcome.NOT_FOUND,
|
|
139
|
+
RecallExecutionOutcome.UNSUPPORTED: RecallAnswerOutcome.UNSUPPORTED,
|
|
140
|
+
RecallExecutionOutcome.AMBIGUOUS_PLAN: RecallAnswerOutcome.AMBIGUOUS_PLAN,
|
|
141
|
+
RecallExecutionOutcome.AMBIGUOUS_SUBJECT: RecallAnswerOutcome.AMBIGUOUS_SUBJECT,
|
|
142
|
+
RecallExecutionOutcome.SUBJECT_TYPE_CONFLICT: (
|
|
143
|
+
RecallAnswerOutcome.SUBJECT_TYPE_CONFLICT
|
|
144
|
+
),
|
|
145
|
+
}.get(outcome)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _multi_fact_request_ids(recall_result: RecallExecutionResult) -> set[UUID]:
|
|
149
|
+
required_ids: set[UUID] = set()
|
|
150
|
+
for execution in recall_result.executions:
|
|
151
|
+
if execution.request.kind != MemoryKind.FACT:
|
|
152
|
+
continue
|
|
153
|
+
fact_memories = tuple(
|
|
154
|
+
memory
|
|
155
|
+
for memory in execution.result.memories
|
|
156
|
+
if memory.kind == MemoryKind.FACT
|
|
157
|
+
)
|
|
158
|
+
if len(fact_memories) > 1:
|
|
159
|
+
required_ids.update(memory.id for memory in fact_memories)
|
|
160
|
+
return required_ids
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Pure normalization for deterministic entity-name comparison."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_entity_name(value: str) -> str:
|
|
7
|
+
"""Normalize a name without erasing identity-significant distinctions.
|
|
8
|
+
|
|
9
|
+
NFKC normalization is followed by outer trimming, whitespace collapsing,
|
|
10
|
+
and Unicode case folding. Punctuation and diacritics remain intact.
|
|
11
|
+
"""
|
|
12
|
+
normalized = " ".join(unicodedata.normalize("NFKC", value).split()).casefold()
|
|
13
|
+
if not normalized:
|
|
14
|
+
raise ValueError("entity name must not be blank")
|
|
15
|
+
return normalized
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Conservative deterministic entity resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from chronem.entities.normalization import normalize_entity_name
|
|
8
|
+
from chronem.interfaces.entities import EntityResolution, EntityResolutionOutcome, EntityResolver
|
|
9
|
+
from chronem.interfaces.repositories import EntityRepository, RepositoryInvariantError
|
|
10
|
+
from chronem.models._shared import require_non_blank
|
|
11
|
+
from chronem.models.entity import Entity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeterministicEntityResolver(EntityResolver):
|
|
15
|
+
"""Match only normalized canonical names and explicit aliases.
|
|
16
|
+
|
|
17
|
+
Resolution is read-only. Unmatched entity creation and all merge decisions
|
|
18
|
+
belong outside this resolver; ingestion orchestration may create an entity.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, repository: EntityRepository) -> None:
|
|
22
|
+
self._repository = repository
|
|
23
|
+
|
|
24
|
+
def resolve(self, *, user_id: str, mention: str) -> EntityResolution:
|
|
25
|
+
scoped_user_id = require_non_blank(user_id, field_name="user_id")
|
|
26
|
+
normalized_mention = normalize_entity_name(mention)
|
|
27
|
+
matches: dict[UUID, Entity] = {}
|
|
28
|
+
|
|
29
|
+
for entity in self._repository.list_for_user(user_id=scoped_user_id):
|
|
30
|
+
if entity.user_id != scoped_user_id:
|
|
31
|
+
raise RepositoryInvariantError(
|
|
32
|
+
"entity repository returned a record from a different user scope"
|
|
33
|
+
)
|
|
34
|
+
names = (entity.canonical_name, *entity.aliases)
|
|
35
|
+
if any(normalize_entity_name(name) == normalized_mention for name in names):
|
|
36
|
+
matches[entity.id] = entity
|
|
37
|
+
|
|
38
|
+
candidates = tuple(matches[entity_id] for entity_id in sorted(matches))
|
|
39
|
+
if not candidates:
|
|
40
|
+
return EntityResolution(
|
|
41
|
+
user_id=scoped_user_id,
|
|
42
|
+
mention=mention,
|
|
43
|
+
outcome=EntityResolutionOutcome.UNMATCHED,
|
|
44
|
+
)
|
|
45
|
+
if len(candidates) == 1:
|
|
46
|
+
return EntityResolution(
|
|
47
|
+
user_id=scoped_user_id,
|
|
48
|
+
mention=mention,
|
|
49
|
+
outcome=EntityResolutionOutcome.MATCHED,
|
|
50
|
+
matched_entity=candidates[0],
|
|
51
|
+
)
|
|
52
|
+
return EntityResolution(
|
|
53
|
+
user_id=scoped_user_id,
|
|
54
|
+
mention=mention,
|
|
55
|
+
outcome=EntityResolutionOutcome.AMBIGUOUS,
|
|
56
|
+
candidates=candidates,
|
|
57
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Provider-independent deterministic evaluation public API."""
|
|
2
|
+
|
|
3
|
+
from chronem.evaluation.metrics import classification_metrics, summarize_results
|
|
4
|
+
from chronem.evaluation.models import (
|
|
5
|
+
AnswerGroundingEvaluationCase,
|
|
6
|
+
ClassificationMetrics,
|
|
7
|
+
EntityResolutionEvaluationCase,
|
|
8
|
+
EvaluationCase,
|
|
9
|
+
EvaluationDomain,
|
|
10
|
+
EvaluationReport,
|
|
11
|
+
EvaluationResult,
|
|
12
|
+
EvaluationSummary,
|
|
13
|
+
ExtractionEvaluationCase,
|
|
14
|
+
LifecycleEvaluationCase,
|
|
15
|
+
RecallPlanningEvaluationCase,
|
|
16
|
+
StructuredRecallEvaluationCase,
|
|
17
|
+
)
|
|
18
|
+
from chronem.evaluation.runner import build_report, evaluation_exit_code, run_core_evaluations
|
|
19
|
+
from chronem.evaluation.structured_recall import evaluate_structured_recall
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AnswerGroundingEvaluationCase",
|
|
23
|
+
"ClassificationMetrics",
|
|
24
|
+
"EntityResolutionEvaluationCase",
|
|
25
|
+
"EvaluationCase",
|
|
26
|
+
"EvaluationDomain",
|
|
27
|
+
"EvaluationReport",
|
|
28
|
+
"EvaluationResult",
|
|
29
|
+
"EvaluationSummary",
|
|
30
|
+
"ExtractionEvaluationCase",
|
|
31
|
+
"LifecycleEvaluationCase",
|
|
32
|
+
"RecallPlanningEvaluationCase",
|
|
33
|
+
"StructuredRecallEvaluationCase",
|
|
34
|
+
"classification_metrics",
|
|
35
|
+
"build_report",
|
|
36
|
+
"evaluate_structured_recall",
|
|
37
|
+
"evaluation_exit_code",
|
|
38
|
+
"run_core_evaluations",
|
|
39
|
+
"summarize_results",
|
|
40
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Versioned evaluation datasets packaged with Chronem."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"name":"parking_current","question":"Where did I park?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"my car","expected_subject_type":"vehicle","kind":"CURRENT_STATE","predicate":"parked_at","mode":"CURRENT"}]},
|
|
3
|
+
{"name":"passport_current","question":"Where is my passport?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"my passport","expected_subject_type":"object","kind":"CURRENT_STATE","predicate":"located_at","mode":"CURRENT"}]},
|
|
4
|
+
{"name":"birthday_fact","question":"When is Kevin's birthday?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"Kevin","expected_subject_type":"person","kind":"FACT","predicate":"birthday","mode":"CURRENT"}]},
|
|
5
|
+
{"name":"friend_preferences","question":"What does Kevin like?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"Kevin","expected_subject_type":"person","kind":"PREFERENCE","predicate":"likes","mode":"ACTIVE"}]},
|
|
6
|
+
{"name":"bench_history","question":"Show my recent bench press history.","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"me","expected_subject_type":"person","kind":"EVENT","predicate":"bench_press","mode":"HISTORY"}]},
|
|
7
|
+
{"name":"latest_oil_change","question":"When did I last change the oil?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"my vehicle","expected_subject_type":"vehicle","kind":"EVENT","predicate":"oil_changed","mode":"LATEST"}]},
|
|
8
|
+
{"name":"active_shopping","question":"What am I still planning to buy?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"me","expected_subject_type":"person","kind":"INTENT","predicate":"buy","mode":"ACTIVE"}]},
|
|
9
|
+
{"name":"workouts_this_week","question":"What workouts did I log this week?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"UNSUPPORTED","expected_requests":[]},
|
|
10
|
+
{"name":"bench_yesterday","question":"What did I bench yesterday?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"PLANNED","expected_requests":[{"subject":"me","expected_subject_type":"person","kind":"EVENT","predicate":"bench_press","mode":"HISTORY","since":"2026-10-08T00:00:00+00:00","until":"2026-10-08T23:59:59.999999+00:00"}]},
|
|
11
|
+
{"name":"maintenance_last_month","question":"What maintenance did I record last month?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"UNSUPPORTED","expected_requests":[]},
|
|
12
|
+
{"name":"unsupported_semantic","question":"Find memories similar to my happiest notes.","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"UNSUPPORTED","expected_requests":[]},
|
|
13
|
+
{"name":"ambiguous_target","question":"Where is it?","asked_at":"2026-10-09T15:30:00+00:00","expected_outcome":"AMBIGUOUS","expected_requests":[]}
|
|
14
|
+
]
|