File size: 41,430 Bytes
366471b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
"""Per-session document operations: copy outside lock, merge/validate/save under lock."""
from __future__ import annotations

import copy
from dataclasses import asdict, dataclass, field
import time
from uuid import uuid4

from sofiel_documents import (DocumentError, Limits, digest, empty_store, extract_isolated,
    memory_index, now, packed, reconcile_memory_refs, retrieve, validate_store)
from sofiel_evidence import structured_payload_budget


@dataclass
class OperationResult:
    status: str
    operation_id: str
    error_code: str | None = None
    extraction_status: str = "pending"
    interpretation_status: str = "pending"
    incorporated: int = 0
    pending_segments: int = 0
    coverage: dict = field(default_factory=dict)
    warnings: list = field(default_factory=list)


class LegacyDocumentService:
    def __init__(self, owner, limits=None):
        self.owner = owner
        self.limits = limits or Limits.from_env()

    def evidence(self, query):
        # No stale indexes or old list references after restore; live portable state only.
        return retrieve(self.owner.memory.data.get("documents"), query,
            atoms=getattr(getattr(self.owner, "synaptic_manager", None), "kernel", []))

    def _context(self, query):
        persisted = memory_index(self.owner.memory.data.get("chats", []))
        terms = set(str(query).casefold().split())
        ranked = sorted(persisted.items(), key=lambda row: -sum(t in packed(row[1]).casefold() for t in terms))
        chosen = dict(ranked[:3])
        # The reference ID always hashes full saved fields; payload text alone is bounded.
        return {"human_message": str(query)[:700],
            "memories": {k: {f: str(v)[:350] for f, v in row.items()} for k, row in chosen.items()},
            "symbolic_traits": copy.deepcopy(self.owner.memory.data.get("self", {}).get("traits", {})),
            "purposes": str(self.owner.memory.data.get("purpose_memory", {}))[:500]}

    def ingest(self, path=None, *, query="", source_id=None, operation_id=None, encoding=None, delimiter=None,

               extractor=None):
        operation_id = operation_id or "op_" + uuid4().hex
        owner = self.owner
        if owner is None or not hasattr(owner, "state_lock") or not getattr(owner, "memory", None):
            return OperationResult("failed", operation_id, "session_missing")
        if not isinstance(operation_id, str) or len(operation_id) > 100:
            return OperationResult("failed", "", "invalid_operation_id")
        with owner.state_lock:
            try:
                store = validate_store(owner.memory.data["documents"], self.limits) if "documents" in owner.memory.data else empty_store()
            except DocumentError as exc:
                return OperationResult("failed", operation_id, exc.code)
            existing = store["operations"].get(operation_id)
            if existing:
                return OperationResult(**existing["result"])
            epoch = getattr(owner, "_ui_epoch", 0)
            memory_object = owner.memory
            memory_data = owner.memory.data
            revision = store["revision"]
            context = self._context(query)
        previous = store["sources"].get(source_id) if source_id else None
        if source_id and previous is None:
            return OperationResult("failed", operation_id, "unknown_source")
        result = OperationResult("pending", operation_id)
        extraction = None
        # First finish interpreting retained segments. Subsequent continuation can extend extraction.
        analyzed_before = {s for op in store["operations"].values() for s in op["analyzed_segment_ids"]}
        pending_retained = [s for s in store["segments"].values() if s["source_id"] == source_id and s["segment_id"] not in analyzed_before]
        if path and (not previous or not pending_retained):
            extractor = extractor or extract_isolated
            try:
                extraction = extractor(path, limits=self.limits, operation_id=operation_id, instance_id=store["instance_id"],
                    cursor=previous["coverage"]["cursor"] if previous else None,
                    expected_hash=previous["sha256"] if previous else None, encoding=encoding, delimiter=delimiter)
            except Exception:
                # No commit or generation has happened yet. Preserve the session and
                # return a public code rather than leaving a UI operation unresolved.
                return OperationResult("failed", operation_id, "document_extraction_failed", extraction_status="failed")
            if extraction.error_code:
                return OperationResult("failed", operation_id, extraction.error_code, extraction_status="failed", warnings=extraction.warnings)
            source_id = extraction.source["source_id"]
            previous = store["sources"].get(source_id)
            if previous:
                # Repeated upload is idempotent by bytes. Continue only at the confirmed cursor.
                if not pending_retained and previous["coverage"]["cursor"] is None:
                    extraction.segments = {}
                    extraction.source = copy.deepcopy(previous)
                else:
                    for bucket in ("visited", "empty", "failed"):
                        extraction.source["coverage"][bucket] = sorted(set(previous["coverage"][bucket] + extraction.source["coverage"][bucket]))
                    for key in ("created_at", "active", "participant", "declared_authors"):
                        extraction.source[key] = previous[key]
            store["sources"][source_id] = extraction.source
            for key, segment in extraction.segments.items():
                if key in store["segments"] and store["segments"][key] != segment:
                    return OperationResult("failed", operation_id, "conflicting_segment")
                store["segments"][key] = segment
            result.extraction_status = extraction.status
            result.warnings.extend(extraction.warnings)
        elif not previous:
            return OperationResult("failed", operation_id, "file_required")
        source = store["sources"][source_id]
        result.coverage = copy.deepcopy(source["coverage"])
        if result.extraction_status == "pending":
            result.extraction_status = "partial" if source["coverage"]["cursor"] or source["coverage"]["failed"] else "complete"
        if source["coverage"]["cursor"] and not path:
            result.warnings.append("Recarg谩 el mismo original para extraer lo a煤n no retenido; se comprobar谩 su hash.")
        op = {"operation_id": operation_id, "instance_id": store["instance_id"], "created_at": now(),
              "submitted_segment_ids": [], "analyzed_segment_ids": [], "attempts": [], "result": {}}
        store["operations"][operation_id] = op
        # Reject quota violations before inference, too.
        try:
            validate_store(store, self.limits)
        except DocumentError as exc:
            return OperationResult("failed", operation_id, exc.code)
        pending = [s for s in store["segments"].values() if s["source_id"] == source_id and s["segment_id"] not in analyzed_before]
        pending.sort(key=lambda s: (s["locator"]["unit"], s["char_start"]))
        deadline = time.monotonic() + self.limits.interpretation_seconds
        calls = 0
        while pending and calls < self.limits.calls and time.monotonic() < deadline:
            # One full segment at a time, so output/citations cannot erase a long block's tail.
            segment = pending[0]
            payload = {"segments": [segment], "source": {k: source[k] for k in ("source_id", "name", "declared_authors")},
                       "context": context, "existing_knowledge": [
                           {"knowledge_id": k["knowledge_id"], "statement": k["statement"][:250], "attribution": k["attribution"]}
                           for k in list(store["knowledge"].values())[-3:]]}
            # Context must fit a conservative character/token bound without cutting a segment.
            while len(packed(payload)) > min(4900, structured_payload_budget(max_tokens=1200)) and payload["context"]["memories"]:
                payload["context"]["memories"].pop(next(iter(payload["context"]["memories"])))
            bridge = getattr(owner, "llm_bridge", None)
            if bridge is None or not hasattr(bridge, "generate_structured"):
                result.error_code = "structured_provider_unavailable"
                break
            calls += 1
            op["submitted_segment_ids"] = list(dict.fromkeys(op["submitted_segment_ids"] + [segment["segment_id"]]))
            try:
                inferred = bridge.generate_structured(payload, operation_id=operation_id,
                    timeout=min(30, max(0, deadline-time.monotonic())), max_tokens=1200)
            except Exception:
                result.error_code = "structured_provider_failed"
                break
            op["attempts"].append({"status": inferred.status, "error_code": inferred.error_code,
                                   "finish_reason": inferred.finish_reason, "truncated": inferred.truncated})
            if time.monotonic() > deadline:
                result.error_code = "interpretation_timeout"
                break
            if inferred.status != "complete":
                result.error_code = inferred.error_code
                if inferred.error_code in {"structured_provider_unavailable", "context_budget_exceeded"}:
                    break
                continue
            try:
                records, analyzed = self._admit(inferred.value, payload, store, operation_id)
            except (DocumentError, TypeError, ValueError, KeyError, AttributeError) as exc:
                result.error_code = exc.code if isinstance(exc, DocumentError) else "invalid_interpretation_structure"
                continue
            result.error_code = None
            op["analyzed_segment_ids"].extend(analyzed)
            for record in records:
                if record["knowledge_id"] not in store["knowledge"]:
                    store["knowledge"][record["knowledge_id"]] = record
                    result.incorporated += record["state"] == "incorporated"
            pending.pop(0)
        result.pending_segments = len(pending)
        result.interpretation_status = "partial" if pending and op["analyzed_segment_ids"] else "failed" if pending else "complete"
        if pending and result.error_code is None:
            result.error_code = "interpretation_budget_exhausted"
        result.status = "partial" if pending or result.extraction_status == "partial" else "complete"
        result.warnings.append("Soporte textual y esquema verificados; ni verdad factual ni implicaci贸n sem谩ntica est谩n demostradas.")
        op["result"] = asdict(result)
        with owner.state_lock:
            if owner.memory is not memory_object or owner.memory.data is not memory_data or getattr(owner, "_ui_epoch", 0) != epoch:
                return OperationResult("failed", operation_id, "session_changed")
            current = owner.memory.data.get("documents")
            if current and (current["revision"] != revision or current["instance_id"] != store["instance_id"]):
                return OperationResult("failed", operation_id, "document_revision_changed")
            reconcile_memory_refs(store, owner.memory.data.get("chats", []))
            store["revision"] += 1
            try:
                validated = validate_store(store, self.limits)
            except DocumentError as exc:
                return OperationResult("failed", operation_id, exc.code)
            prior_metadata = copy.deepcopy(owner.memory.data.get("metadata", {}))
            owner.memory.data["documents"] = validated
            try:
                if not owner.memory.save():
                    raise DocumentError("document_save_failed")
            except Exception:
                if current is None:
                    owner.memory.data.pop("documents", None)
                else:
                    owner.memory.data["documents"] = current
                owner.memory.data["metadata"] = prior_metadata
                return OperationResult("failed", operation_id, "document_save_failed")
            realtime = getattr(owner, "realtime_memory", None)
            if realtime:
                realtime.cached_data = copy.deepcopy(owner.memory.data)
            return result

    def _admit(self, value, payload, store, operation_id):
        if not isinstance(value, dict) or not isinstance(value.get("claims"), list) or len(value["claims"]) > 8:
            raise DocumentError("invalid_claims")
        allowed = {s["segment_id"]: s for s in payload["segments"]}
        analyzed = value.get("analyzed_segment_ids")
        if not isinstance(analyzed, list) or set(analyzed) != set(allowed) or len(analyzed) != len(set(analyzed)):
            raise DocumentError("invalid_analyzed_ids")
        records = []
        for claim in value["claims"]:
            if not isinstance(claim, dict):
                raise DocumentError("invalid_claim")
            refs = claim.get("support_refs")
            if not isinstance(refs, list) or not refs:
                raise DocumentError("missing_support")
            for ref in refs:
                if not isinstance(ref, dict) or ref.get("segment_id") not in allowed:
                    raise DocumentError("invented_segment")
                quote = ref.get("quote")
                if not isinstance(quote, str) or not quote.strip() or quote not in allowed[ref["segment_id"]]["text"]:
                    raise DocumentError("unsupported_quote")
            attribution = claim.get("attribution")
            if not isinstance(attribution, dict):
                raise DocumentError("invalid_attribution")
            label = attribution.get("label", "")
            if not isinstance(label, str):
                raise DocumentError("invalid_attribution_label")
            if label and not any(label in r["quote"] for r in refs) and label not in payload["source"]["declared_authors"]:
                raise DocumentError("unsupported_attribution_label")
            memory_refs = claim.get("memory_refs", [])
            if not isinstance(memory_refs, list) or any(not isinstance(r, str) or r not in payload["context"]["memories"] for r in memory_refs):
                raise DocumentError("invented_memory")
            relations = claim.get("relations", [])
            existing = {k["knowledge_id"] for k in payload["existing_knowledge"]}
            if not isinstance(relations, list) or any(not isinstance(r, dict) or r.get("target_id") not in existing for r in relations):
                raise DocumentError("invented_relation")
            content = {key: copy.deepcopy(claim.get(key)) for key in ("statement", "claim_kind", "attribution", "support_refs", "relevance_reasons")}
            key = "kn_" + digest(packed({"source": payload["source"]["source_id"],
                **{k: v for k, v in content.items() if k != "relevance_reasons"}}))
            reasons = content.get("relevance_reasons")
            state = "incorporated" if isinstance(reasons, list) and reasons else "candidate"
            record = {**content, "knowledge_id": key, "source_id": payload["source"]["source_id"],
                "instance_id": store["instance_id"], "operation_id": operation_id, "state": state,
                "created_at": now(), "incorporated_at": now() if state == "incorporated" else None,
                "memory_refs": [{"memory_id": r, "status": "resolved"} for r in memory_refs], "relations": relations}
            check = copy.deepcopy(store)
            check["knowledge"][key] = record
            validate_store(check, self.limits)
            records.append(record)
        return records, analyzed

    def set_active(self, source_id, active):
        if type(active) is not bool:
            raise DocumentError("invalid_active")
        owner = self.owner
        with owner.state_lock:
            previous = owner.memory.data.get("documents")
            if not previous or source_id not in previous["sources"]:
                raise DocumentError("unknown_source")
            updated = copy.deepcopy(previous)
            updated["sources"][source_id]["active"] = active
            updated["revision"] += 1
            prior_metadata = copy.deepcopy(owner.memory.data.get("metadata", {}))
            owner.memory.data["documents"] = validate_store(updated, self.limits)
            try:
                if not owner.memory.save():
                    raise DocumentError("document_save_failed")
            except Exception:
                owner.memory.data["documents"] = previous
                owner.memory.data["metadata"] = prior_metadata
                raise DocumentError("document_save_failed")
            realtime = getattr(owner, "realtime_memory", None)
            if realtime:
                realtime.cached_data = copy.deepcopy(owner.memory.data)


