weft-memory 0.1.0a0__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.
- weft/__init__.py +3 -0
- weft/adapters.py +238 -0
- weft/cli.py +168 -0
- weft/ingest.py +301 -0
- weft/knowledge.py +109 -0
- weft/logging_config.py +54 -0
- weft/models.py +99 -0
- weft/reasoning.py +336 -0
- weft/retrieval.py +158 -0
- weft/scope.py +352 -0
- weft/server.py +110 -0
- weft/server_common.py +10 -0
- weft/shadow.py +153 -0
- weft/substrate.py +1562 -0
- weft_memory-0.1.0a0.dist-info/METADATA +107 -0
- weft_memory-0.1.0a0.dist-info/RECORD +20 -0
- weft_memory-0.1.0a0.dist-info/WHEEL +5 -0
- weft_memory-0.1.0a0.dist-info/entry_points.txt +2 -0
- weft_memory-0.1.0a0.dist-info/licenses/LICENSE +661 -0
- weft_memory-0.1.0a0.dist-info/top_level.txt +1 -0
weft/ingest.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Phase 3 ingestion (spec §1, D3-3..D3-9).
|
|
2
|
+
|
|
3
|
+
Every entry point returns either a record id or a Rejection with a named reason.
|
|
4
|
+
Nothing is recorded as a success when extraction fails (D3-9).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import uuid
|
|
15
|
+
import zipfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from weft.substrate import Record, RecordConflict, ScopeViolation
|
|
19
|
+
|
|
20
|
+
def _centroid(vectors: list) -> list:
|
|
21
|
+
"""Normalised mean of the given vectors (note-level representation, no extra model calls)."""
|
|
22
|
+
dim = len(vectors[0])
|
|
23
|
+
mean = [sum(v[i] for v in vectors) / len(vectors) for i in range(dim)]
|
|
24
|
+
norm = sum(x * x for x in mean) ** 0.5 or 1.0
|
|
25
|
+
return [x / norm for x in mean]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DOC_HEAD_CHARS = 2000 # opening text embedded per document when doc_vectors is on
|
|
29
|
+
_TURN_ATTRIBUTION = {"user": "user", "assistant": "agent", "system": "system"}
|
|
30
|
+
_FENCE = re.compile(r"<weft-recall\b[^>]*>.*?</weft-recall>", re.DOTALL | re.IGNORECASE)
|
|
31
|
+
_STRAY = re.compile(r"</?weft-recall\b[^>]*>", re.IGNORECASE)
|
|
32
|
+
_OPEN = re.compile(r"<weft-recall\b[^>]*>", re.IGNORECASE)
|
|
33
|
+
_CLOSE = re.compile(r"</weft-recall>", re.IGNORECASE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Rejection:
|
|
38
|
+
name: str
|
|
39
|
+
reason: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fence(text: str) -> tuple[str, bool]:
|
|
43
|
+
"""Strip recall blocks and stray recall tags (D3-5). Returns (text, changed).
|
|
44
|
+
|
|
45
|
+
Blocks are removed until none remain (review G-P3-8: nested or repeated blocks
|
|
46
|
+
must not leave recalled content behind), then any stray tag token is removed.
|
|
47
|
+
"""
|
|
48
|
+
cleaned = text
|
|
49
|
+
while True:
|
|
50
|
+
opened = _OPEN.search(cleaned)
|
|
51
|
+
if opened is None:
|
|
52
|
+
break
|
|
53
|
+
closes = list(_CLOSE.finditer(cleaned, opened.end()))
|
|
54
|
+
if not closes:
|
|
55
|
+
break
|
|
56
|
+
# Conservative: everything from the first open tag through the last close tag goes.
|
|
57
|
+
cleaned = cleaned[:opened.start()] + cleaned[closes[-1].end():]
|
|
58
|
+
cleaned = _STRAY.sub("", cleaned)
|
|
59
|
+
return cleaned, cleaned != text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_paragraphs(text: str) -> list[str]:
|
|
63
|
+
return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _digest(data: bytes) -> str:
|
|
67
|
+
return hashlib.sha256(data).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _record_digest(record: Record) -> str:
|
|
71
|
+
row = list(record.to_row())
|
|
72
|
+
del row[5] # creation time is not part of the intended content
|
|
73
|
+
return _digest(json.dumps(row, separators=(',', ':')).encode())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse(name: str, data: bytes):
|
|
77
|
+
"""Return extracted text, or a Rejection. Never returns partial text on failure."""
|
|
78
|
+
ext = os.path.splitext(name.lower())[1]
|
|
79
|
+
if ext in (".txt", ".md"):
|
|
80
|
+
try:
|
|
81
|
+
return data.decode("utf-8")
|
|
82
|
+
except UnicodeDecodeError:
|
|
83
|
+
return Rejection(name, "encoding_unsupported")
|
|
84
|
+
if ext == ".pdf":
|
|
85
|
+
if not data.startswith(b"%PDF"):
|
|
86
|
+
return Rejection(name, "parse_failed")
|
|
87
|
+
if b"/Encrypt" in data:
|
|
88
|
+
return Rejection(name, "encrypted_pdf")
|
|
89
|
+
try:
|
|
90
|
+
from pypdf import PdfReader
|
|
91
|
+
reader = PdfReader(io.BytesIO(data))
|
|
92
|
+
if reader.is_encrypted:
|
|
93
|
+
return Rejection(name, "encrypted_pdf")
|
|
94
|
+
text = "\n\n".join((page.extract_text() or "") for page in reader.pages).strip()
|
|
95
|
+
except Exception:
|
|
96
|
+
return Rejection(name, "parse_failed")
|
|
97
|
+
return text if text else Rejection(name, "no_text_layer")
|
|
98
|
+
if ext == ".docx":
|
|
99
|
+
try:
|
|
100
|
+
import docx
|
|
101
|
+
doc = docx.Document(io.BytesIO(data))
|
|
102
|
+
text = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
|
103
|
+
except Exception:
|
|
104
|
+
return Rejection(name, "parse_failed")
|
|
105
|
+
return text if text else Rejection(name, "no_text_layer")
|
|
106
|
+
return Rejection(name, "unsupported_format")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Ingestor:
|
|
110
|
+
"""Writes Sources and Observations through the substrate. Holds no state of its own."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, substrate, embedder=None, doc_vectors=False, doc_centroid=True):
|
|
113
|
+
self.sub = substrate
|
|
114
|
+
self.embedder = embedder # optional: anything with embed_document(text) -> vector
|
|
115
|
+
self.doc_vectors = doc_vectors # also embed each document's opening text (note-level)
|
|
116
|
+
self.doc_centroid = doc_centroid # note-level vector: normalised mean of paragraph vectors (default on)
|
|
117
|
+
|
|
118
|
+
def _put_matching(self, record, ctx):
|
|
119
|
+
"""Recover only an identical, currently readable and writable incumbent."""
|
|
120
|
+
try:
|
|
121
|
+
self.sub.put(record, ctx)
|
|
122
|
+
except RecordConflict:
|
|
123
|
+
incumbent = self.sub._lookup_record(record.record_id)
|
|
124
|
+
engine = self.sub.scope_engine
|
|
125
|
+
if (incumbent is None
|
|
126
|
+
or not engine.authorize_write(incumbent, ctx).allowed
|
|
127
|
+
or not engine.authorize_read(incumbent.scope, ctx, engine._payload_text(incumbent)).allowed
|
|
128
|
+
or any(getattr(incumbent, field) != getattr(record, field) for field in (
|
|
129
|
+
'record_id', 'record_type', 'scope', 'payload', 'epistemic_status',
|
|
130
|
+
'effective_at', 'supersedes', 'source_ids', 'provenance', 'attribution'))):
|
|
131
|
+
raise
|
|
132
|
+
|
|
133
|
+
def ingest_turn(self, text: str, speaker: str, ctx, scope: str, conversation_id: str) -> str:
|
|
134
|
+
"""A conversation turn becomes a Source with the speaker's attribution (spec §7.1)."""
|
|
135
|
+
attribution = _TURN_ATTRIBUTION.get(speaker)
|
|
136
|
+
if attribution is None:
|
|
137
|
+
raise ValueError(f"unknown speaker: {speaker!r}")
|
|
138
|
+
clean, changed = fence(text)
|
|
139
|
+
provenance = {"conversation_id": conversation_id, "speaker": speaker}
|
|
140
|
+
if changed:
|
|
141
|
+
provenance["fenced"] = True
|
|
142
|
+
rid = "turn-" + uuid.uuid4().hex[:24]
|
|
143
|
+
record = Record(
|
|
144
|
+
record_id=rid, record_type="Source", scope=scope, payload={"text": clean},
|
|
145
|
+
attribution=attribution, source_ids=[], provenance=provenance,
|
|
146
|
+
)
|
|
147
|
+
if not self.sub.scope_engine.authorize_write(record, ctx).allowed:
|
|
148
|
+
raise ScopeViolation("write not permitted", audit_recorded=False)
|
|
149
|
+
vector = self.embedder.embed_document(clean) if self.embedder is not None else None
|
|
150
|
+
with self.sub.ingestion_transaction():
|
|
151
|
+
self.sub.put(record, ctx)
|
|
152
|
+
if self.embedder is not None:
|
|
153
|
+
self.sub.vector_put(rid, scope, vector, ctx=ctx, model_id=self.embedder.model_id)
|
|
154
|
+
return rid
|
|
155
|
+
|
|
156
|
+
def ingest_document(self, name: str, data: bytes, ctx, scope: str, author: str):
|
|
157
|
+
"""Document text is evidence: Observations carry attribution `document` (D3-3)."""
|
|
158
|
+
parsed = _parse(name, data)
|
|
159
|
+
if isinstance(parsed, Rejection):
|
|
160
|
+
return parsed
|
|
161
|
+
parsed, _ = fence(parsed) # review G-P3-4: recall blocks never persist from documents
|
|
162
|
+
if not parsed.strip():
|
|
163
|
+
return Rejection(name, "empty_document")
|
|
164
|
+
content_hash = _digest(data)
|
|
165
|
+
sid = "doc-" + hashlib.sha256(f"{scope}\0{content_hash}".encode()).hexdigest()[:24]
|
|
166
|
+
paragraphs = _extract_paragraphs(parsed)
|
|
167
|
+
source = Record(
|
|
168
|
+
record_id=sid, record_type="Source", scope=scope,
|
|
169
|
+
payload={"text": parsed, "name": name}, attribution="document", source_ids=[],
|
|
170
|
+
provenance={"author": author, "document": name, "content_hash": content_hash},
|
|
171
|
+
)
|
|
172
|
+
observations = [Record(
|
|
173
|
+
record_id=f"{sid}-p{i:04d}", record_type="Observation", scope=scope,
|
|
174
|
+
payload={"text": paragraph}, attribution="document", source_ids=[sid],
|
|
175
|
+
provenance={"location": f"paragraph {i + 1}", "document": name},
|
|
176
|
+
) for i, paragraph in enumerate(paragraphs)]
|
|
177
|
+
if any(not self.sub.scope_engine.authorize_write(record, ctx).allowed
|
|
178
|
+
for record in [source, *observations]):
|
|
179
|
+
raise ScopeViolation("write not permitted", audit_recorded=False)
|
|
180
|
+
state = self.sub.job_state(sid, "ingest", content_hash, scope=scope, ctx=ctx)
|
|
181
|
+
if state == "done":
|
|
182
|
+
with self.sub.ingestion_transaction():
|
|
183
|
+
# A completed bundle with missing members may reflect a deletion.
|
|
184
|
+
# Never repair it implicitly by recreating those records on retry.
|
|
185
|
+
if any(self.sub._lookup_record(record.record_id) is None
|
|
186
|
+
for record in [source, *observations]):
|
|
187
|
+
raise RecordConflict('completed ingestion is incomplete')
|
|
188
|
+
self._put_matching(source, ctx)
|
|
189
|
+
for observation in observations:
|
|
190
|
+
self._put_matching(observation, ctx)
|
|
191
|
+
if self.embedder is None:
|
|
192
|
+
return sid
|
|
193
|
+
# Existing vector rows carry no generation-configuration evidence.
|
|
194
|
+
# Recompute rather than treating their presence as a matching mode.
|
|
195
|
+
|
|
196
|
+
# Compute every embedding before writing the job, Source, or Observations. Model
|
|
197
|
+
# load/inference failures therefore cannot leave an apparently successful partial import.
|
|
198
|
+
paragraph_vectors = []
|
|
199
|
+
source_vector = None
|
|
200
|
+
if self.embedder is not None:
|
|
201
|
+
paragraph_vectors = [self.embedder.embed_document(paragraph) for paragraph in paragraphs]
|
|
202
|
+
if self.doc_vectors:
|
|
203
|
+
source_vector = self.embedder.embed_document(parsed[:DOC_HEAD_CHARS])
|
|
204
|
+
if self.doc_centroid and paragraph_vectors:
|
|
205
|
+
source_vector = _centroid(paragraph_vectors)
|
|
206
|
+
|
|
207
|
+
with self.sub.ingestion_transaction():
|
|
208
|
+
# Read completion under the write transaction: another ingestion may
|
|
209
|
+
# have completed and a member been deleted while embeddings ran.
|
|
210
|
+
current_state = self.sub.job_state(sid, "ingest", content_hash, scope=scope, ctx=ctx)
|
|
211
|
+
if (state == "done" or current_state == "done") and any(
|
|
212
|
+
self.sub._lookup_record(record.record_id) is None
|
|
213
|
+
for record in [source, *observations]):
|
|
214
|
+
raise RecordConflict('completed ingestion is incomplete')
|
|
215
|
+
self.sub.set_job(sid, "ingest", content_hash, "running", scope=scope, ctx=ctx)
|
|
216
|
+
self._put_matching(source, ctx)
|
|
217
|
+
# A preexisting source vector would contradict this requested mode.
|
|
218
|
+
# Refuse rather than silently retaining it under the same model ID.
|
|
219
|
+
if (self.embedder is not None and not self.doc_vectors and not self.doc_centroid
|
|
220
|
+
and self.sub.vector_has_record(sid, scope, ctx, model_id=self.embedder.model_id)):
|
|
221
|
+
raise ValueError('existing source vector conflicts with requested vector mode')
|
|
222
|
+
for i, observation in enumerate(observations):
|
|
223
|
+
oid = observation.record_id
|
|
224
|
+
self._put_matching(observation, ctx)
|
|
225
|
+
if self.embedder is not None:
|
|
226
|
+
self.sub.vector_put(oid, scope, paragraph_vectors[i], ctx=ctx, model_id=self.embedder.model_id)
|
|
227
|
+
if self.embedder is not None and source_vector is not None:
|
|
228
|
+
self.sub.vector_put(sid, scope, source_vector, ctx=ctx, model_id=self.embedder.model_id)
|
|
229
|
+
self.sub.set_job(sid, "ingest", content_hash, "done", scope=scope, ctx=ctx)
|
|
230
|
+
return sid
|
|
231
|
+
|
|
232
|
+
def ingest_media(self, name: str, data: bytes, kind: str, ctx, scope: str,
|
|
233
|
+
describer=None, transcriber=None):
|
|
234
|
+
"""Image or audio becomes machine-derived text. Never presented as verbatim (D3-6)."""
|
|
235
|
+
if kind == "image":
|
|
236
|
+
adapter = describer
|
|
237
|
+
elif kind == "audio":
|
|
238
|
+
adapter = transcriber
|
|
239
|
+
else:
|
|
240
|
+
return Rejection(name, "unsupported_kind")
|
|
241
|
+
if adapter is None:
|
|
242
|
+
return Rejection(name, "no_adapter_configured")
|
|
243
|
+
content_hash = _digest(data)
|
|
244
|
+
sid = "media-" + hashlib.sha256(f"{scope}\0{content_hash}".encode()).hexdigest()[:24]
|
|
245
|
+
stub = Record(record_id=sid, record_type="Source", scope=scope, payload={}, attribution="document")
|
|
246
|
+
if ctx is None or not self.sub.scope_engine.authorize_write(stub, ctx).allowed:
|
|
247
|
+
raise ScopeViolation("write not permitted")
|
|
248
|
+
with self.sub.ingestion_transaction():
|
|
249
|
+
row = self.sub._conn.execute(
|
|
250
|
+
"SELECT state FROM jobs WHERE scope=? AND source_id=? AND stage='derive' AND content_hash=?",
|
|
251
|
+
(scope, sid, content_hash),
|
|
252
|
+
).fetchone()
|
|
253
|
+
# The public job writer and an unkeyed digest cannot attest inference.
|
|
254
|
+
# Until trusted provenance exists, no completed media job is reusable.
|
|
255
|
+
if row and row[0] == 'done':
|
|
256
|
+
raise RecordConflict('completed media reuse is unavailable')
|
|
257
|
+
self.sub.set_job(sid, "derive", content_hash, "running", scope=scope, ctx=ctx)
|
|
258
|
+
try:
|
|
259
|
+
text = adapter(data)
|
|
260
|
+
except Exception:
|
|
261
|
+
self.sub.set_job(sid, "derive", content_hash, "failed", "adapter_failed", scope=scope, ctx=ctx)
|
|
262
|
+
return Rejection(name, "adapter_failed")
|
|
263
|
+
if isinstance(text, str):
|
|
264
|
+
text, _ = fence(text) # review G-P3-4: derived text is fenced like any other input
|
|
265
|
+
if not isinstance(text, str) or not text.strip():
|
|
266
|
+
self.sub.set_job(sid, "derive", content_hash, "failed", "empty_output", scope=scope, ctx=ctx)
|
|
267
|
+
return Rejection(name, "empty_output")
|
|
268
|
+
vector = None
|
|
269
|
+
if self.embedder is not None:
|
|
270
|
+
try:
|
|
271
|
+
vector = self.embedder.embed_document(text.strip())
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
try:
|
|
274
|
+
self.sub.set_job(sid, "derive", content_hash, "failed", "embedding_failed", scope=scope, ctx=ctx)
|
|
275
|
+
except Exception as job_exc:
|
|
276
|
+
exc.add_note(f"Could not record failed job: {job_exc}")
|
|
277
|
+
raise
|
|
278
|
+
failure_reason = "persistence_failed"
|
|
279
|
+
try:
|
|
280
|
+
with self.sub.ingestion_transaction():
|
|
281
|
+
model = getattr(adapter, "model_id", "unknown")
|
|
282
|
+
output = Record(
|
|
283
|
+
record_id=sid, record_type="Source", scope=scope, payload={"text": text.strip()},
|
|
284
|
+
attribution="media-derived", source_ids=[],
|
|
285
|
+
provenance={"machine_derived": True, "model": model, "kind": kind,
|
|
286
|
+
"name": name, "content_hash": content_hash},
|
|
287
|
+
)
|
|
288
|
+
# Reusing a preexisting output is not proof this adapter produced it.
|
|
289
|
+
self.sub.put(output, ctx)
|
|
290
|
+
if self.embedder is not None:
|
|
291
|
+
failure_reason = "embedding_failed"
|
|
292
|
+
self.sub.vector_put(sid, scope, vector, ctx=ctx, model_id=self.embedder.model_id)
|
|
293
|
+
failure_reason = "persistence_failed"
|
|
294
|
+
self.sub.set_job(sid, "derive", content_hash, "done", 'sha256:' + _record_digest(output), scope=scope, ctx=ctx)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
try:
|
|
297
|
+
self.sub.set_job(sid, "derive", content_hash, "failed", failure_reason, scope=scope, ctx=ctx)
|
|
298
|
+
except Exception as job_exc:
|
|
299
|
+
exc.add_note(f"Could not record failed job: {job_exc}")
|
|
300
|
+
raise
|
|
301
|
+
return sid
|
weft/knowledge.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Phase 2 knowledge rules (spec §3, D2-2, D2-3, D2-9).
|
|
2
|
+
|
|
3
|
+
A pure decision function over a record and the caller's context. Storage lookups
|
|
4
|
+
are passed in, so the rules stay testable and cannot reach around the store.
|
|
5
|
+
Returns a reason string when the write must be refused, or None when allowed.
|
|
6
|
+
Callers never show the reason to the writer (generic denial, G4/G10).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KnowledgeRefusal(Exception):
|
|
16
|
+
"""Raised inside a write transaction; the store turns it into a generic denial."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Rule:
|
|
21
|
+
# attribution -> statuses that attribution may produce for this type
|
|
22
|
+
statuses: dict = field(default_factory=dict)
|
|
23
|
+
needs_source: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _s(*names: str) -> frozenset:
|
|
27
|
+
return frozenset(names)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
RULES: dict[str, Rule] = {
|
|
31
|
+
"Person": Rule({"user": _s("stated"), "document": _s("stated")}),
|
|
32
|
+
"Agent": Rule({"system": _s("stated")}),
|
|
33
|
+
"Project": Rule({"user": _s("stated")}),
|
|
34
|
+
# Phase 3 (spec §1): assistant turns are agent-attributed Sources; fenced
|
|
35
|
+
# or system-injected text is system-attributed. Neither can yield Decisions.
|
|
36
|
+
"Source": Rule({
|
|
37
|
+
"document": _s("stated"), "media-derived": _s("stated"), "user": _s("stated"),
|
|
38
|
+
"agent": _s("stated"), "system": _s("stated"),
|
|
39
|
+
}),
|
|
40
|
+
"Conversation": Rule({"system": _s("stated")}),
|
|
41
|
+
"Observation": Rule(
|
|
42
|
+
{"document": _s("stated"), "media-derived": _s("stated")}, needs_source=True
|
|
43
|
+
),
|
|
44
|
+
"Claim": Rule({"agent": _s("derived"), "verifier": _s("verified")}),
|
|
45
|
+
"Hypothesis": Rule({"agent": _s("derived")}),
|
|
46
|
+
"Decision": Rule({"user": _s("stated")}),
|
|
47
|
+
"Commitment": Rule({"user": _s("stated")}),
|
|
48
|
+
"Correction": Rule({"user": _s("stated")}),
|
|
49
|
+
"Verification": Rule({"verifier": _s("stated")}),
|
|
50
|
+
"Deletion": Rule({"user": _s("stated")}),
|
|
51
|
+
"Summary": Rule({"system": _s("derived")}),
|
|
52
|
+
"RecallReceipt": Rule({"system": _s("stated")}),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_record(
|
|
57
|
+
record,
|
|
58
|
+
ctx,
|
|
59
|
+
*,
|
|
60
|
+
is_verifier: Callable[[str | None], bool],
|
|
61
|
+
lookup: Callable[[str], object | None],
|
|
62
|
+
) -> str | None:
|
|
63
|
+
"""Return a refusal reason, or None if the record may be written."""
|
|
64
|
+
rule = RULES.get(record.record_type)
|
|
65
|
+
if rule is None:
|
|
66
|
+
return "unknown record type"
|
|
67
|
+
attribution = record.attribution
|
|
68
|
+
if not attribution:
|
|
69
|
+
return "attribution required"
|
|
70
|
+
allowed = rule.statuses.get(attribution)
|
|
71
|
+
if allowed is None:
|
|
72
|
+
return "attribution not permitted for this record type"
|
|
73
|
+
if record.epistemic_status not in allowed:
|
|
74
|
+
return "status not permitted for this attribution"
|
|
75
|
+
if attribution == "user" and not ctx.user_authored:
|
|
76
|
+
# D2-2 / D2-7: "user" means the user's own turn. An agent cannot claim it.
|
|
77
|
+
return "user attribution requires a user-authored turn"
|
|
78
|
+
if rule.needs_source and not record.source_ids:
|
|
79
|
+
return "observation must cite a source"
|
|
80
|
+
if rule.needs_source:
|
|
81
|
+
for sid in record.source_ids:
|
|
82
|
+
src = lookup(sid)
|
|
83
|
+
# Same-scope only: a foreign or missing source is one generic refusal.
|
|
84
|
+
if src is None or src.scope != record.scope:
|
|
85
|
+
return "cited source not available"
|
|
86
|
+
if attribution == "verifier" and not is_verifier(ctx.actor):
|
|
87
|
+
return "actor is not a registered verifier"
|
|
88
|
+
if record.epistemic_status == "verified" and not _names_claim(record, lookup):
|
|
89
|
+
return "no verification names this claim"
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _names_claim(claim, lookup) -> bool:
|
|
94
|
+
"""D2-3: a verified Claim must cite a Verification that names it.
|
|
95
|
+
|
|
96
|
+
The Verification must be in the same scope as the Claim, so the answer
|
|
97
|
+
reveals nothing about records the writer cannot already see.
|
|
98
|
+
"""
|
|
99
|
+
for vid in claim.source_ids:
|
|
100
|
+
v = lookup(vid)
|
|
101
|
+
if (
|
|
102
|
+
v is not None
|
|
103
|
+
and v.record_type == "Verification"
|
|
104
|
+
and v.attribution == "verifier"
|
|
105
|
+
and v.scope == claim.scope
|
|
106
|
+
and v.payload.get("claim_id") == claim.record_id
|
|
107
|
+
):
|
|
108
|
+
return True
|
|
109
|
+
return False
|
weft/logging_config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Phase 6: structured logging with redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RedactingFormatter(logging.Formatter):
|
|
14
|
+
"""JSON formatter that redacts untrusted content fields."""
|
|
15
|
+
|
|
16
|
+
_PATTERNS = [
|
|
17
|
+
re.compile(r"<\s*UNTRUSTED_DOCUMENT\s*>", re.IGNORECASE),
|
|
18
|
+
re.compile(r"<\s*/\s*UNTRUSTED_DOCUMENT\s*>", re.IGNORECASE),
|
|
19
|
+
re.compile(r"\[\s*REDACTED\s+INJECTION\s*\]", re.IGNORECASE),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
23
|
+
message = super().format(record)
|
|
24
|
+
for pat in self._PATTERNS:
|
|
25
|
+
message = pat.sub("[REDACTED]", message)
|
|
26
|
+
return message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
30
|
+
"""Configure structured JSON logging with redaction."""
|
|
31
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
32
|
+
handler.setFormatter(RedactingFormatter("%(message)s"))
|
|
33
|
+
root = logging.getLogger()
|
|
34
|
+
root.handlers = [handler]
|
|
35
|
+
root.setLevel(getattr(logging, level, logging.INFO))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def log_event(
|
|
39
|
+
operation: str,
|
|
40
|
+
record_id: str | None = None,
|
|
41
|
+
scope: str | None = None,
|
|
42
|
+
detail: str = "",
|
|
43
|
+
level: str = "INFO",
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Emit a structured log event."""
|
|
46
|
+
entry = {
|
|
47
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
48
|
+
"level": level,
|
|
49
|
+
"operation": operation,
|
|
50
|
+
"record_id": record_id,
|
|
51
|
+
"scope": scope,
|
|
52
|
+
"detail": detail,
|
|
53
|
+
}
|
|
54
|
+
logging.getLogger("weft").log(getattr(logging, level, logging.INFO), json.dumps(entry))
|
weft/models.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Model layer for Weft: local and external models behind one interface.
|
|
2
|
+
|
|
3
|
+
Each task may use a different model. External calls require explicit opt-in
|
|
4
|
+
and are recorded in the receipt/audit trail.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ModelCall:
|
|
15
|
+
model_id: str
|
|
16
|
+
provider: str
|
|
17
|
+
task: str
|
|
18
|
+
input_chars: int = 0
|
|
19
|
+
output_chars: int = 0
|
|
20
|
+
egress: bool = False
|
|
21
|
+
error: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ModelResult:
|
|
26
|
+
text: str
|
|
27
|
+
model: ModelCall
|
|
28
|
+
raw: Any = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ModelLayerError(Exception):
|
|
32
|
+
"""Base error for model layer failures."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EgressPolicyError(ModelLayerError):
|
|
36
|
+
"""External inference was attempted without opt-in."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ModelUnavailable(ModelLayerError):
|
|
40
|
+
"""The requested model cannot be loaded or reached."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ModelLayer:
|
|
44
|
+
"""One interface for local and external inference."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, local_factory: Optional[Callable[[str], Any]] = None,
|
|
47
|
+
external_factory: Optional[Callable[[str, str], Any]] = None,
|
|
48
|
+
egress_allowed: bool = False) -> None:
|
|
49
|
+
self.local_factory = local_factory
|
|
50
|
+
self.external_factory = external_factory
|
|
51
|
+
self.egress_allowed = egress_allowed
|
|
52
|
+
self.calls: List[ModelCall] = []
|
|
53
|
+
|
|
54
|
+
def _record(self, call: ModelCall, text: str) -> ModelResult:
|
|
55
|
+
self.calls.append(call)
|
|
56
|
+
return ModelResult(text=text, model=call)
|
|
57
|
+
|
|
58
|
+
def _run_local(self, model_id: str, prompt: str, task: str) -> ModelResult:
|
|
59
|
+
if self.local_factory is None:
|
|
60
|
+
raise ModelUnavailable(f"no local model configured for {task}")
|
|
61
|
+
model = self.local_factory(model_id)
|
|
62
|
+
call = ModelCall(model_id=model_id, provider="local", task=task,
|
|
63
|
+
input_chars=len(prompt))
|
|
64
|
+
try:
|
|
65
|
+
text = model(prompt)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
call.error = str(exc)
|
|
68
|
+
self.calls.append(call)
|
|
69
|
+
raise ModelLayerError(f"local model failed: {exc}") from exc
|
|
70
|
+
call.output_chars = len(text)
|
|
71
|
+
return self._record(call, text)
|
|
72
|
+
|
|
73
|
+
def _run_external(self, model_id: str, prompt: str, task: str, base_url: str, api_key: str) -> ModelResult:
|
|
74
|
+
if not self.egress_allowed:
|
|
75
|
+
raise EgressPolicyError(f"external inference requires opt-in for {task}")
|
|
76
|
+
if self.external_factory is None:
|
|
77
|
+
raise ModelUnavailable(f"no external factory configured for {task}")
|
|
78
|
+
model = self.external_factory(model_id, base_url)
|
|
79
|
+
call = ModelCall(model_id=model_id, provider="external", task=task,
|
|
80
|
+
input_chars=len(prompt), egress=True)
|
|
81
|
+
try:
|
|
82
|
+
text = model(prompt, api_key=api_key)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
call.error = str(exc)
|
|
85
|
+
self.calls.append(call)
|
|
86
|
+
raise ModelLayerError(f"external model failed: {exc}") from exc
|
|
87
|
+
call.output_chars = len(text)
|
|
88
|
+
return self._record(call, text)
|
|
89
|
+
|
|
90
|
+
def run(self, model_id: str, prompt: str, task: str, *,
|
|
91
|
+
base_url: str = "", api_key: str = "", force_local: bool = False) -> ModelResult:
|
|
92
|
+
"""Run a prompt through the appropriate model.
|
|
93
|
+
|
|
94
|
+
External calls require `egress_allowed=True` unless `force_local=True`.
|
|
95
|
+
Returns the result and records the call for audit.
|
|
96
|
+
"""
|
|
97
|
+
if force_local or not base_url or not api_key:
|
|
98
|
+
return self._run_local(model_id, prompt, task)
|
|
99
|
+
return self._run_external(model_id, prompt, task, base_url, api_key)
|