class DocumentService(LegacyDocumentService):
    """v2 writer; legacy validation remains available for migration and regression fixtures."""
    def __init__(self, owner, limits=None):
        super().__init__(owner, limits)
        from sofiel_selective import DocumentSessionStore
        self.session = DocumentSessionStore()

    def clear_session(self):
        self.session.clear()

    def evidence(self, query, *, focus=None, max_chars=12000):
        from sofiel_selective import retrieve_v2, search_originals
        with self.owner.state_lock:
            store = self.owner.memory.data.get('documents')
            if not store: return {}
            if store.get('schema_version') == 1: return super().evidence(query)
            session_token=self.session.read(self.owner)
            session=copy.deepcopy(session_token)
            originals=dict(self.session.originals)
            memory=self.owner.memory; data=memory.data; epoch=getattr(self.owner,'_ui_epoch',0)
            store=copy.deepcopy(store)
            atoms=copy.deepcopy(getattr(getattr(self.owner,'synaptic_manager',None),'kernel',[]))
        # Parsing is outside the state lock and never modifies the consolidation cursor.
        extra,diagnostics=search_originals(store,session,originals,query,self.limits,extract_isolated,focus=focus)
        transient={'segments':dict((session or {}).get('segments',{}))}
        transient['segments'].update(extra)
        reserve=len(packed(diagnostics))+30 if diagnostics else 0
        out=retrieve_v2(store,query,atoms=atoms,session=transient,focus=focus,
                        max_chars=max(0,max_chars-reserve),original_sources=originals)
        if diagnostics:
            candidate={**out,'original_search':diagnostics}
            if len(packed(candidate))<=max_chars: out=candidate
        with self.owner.state_lock:
            current=self.owner.memory.data.get('documents',{})
            if (self.owner.memory is not memory or self.owner.memory.data is not data
                or getattr(self.owner,'_ui_epoch',0)!=epoch or current.get('revision')!=store['revision']
                or current.get('instance_id')!=store['instance_id']
                or self.session.data is not session_token or self.session.originals!=originals): return {}
        return out

    def view_store(self):
        """Compatibility presentation only; never serialized or installed in memory."""
        from sofiel_selective import passage
        store=self.owner.memory.data.get('documents',{})
        if store.get('schema_version') != 2: return store
        view=copy.deepcopy(store); view['knowledge']={}
        for key,k in store['insights'].items():
            row=copy.deepcopy(k)
            row['support_refs']=[{'segment_id':r['excerpt_id'],'quote':store['excerpts'][r['excerpt_id']]['text']} for r in k['support_refs']]
            view['knowledge'][key]=row
        return view

    def _durable(self, store):
        from sofiel_selective import empty_v2, migrate_v1
        if not store: return empty_v2()
        return migrate_v1(store,self.owner.memory.data,self.limits) if store.get('schema_version') == 1 else validate_store(store,self.limits)

    def ingest(self, path=None, *, query='', source_id=None, operation_id=None, encoding=None, delimiter=None, extractor=None):
        from sofiel_selective import make_event, add_excerpt, validate_v2, record_document_authors, purpose_index, reconcile_purpose_refs, fit_session, event_context
        from pathlib import Path
        operation_id=operation_id or 'op_'+uuid4().hex
        owner=self.owner
        if owner is None or not hasattr(owner,'state_lock') or not getattr(owner,'memory',None):
            return OperationResult('failed',operation_id,'session_missing')
        if not isinstance(operation_id,str) or not operation_id or len(operation_id)>100:
            return OperationResult('failed','','invalid_operation_id')
        with owner.state_lock:
            raw=owner.memory.data.get('documents')
            try: durable=self._durable(raw)
            except DocumentError as exc: return OperationResult('failed',operation_id,exc.code)
            if operation_id in durable['operations']:
                prior=durable['operations'][operation_id]
                if source_id and prior['source_id']!=source_id:
                    return OperationResult('failed',operation_id,'operation_id_conflict')
                if path:
                    try:
                        with Path(path).open('rb') as stream:
                            submitted=stream.read(self.limits.file_bytes+1)
                        if len(submitted)>self.limits.file_bytes or 'src_'+digest(submitted)!=prior['source_id']:
                            return OperationResult('failed',operation_id,'operation_id_conflict')
                    except OSError: return OperationResult('failed',operation_id,'file_required')
                return OperationResult(**prior['result'])
            epoch=getattr(owner,'_ui_epoch',0); memory=owner.memory; data=memory.data
            original_revision=raw.get('revision',0) if raw else 0
            cached=self.session.read(owner)
            work=copy.deepcopy(cached) if cached else empty_store(durable['instance_id'])
            work['sources']=copy.deepcopy(durable['sources'])
            work.setdefault('analyzed',[])
            context=self._context(query)
            context['purpose_refs']=dict(list(purpose_index(data).items())[-5:])
            participant=data.get('user_profile',{}).get('nombre_primario') or data.get('user_profile',{}).get('name')
        previous=work['sources'].get(source_id) if source_id else None
        if source_id and previous is None: return OperationResult('failed',operation_id,'unknown_source')
        shared_upload=bool(path)
        if not path and cached and source_id in self.session.originals:
            path=self.session.originals[source_id]
        result=OperationResult('pending',operation_id)
        pending_before=[s for s in work['segments'].values() if s['source_id']==source_id and s['segment_id'] not in work['analyzed']]
        if path:
            # A reload after restore must start extraction at the beginning: unselected text is absent.
            cursor=previous['coverage']['cursor'] if previous and cached and not pending_before else None
            if previous and pending_before: cursor=None
            try:
                extraction=(extractor or extract_isolated)(path,limits=self.limits,operation_id=operation_id,
                    instance_id=durable['instance_id'],cursor=cursor,expected_hash=previous['sha256'] if previous else None,
                    encoding=encoding,delimiter=delimiter)
            except Exception: return OperationResult('failed',operation_id,'document_extraction_failed',extraction_status='failed')
            if extraction.error_code: return OperationResult('failed',operation_id,extraction.error_code,extraction_status='failed',warnings=extraction.warnings)
            source_id=extraction.source['source_id']; previous=work['sources'].get(source_id)
            source=copy.deepcopy(extraction.source)
            if previous:
                for key in ('created_at','active','participant','declared_authors'): source[key]=previous[key]
                if not shared_upload: source['name']=previous['name']
                if cached:
                    for bucket in ('visited','empty','failed'):
                        source['coverage'][bucket]=sorted(set(source['coverage'][bucket]+previous['coverage'][bucket]))
                    if pending_before: source['coverage']=copy.deepcopy(previous['coverage'])
            source['availability']='reload_original'
            work['sources'][source_id]=source
            work['segments'].update(copy.deepcopy(extraction.segments))
            result.extraction_status=extraction.status
            result.warnings=extraction.warnings[:4]
        elif not previous: return OperationResult('failed',operation_id,'file_required')
        elif not cached or not any(s['source_id']==source_id for s in work['segments'].values()):
            return OperationResult('failed',operation_id,'original_required')
        source=work['sources'][source_id]; durable['sources']=copy.deepcopy(work['sources'])
        result.coverage=copy.deepcopy(source['coverage'])
        if result.extraction_status=='pending':
            result.extraction_status='partial' if source['coverage']['cursor'] or source['coverage']['failed'] else 'complete'
        # Every new send has its own event; continuation retains the latest declared purpose.
        prior_events=sorted((e for e in durable['sharing_events'].values() if e['source_id']==source_id), key=lambda e:e['created_at'])
        try:
            event=make_event(source_id,query,participant,event_id='share_'+digest(operation_id)) if shared_upload or not prior_events else copy.deepcopy(prior_events[-1])
            record_document_authors(event,(s for s in work['segments'].values() if s['source_id']==source_id))
            durable['sharing_events'][event['event_id']]=event
            if shared_upload and prior_events and prior_events[-1]['stated_purpose'] != event['stated_purpose']:
                # A new purpose may reconsider cached windows, within the same call budget.
                source_segments={s['segment_id'] for s in work['segments'].values() if s['source_id']==source_id}
                work['analyzed']=[sid for sid in work['analyzed'] if sid not in source_segments]
            context['sharing_event']=event_context(event)
            # Bound both temporary and durable data before any provider call. No silent eviction.
            fit_session(work,self.limits)
            if len(durable['operations'])>=self.limits.operations: raise DocumentError('document_history_limit')
            if path and source_id not in self.session.originals:
                total=sum(Path(p).stat().st_size for p in self.session.originals.values())+Path(path).stat().st_size
                if total>self.limits.session_file_bytes: raise DocumentError('document_session_file_limit')
            validate_v2(durable,self.limits)
        except (OSError,DocumentError) as exc:
            return OperationResult('failed',operation_id,exc.code if isinstance(exc,DocumentError) else 'document_original_cache_failed')
        pending=sorted((s for s in work['segments'].values() if s['source_id']==source_id and s['segment_id'] not in work['analyzed']),
                       key=lambda s:(s['locator']['unit'],s['char_start']))
        attempts=[]; admitted=0; deadline=time.monotonic()+self.limits.interpretation_seconds
        # One bounded response per segment. A truncated response is never retried identically in this operation.
        while pending and len(attempts)<self.limits.calls and time.monotonic()<deadline:
            segment=pending[0]
            payload={'segments':[segment], 'source':{k:source[k] for k in ('source_id','name','declared_authors')},
                     'context':copy.deepcopy(context), 'existing_knowledge':[
                        {'knowledge_id':k['knowledge_id'],'statement':k['statement'][:250],'attribution':k['attribution']}
                        for k in list(durable['insights'].values())[-3:]]}
            # Full declarations stay durable. The provider receives identified bounded excerpts.
            payload['context']['sharing_event']['message']=event['message'][:350]
            payload['context']['sharing_event']['stated_purpose']=event['stated_purpose'][:350]
            payload['context']['sharing_event']['corrections']=event['corrections'][-1:]
            while len(packed(payload))>min(4900,structured_payload_budget(max_tokens=1200)) and payload['context']['memories']:
                payload['context']['memories'].pop(next(iter(payload['context']['memories'])))
            bridge=getattr(owner,'llm_bridge',None)
            if bridge is None or not hasattr(bridge,'generate_structured'):
                result.error_code='structured_provider_unavailable'; break
            try:
                inferred=bridge.generate_structured(payload,operation_id=operation_id,
                    timeout=min(30,max(0,deadline-time.monotonic())),max_tokens=1200)
            except Exception:
                result.error_code='structured_provider_failed'; break
            attempts.append({'status':inferred.status,'error_code':inferred.error_code,
                             'finish_reason':inferred.finish_reason,'truncated':inferred.truncated})
            if time.monotonic()>deadline: result.error_code='interpretation_timeout'; break
            if inferred.status!='complete': result.error_code=inferred.error_code; break
            try:
                self._select(inferred.value,payload,durable,event,self.limits)
            except (DocumentError,TypeError,ValueError,KeyError,AttributeError) as exc:
                if isinstance(exc,DocumentError) and exc.code in {'selection_limit','source_store_limit','document_store_limit','document_history_limit'}:
                    return OperationResult('failed',operation_id,exc.code)
                result.error_code=exc.code if isinstance(exc,DocumentError) else 'invalid_interpretation_structure'; break
            work.setdefault('candidates',{})[segment['segment_id']]=copy.deepcopy(inferred.value)
            work['analyzed'].append(segment['segment_id']); pending.pop(0); admitted+=1
        result.pending_segments=len(pending)
        result.interpretation_status='partial' if pending and admitted else 'failed' if pending else 'complete'
        if pending and not result.error_code: result.error_code='interpretation_budget_exhausted'
        result.incorporated=len(durable['insights'])-len(raw.get('insights',raw.get('knowledge',{})) if raw else {})
        result.incorporated=max(0,result.incorporated)
        result.status='partial' if pending or result.extraction_status=='partial' else 'complete'
        result.warnings.append('Selecci贸n provisional de lo le铆do; las citas no prueban la verdad ni la interpretaci贸n.')
        durable['operations'][operation_id]={'operation_id':operation_id,'created_at':now(),'event_id':event['event_id'],
            'source_id':source_id,'result':asdict(result),'diagnostics':attempts}
        durable['revision']+=1
        with owner.state_lock:
            if owner.memory is not memory or owner.memory.data is not data or getattr(owner,'_ui_epoch',0)!=epoch:
                return OperationResult('failed',operation_id,'session_changed')
            current=owner.memory.data.get('documents')
            if (current.get('revision',0) if current else 0)!=original_revision or (current and current.get('instance_id')!=durable['instance_id']):
                return OperationResult('failed',operation_id,'document_revision_changed')
            reconcile_memory_refs(durable,owner.memory.data.get('chats',[]))
            reconcile_purpose_refs(durable,owner.memory.data)
            try:
                validated=validate_v2(durable,self.limits)
                fit_session(work,self.limits)
                if work.get('evicted_windows'):
                    result.warnings.append('Cach茅 acotada: algunas ventanas ya interpretadas requieren releer el original; las selecciones guardadas se conservan.')
                    validated['operations'][operation_id]['result']=asdict(result)
                    validated=validate_v2(validated,self.limits)
                staged=self.session.stage_original(path,source_id,self.limits) if path else None
            except (OSError,DocumentError) as exc:
                return OperationResult('failed',operation_id,exc.code if isinstance(exc,DocumentError) else 'document_original_cache_failed')
            try:
                self._commit(validated,current)
            except DocumentError as exc:
                if staged:
                    from pathlib import Path
                    Path(staged).unlink(missing_ok=True)
                return OperationResult('failed',operation_id,exc.code)
            self.session.install(owner,work,self.limits)
            if staged: self.session.originals[source_id]=staged
        return result

    def _select(self,value,payload,durable,event,limits):
        from sofiel_selective import add_excerpt,validate_v2,effective_authorship
        import re
        if not isinstance(value,dict) or not isinstance(value.get('claims'),list) or len(value['claims'])>8:
            raise DocumentError('invalid_claims')
        allowed={s['segment_id']:s for s in payload['segments']}
        ids=value.get('analyzed_segment_ids')
        if not isinstance(ids,list) or set(ids)!=set(allowed) or len(ids)!=len(set(ids)): raise DocumentError('invalid_analyzed_ids')
        candidate=copy.deepcopy(durable)
        for claim in value['claims']:
            if not isinstance(claim,dict): raise DocumentError('invalid_claim')
            refs=claim.get('support_refs'); attribution=claim.get('attribution')
            if not isinstance(refs,list) or not refs or len(refs)>8: raise DocumentError('missing_support')
            if not isinstance(attribution,dict): raise DocumentError('invalid_attribution')
            for ref in refs:
                if not isinstance(ref,dict) or ref.get('segment_id') not in allowed: raise DocumentError('invented_segment')
                quote=ref.get('quote')
                if not isinstance(quote,str) or not quote.strip() or quote not in allowed[ref['segment_id']]['text']: raise DocumentError('unsupported_quote')
            label=attribution.get('label','')
            source_authors=[] if any(c['author'] for c in event['corrections']) else payload['source']['declared_authors']
            declared=(source_authors+[a['label'] for a in effective_authorship(event)
                      if a['status'] in {'user_declared','document_reported'}]) if attribution.get('role')=='author' else []
            if not isinstance(label,str) or (label and not any(label in r['quote'] for r in refs) and label not in declared): raise DocumentError('unsupported_attribution_label')
            memories=claim.get('memory_refs',[]); relations=claim.get('relations',[])
            if not isinstance(memories,list) or any(m not in payload['context']['memories'] for m in memories): raise DocumentError('invented_memory')
            purposes=claim.get('purpose_refs',[])
            if not isinstance(purposes,list) or any(p not in payload['context'].get('purpose_refs',{}) for p in purposes): raise DocumentError('invented_purpose')
            known={k['knowledge_id'] for k in payload['existing_knowledge']}
            if not isinstance(relations,list) or any(not isinstance(r,dict) or r.get('target_id') not in known for r in relations): raise DocumentError('invented_relation')
            reasons=claim.get('relevance_reasons',[])
            if not isinstance(reasons,list) or any(not isinstance(r,str) for r in reasons): raise DocumentError('invalid_relevance')
            # Structural grounding is necessary, not proof of semantic entailment.
            basis=payload['context']['human_message']+' '+payload['context'].get('sharing_event',{}).get('stated_purpose','')+' '+packed(payload['context']['memories'])+' '+packed(payload['context']['symbolic_traits'])+' '+payload['context']['purposes']+' '+' '.join(r['quote'] for r in refs)
            terms=set(re.findall(r'\w{4,}',basis.casefold()))-{'este','esta','texto','documento','para','como','with','that','source','user','sofiel'}
            grounded=[r for r in reasons if len(r.strip())>=24 and len(set(re.findall(r'\w{4,}',r.casefold())) & terms)>=1
                      and not re.fullmatch(r'(es |muy )?(profundo|interesante|relevante)[.! ]*',r.casefold())]
            if not grounded: continue
            support=[{'excerpt_id':add_excerpt(candidate,allowed[r['segment_id']],r['quote'],attribution,limits)} for r in refs]
            sid=payload['source']['source_id']
            key='kn_'+digest(packed({'source':sid,'statement':claim.get('statement'),'kind':claim.get('claim_kind'),'voice':attribution,'support':support}))
            if key in candidate['insights']:
                row=candidate['insights'][key]
                row['event_ids']=list(dict.fromkeys(row['event_ids']+[event['event_id']]))
                continue
            row={'knowledge_id':key,'source_id':sid,'statement':claim.get('statement'),'claim_kind':claim.get('claim_kind'),
                 'attribution':copy.deepcopy(attribution),'support_refs':support,'relevance_reasons':grounded,'state':'incorporated',
                 'created_at':now(),'incorporated_at':now(),'memory_refs':[{'memory_id':m,'status':'resolved'} for m in memories],
                 'purpose_refs':[{'purpose_id':p,'status':'resolved'} for p in purposes],
                 'relations':copy.deepcopy(relations),'event_ids':[event['event_id']],'review_state':'selection_provisional'}
            candidate['insights'][key]=row
        validate_v2(candidate,limits)
        durable.clear();durable.update(candidate)

    def _commit(self, updated, previous):
        owner=self.owner
        metadata=copy.deepcopy(owner.memory.data.get('metadata',{}))
        if previous and previous.get('schema_version') == 1:
            from sofiel_selective import backup_migration
            try:
                backup_migration(owner.memory.data, owner.memory.memory_file)
            except (AttributeError, OSError, ValueError, TypeError):
                raise DocumentError('migration_backup_failed') from None
        owner.memory.data['documents']=updated
        try:
            if not owner.memory.save(): raise DocumentError('document_save_failed')
        except Exception:
            if previous is None: owner.memory.data.pop('documents',None)
            else: owner.memory.data['documents']=previous
            owner.memory.data['metadata']=metadata
            raise DocumentError('document_save_failed') from None
        realtime=getattr(owner,'realtime_memory',None)
        if realtime: realtime.cached_data=copy.deepcopy(owner.memory.data)

    def set_active(self, source_id, active):
        if type(active) is not bool: raise DocumentError('invalid_active')
        with self.owner.state_lock:
            old=self.owner.memory.data.get('documents')
            updated=self._durable(old)
            if source_id not in updated['sources']: raise DocumentError('unknown_source')
            updated['sources'][source_id]['active']=active; updated['revision']+=1
            self._commit(validate_store(updated,self.limits),old)

    def edit_provenance(self, source_id, author='', purpose=''):
        """An explicit optional UI correction; never rewrites literal excerpts or fictional voices."""
        from sofiel_selective import validate_v2, update_authorship_review
        with self.owner.state_lock:
            old=self.owner.memory.data.get('documents'); updated=self._durable(old)
            events=sorted((e for e in updated['sharing_events'].values() if e['source_id']==source_id), key=lambda e:e['created_at'])
            if not events: raise DocumentError('unknown_source')
            event=events[-1]
            event['corrections'].append({'created_at':now(),'author':author,'purpose':purpose})
            if author:
                event['authorship'].append({'label':author,'status':'user_declared','basis':'Correcci贸n expl铆cita en procedencia'})
            if purpose:
                event['stated_purpose']=purpose;event['purpose_status']='explicit'
            update_authorship_review(event)
            updated['revision']+=1
            self._commit(validate_v2(updated,self.limits),old)