metis-memory 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.
Files changed (94) hide show
  1. metis/__init__.py +16 -0
  2. metis/api/__init__.py +6 -0
  3. metis/api/routes.py +167 -0
  4. metis/api/server.py +19 -0
  5. metis/audit/__init__.py +4 -0
  6. metis/audit/export.py +28 -0
  7. metis/audit/replay.py +40 -0
  8. metis/capture/__init__.py +12 -0
  9. metis/capture/confirm.py +46 -0
  10. metis/capture/infer.py +64 -0
  11. metis/capture/loop.py +191 -0
  12. metis/capture/observe.py +43 -0
  13. metis/capture/remember.py +64 -0
  14. metis/capture/whisper.py +73 -0
  15. metis/cli/__init__.py +3 -0
  16. metis/cli/commands/__init__.py +1 -0
  17. metis/cli/commands/audit.py +35 -0
  18. metis/cli/commands/capture.py +22 -0
  19. metis/cli/commands/config.py +22 -0
  20. metis/cli/commands/demo.py +39 -0
  21. metis/cli/commands/fragment.py +32 -0
  22. metis/cli/commands/init.py +23 -0
  23. metis/cli/commands/memory.py +55 -0
  24. metis/cli/commands/model.py +55 -0
  25. metis/cli/commands/retrieve.py +22 -0
  26. metis/cli/commands/workspace.py +15 -0
  27. metis/cli/main.py +52 -0
  28. metis/cli/state.py +98 -0
  29. metis/conditions/__init__.py +4 -0
  30. metis/conditions/context.py +46 -0
  31. metis/conditions/exclusions.py +25 -0
  32. metis/conditions/matcher.py +70 -0
  33. metis/consent/__init__.py +9 -0
  34. metis/consent/contestability.py +27 -0
  35. metis/consent/model.py +61 -0
  36. metis/consent/revocation.py +37 -0
  37. metis/engine.py +118 -0
  38. metis/fragment/__init__.py +15 -0
  39. metis/fragment/events.py +13 -0
  40. metis/fragment/model.py +152 -0
  41. metis/fragment/schema.py +28 -0
  42. metis/fragment/store.py +56 -0
  43. metis/governance/__init__.py +14 -0
  44. metis/governance/authority.py +38 -0
  45. metis/governance/lifecycle.py +296 -0
  46. metis/governance/policy.py +59 -0
  47. metis/governance/runtime_rules.py +14 -0
  48. metis/integrations/__init__.py +1 -0
  49. metis/integrations/chap/__init__.py +20 -0
  50. metis/integrations/chap/adapter.py +337 -0
  51. metis/integrations/chap/artefacts.py +95 -0
  52. metis/integrations/chap/compliance.py +79 -0
  53. metis/integrations/chap/participants.py +54 -0
  54. metis/integrations/chap/review.py +23 -0
  55. metis/memory/__init__.py +17 -0
  56. metis/memory/agent_context.py +59 -0
  57. metis/memory/broker.py +132 -0
  58. metis/memory/episodic.py +35 -0
  59. metis/memory/procedural.py +28 -0
  60. metis/memory/semantic.py +51 -0
  61. metis/memory/tacit.py +153 -0
  62. metis/models/__init__.py +20 -0
  63. metis/models/model_config.py +78 -0
  64. metis/models/ollama_client.py +149 -0
  65. metis/models/prompts.py +41 -0
  66. metis/models/structured_outputs.py +82 -0
  67. metis/py.typed +0 -0
  68. metis/resources.py +46 -0
  69. metis/retrieval/__init__.py +9 -0
  70. metis/retrieval/blocked_reasons.py +33 -0
  71. metis/retrieval/decision.py +40 -0
  72. metis/retrieval/explain.py +31 -0
  73. metis/retrieval/gate.py +167 -0
  74. metis/scenarios.py +279 -0
  75. metis/storage/__init__.py +4 -0
  76. metis/storage/jsonl_store.py +25 -0
  77. metis/storage/sqlite_store.py +71 -0
  78. metis/taxonomy/__init__.py +20 -0
  79. metis/taxonomy/categories.py +176 -0
  80. metis/taxonomy/mapping.py +40 -0
  81. metis/validation/__init__.py +14 -0
  82. metis/validation/mission_group.py +28 -0
  83. metis/validation/promotion.py +22 -0
  84. metis/validation/re_elicitation.py +17 -0
  85. metis/validation/rejection.py +17 -0
  86. metis/validation/states.py +42 -0
  87. metis/validation/tier1.py +27 -0
  88. metis/validation/tier2.py +52 -0
  89. metis_memory-0.1.0.dist-info/METADATA +205 -0
  90. metis_memory-0.1.0.dist-info/RECORD +94 -0
  91. metis_memory-0.1.0.dist-info/WHEEL +5 -0
  92. metis_memory-0.1.0.dist-info/entry_points.txt +2 -0
  93. metis_memory-0.1.0.dist-info/licenses/LICENSE +94 -0
  94. metis_memory-0.1.0.dist-info/top_level.txt +1 -0
metis/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Metis, a reference toolkit for governed tacit fragment capture.
2
+
3
+ Metis captures situated human practice as partial, validated, context-bound fragments
4
+ tied to provenance, consent, authority, review state, and retrieval constraints, and turns
5
+ validated fragments into governed tacit memory objects that AI agents can use alongside
6
+ procedural, semantic, and episodic memory, exposed only through condition-aware governance
7
+ gates. It uses local Ollama/Gemma models for bounded assistance and CHAP as its protocol
8
+ foundation.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ from .engine import MetisEngine
15
+
16
+ __all__ = ["MetisEngine", "__version__"]
metis/api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Optional local FastAPI server for Metis (install the ``api`` extra)."""
2
+ try:
3
+ from .server import app, create_app
4
+ __all__ = ["app", "create_app"]
5
+ except Exception: # fastapi not installed
6
+ __all__ = []
metis/api/routes.py ADDED
@@ -0,0 +1,167 @@
1
+ """FastAPI routes over the same engine, models, and evidence logic as the CLI.
2
+
3
+ Optional component (install with the ``api`` extra). Governance remains deterministic and
4
+ human-reviewed; nothing here lets a model or an agent promote or authorise a fragment.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, HTTPException
11
+ from pydantic import BaseModel
12
+
13
+ from ..conditions.context import TacitContext
14
+ from ..consent.model import ConsentRecord, ConsentStatus
15
+ from ..consent.revocation import RevocationReason
16
+ from ..engine import MetisEngine
17
+ from ..scenarios import run_manufacturing
18
+
19
+ router = APIRouter()
20
+
21
+ # A single in-memory engine, seeded with the manufacturing scenario for immediate data.
22
+ ENGINE = run_manufacturing().engine
23
+
24
+
25
+ def reset_engine(engine: MetisEngine | None = None) -> None:
26
+ global ENGINE
27
+ ENGINE = engine or run_manufacturing().engine
28
+
29
+
30
+ class CaptureRequest(BaseModel):
31
+ observation_id: str
32
+ work_as_imagined: str | None = None
33
+ work_as_done: str | None = None
34
+ text: str | None = None
35
+ context: dict[str, Any] = {}
36
+ category: str | None = None
37
+ response: str = "confirm"
38
+ corrected_content: str | None = None
39
+ title: str | None = None
40
+
41
+
42
+ class ReviewRequest(BaseModel):
43
+ fragment_id: str
44
+ outcome: str
45
+ summary: str = ""
46
+ change_control: dict[str, Any] | None = None
47
+
48
+
49
+ class ContextRequest(BaseModel):
50
+ context: dict[str, Any]
51
+ role: str | None = None
52
+ task_id: str = "tsk_api_query"
53
+
54
+
55
+ class RevokeRequest(BaseModel):
56
+ fragment_id: str
57
+ reason: str = "retired"
58
+ by: str = "human:reviewer@plant_a"
59
+
60
+
61
+ @router.get("/workspace")
62
+ def get_workspace() -> dict[str, Any]:
63
+ return ENGINE.adapter.descriptor()
64
+
65
+
66
+ @router.get("/fragments")
67
+ def list_fragments() -> list[dict[str, Any]]:
68
+ return [f.model_dump(mode="json") for f in ENGINE.fragments.all()]
69
+
70
+
71
+ @router.get("/fragments/{fragment_id}")
72
+ def get_fragment(fragment_id: str) -> dict[str, Any]:
73
+ frag = ENGINE.fragments.get(fragment_id)
74
+ if not frag:
75
+ raise HTTPException(404, "fragment not found")
76
+ return frag.model_dump(mode="json")
77
+
78
+
79
+ @router.get("/memory")
80
+ def list_memory() -> list[dict[str, Any]]:
81
+ return [m.model_dump(mode="json") for m in ENGINE.tacit_store.all()]
82
+
83
+
84
+ @router.get("/memory/{memory_id}")
85
+ def get_memory(memory_id: str) -> dict[str, Any]:
86
+ m = ENGINE.tacit_store.get(memory_id)
87
+ if not m:
88
+ raise HTTPException(404, "memory object not found")
89
+ return m.model_dump(mode="json")
90
+
91
+
92
+ @router.post("/memory/query")
93
+ def memory_query(req: ContextRequest) -> dict[str, Any]:
94
+ amc = ENGINE.agent_context(req.task_id, TacitContext.model_validate(req.context), role=req.role, emit=True)
95
+ return amc.model_dump(mode="json")
96
+
97
+
98
+ @router.post("/capture")
99
+ def capture(req: CaptureRequest) -> dict[str, Any]:
100
+ consent = ConsentRecord(consent_status=ConsentStatus.granted)
101
+ result = ENGINE.capture_observation(
102
+ dict(observation_id=req.observation_id, work_as_imagined=req.work_as_imagined,
103
+ work_as_done=req.work_as_done, text=req.text,
104
+ context=TacitContext.model_validate(req.context), source="api"),
105
+ consent=consent, response=req.response, corrected_content=req.corrected_content,
106
+ category=req.category, title=req.title,
107
+ conditions=TacitContext.model_validate(req.context))
108
+ return {"fragment": result.fragment.model_dump(mode="json") if result.fragment else None,
109
+ "task_id": result.task_id,
110
+ "model_assist_records": [a.assist_id for a in result.model_assist_records]}
111
+
112
+
113
+ @router.post("/confirm")
114
+ def confirm(req: CaptureRequest) -> dict[str, Any]:
115
+ """Confirmation is captured as part of the loop; this is an alias for /capture."""
116
+ return capture(req)
117
+
118
+
119
+ @router.post("/review")
120
+ def review(req: ReviewRequest) -> dict[str, Any]:
121
+ out = ENGINE.tier2_review(req.fragment_id, req.outcome, summary=req.summary,
122
+ change_control=req.change_control)
123
+ return {"outcome": req.outcome, "memory_id": out.get("memory").memory_id if out.get("memory") else None}
124
+
125
+
126
+ @router.post("/promote")
127
+ def promote(req: ReviewRequest) -> dict[str, Any]:
128
+ if req.outcome not in ("promoted_to_advisory", "promoted_to_controlled"):
129
+ req.outcome = "promoted_to_advisory"
130
+ return review(req)
131
+
132
+
133
+ @router.post("/retrieve")
134
+ def retrieve(req: ContextRequest) -> dict[str, Any]:
135
+ decision = ENGINE.retrieve(TacitContext.model_validate(req.context), role=req.role)
136
+ return decision.model_dump(mode="json")
137
+
138
+
139
+ @router.post("/revoke")
140
+ def revoke(req: RevokeRequest) -> dict[str, Any]:
141
+ art = ENGINE.governance.revoke(req.fragment_id, reason=RevocationReason(req.reason), by=req.by)
142
+ return {"revocation_record_artefact": art}
143
+
144
+
145
+ @router.get("/audit")
146
+ def audit() -> list[dict[str, Any]]:
147
+ return ENGINE.adapter.evidence_records()
148
+
149
+
150
+ @router.post("/audit/export")
151
+ def audit_export(out: str = "evidence.jsonl") -> dict[str, Any]:
152
+ n = ENGINE.export_audit(out)
153
+ return {"exported": n, "path": out, "verified": ENGINE.verify().ok}
154
+
155
+
156
+ @router.get("/model/status")
157
+ def model_status() -> dict[str, Any]:
158
+ c = ENGINE.model_client
159
+ return {"provider": c.config.provider, "model": c.config.name, "url": c.config.url,
160
+ "available": c.available()}
161
+
162
+
163
+ @router.post("/model/run")
164
+ def model_run(prompt: str, purpose: str = "draft_whisper") -> dict[str, Any]:
165
+ res = ENGINE.model_client.run(purpose, prompt)
166
+ return {"used_live_model": res.used_live_model, "output": res.json(),
167
+ "note": "advisory draft only; not a governance decision"}
metis/api/server.py ADDED
@@ -0,0 +1,19 @@
1
+ """Local FastAPI server. Run: ``uvicorn metis.api.server:app``. Optional component."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def create_app():
6
+ from fastapi import FastAPI
7
+
8
+ from .routes import router
9
+
10
+ application = FastAPI(
11
+ title="Metis",
12
+ description="Governed tacit fragment capture, local-first, CHAP-aligned. "
13
+ "Tacit memory is exposed only through condition-aware governance gates.",
14
+ version="0.1.0")
15
+ application.include_router(router)
16
+ return application
17
+
18
+
19
+ app = create_app()
@@ -0,0 +1,4 @@
1
+ from .export import export_jsonl, export_records
2
+ from .replay import ReplayResult, replay
3
+
4
+ __all__ = ["export_jsonl", "export_records", "replay", "ReplayResult"]
metis/audit/export.py ADDED
@@ -0,0 +1,28 @@
1
+ """Export the CHAP evidence chain as portable JSONL.
2
+
3
+ Each line is one audit entry (the dispatched JSON-RPC envelope plus its chain link), so the
4
+ export is both an audit log and a replayable record. The chain is append-only.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def export_records(source: Any) -> list[dict[str, Any]]:
14
+ """Return the evidence records from an adapter (or anything with evidence_records())."""
15
+ if hasattr(source, "evidence_records"):
16
+ return source.evidence_records()
17
+ adapter = getattr(source, "adapter", source)
18
+ return adapter.evidence_records()
19
+
20
+
21
+ def export_jsonl(source: Any, path: str | Path) -> int:
22
+ path = Path(path)
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ records = export_records(source)
25
+ with path.open("w") as fh:
26
+ for rec in records:
27
+ fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
28
+ return len(records)
metis/audit/replay.py ADDED
@@ -0,0 +1,40 @@
1
+ """Replay and verify an exported evidence chain, independent of the live Coordinator.
2
+
3
+ Reproduces the Coordinator's hash linkage (``prev = sha256( JCS(envelope) || prev )``) over
4
+ the exported JSON-RPC envelopes and confirms prev_hash continuity.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from chap_coordinator import ZERO_HASH, canonicalize, sha256_hex
14
+
15
+
16
+ @dataclass
17
+ class ReplayResult:
18
+ ok: bool
19
+ checked: int
20
+ errors: list[str] = field(default_factory=list)
21
+ methods: list[str] = field(default_factory=list)
22
+
23
+
24
+ def load_jsonl(path: str | Path) -> list[dict[str, Any]]:
25
+ return [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
26
+
27
+
28
+ def replay(path: str | Path) -> ReplayResult:
29
+ records = load_jsonl(path)
30
+ errors: list[str] = []
31
+ methods: list[str] = []
32
+ prev = ZERO_HASH
33
+ for rec in records:
34
+ env = rec.get("envelope") or {}
35
+ ph = rec.get("prev_hash")
36
+ if ph is not None and ph != prev:
37
+ errors.append(f"seq {rec.get('seq')}: prev_hash break")
38
+ methods.append(rec.get("method_or_type") or env.get("method", "?"))
39
+ prev = sha256_hex(canonicalize(env) + prev.encode("utf-8"))
40
+ return ReplayResult(ok=not errors, checked=len(records), errors=errors, methods=methods)
@@ -0,0 +1,12 @@
1
+ from .confirm import ConfirmationResult, operator_confirm
2
+ from .infer import InferenceCandidate, infer_candidate
3
+ from .loop import CaptureLoop, CaptureResult
4
+ from .observe import Observation, build_observation
5
+ from .remember import build_fragment
6
+ from .whisper import WhisperPrompt, build_whisper
7
+
8
+ __all__ = [
9
+ "CaptureLoop", "CaptureResult", "Observation", "build_observation",
10
+ "InferenceCandidate", "infer_candidate", "WhisperPrompt", "build_whisper",
11
+ "ConfirmationResult", "operator_confirm", "build_fragment",
12
+ ]
@@ -0,0 +1,46 @@
1
+ """Confirm, capture the operator's Tier-1 response (descriptive fidelity only).
2
+
3
+ Allowed responses: confirm / correct / dismiss / defer. A local model may summarise the
4
+ confirmation, but the original response and the human-confirmed summary are both preserved.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from ..models.ollama_client import OllamaClient
13
+ from ..models.structured_outputs import AssistPurpose
14
+ from ..validation.tier1 import OperatorResponse
15
+ from .whisper import WhisperPrompt
16
+
17
+
18
+ class ConfirmationResult(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ response: OperatorResponse
22
+ corrected_content: str | None = None
23
+ free_text: str | None = None
24
+ summary: str | None = None
25
+
26
+
27
+ def operator_confirm(
28
+ prompt: WhisperPrompt,
29
+ response: OperatorResponse | str,
30
+ *,
31
+ corrected_content: str | None = None,
32
+ free_text: str | None = None,
33
+ model_client: OllamaClient | None = None,
34
+ ) -> tuple[ConfirmationResult, dict[str, Any] | None]:
35
+ response = OperatorResponse(response)
36
+ assist: dict[str, Any] | None = None
37
+ summary = None
38
+ if model_client is not None and response in (OperatorResponse.confirm, OperatorResponse.correct):
39
+ basis = corrected_content or free_text or prompt.question
40
+ p = f"Summarise this operator confirmation in one sentence. Return JSON {{summary}}. Text: {basis}"
41
+ res = model_client.run(AssistPurpose.summarise_confirmation, p)
42
+ summary = res.json().get("summary")
43
+ assist = {"purpose": AssistPurpose.summarise_confirmation.value, "prompt": p,
44
+ "output": res.json(), "used_live_model": res.used_live_model}
45
+ return ConfirmationResult(response=response, corrected_content=corrected_content,
46
+ free_text=free_text, summary=summary), assist
metis/capture/infer.py ADDED
@@ -0,0 +1,64 @@
1
+ """Infer, compare work-as-imagined with work-as-done and form a *candidate hypothesis*.
2
+
3
+ Deterministic rules come first. A local model may help classify or structure the candidate,
4
+ but the result is only ever a hypothesis (``tacit.inference_candidate``); never trusted
5
+ knowledge. The gap between work-as-imagined and work-as-done is diagnostic, not proof.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from ..models.ollama_client import OllamaClient
14
+ from ..models.structured_outputs import AssistPurpose
15
+ from .observe import Observation
16
+
17
+
18
+ class InferenceCandidate(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ candidate_id: str
22
+ observation_id: str
23
+ hypothesis: str
24
+ category: str
25
+ gap_summary: str
26
+ conditions: dict[str, Any] = Field(default_factory=dict)
27
+ confidence: float = 0.3
28
+ is_hypothesis: bool = True
29
+
30
+
31
+ def infer_candidate(
32
+ observation: Observation,
33
+ *,
34
+ candidate_id: str,
35
+ model_client: OllamaClient | None = None,
36
+ category: str | None = None,
37
+ ) -> tuple[InferenceCandidate, dict[str, Any] | None]:
38
+ wai, wad = observation.work_as_imagined, observation.work_as_done
39
+ if wai and wad and wai.strip() != wad.strip():
40
+ gap = f"Work-as-imagined ('{wai}') diverges from work-as-done ('{wad}')."
41
+ hypothesis = f"Under the stated conditions, practitioners do: {wad} (rather than: {wai})."
42
+ else:
43
+ gap = "No explicit work-as-imagined/work-as-done divergence supplied."
44
+ hypothesis = observation.text
45
+
46
+ assist: dict[str, Any] | None = None
47
+ if category is None and model_client is not None:
48
+ prompt = (
49
+ "Classify the following situated work observation into one K1-K17 tacit category. "
50
+ "Return JSON {category, rationale}. Observation: " + observation.text)
51
+ res = model_client.run(AssistPurpose.classify_fragment, prompt)
52
+ category = res.json().get("category")
53
+ assist = {"purpose": AssistPurpose.classify_fragment.value, "prompt": prompt,
54
+ "output": res.json(), "used_live_model": res.used_live_model}
55
+ if category is None:
56
+ # deterministic fallback classification (no model)
57
+ from ..models.ollama_client import _guess_category
58
+ category = _guess_category(observation.text)
59
+
60
+ candidate = InferenceCandidate(
61
+ candidate_id=candidate_id, observation_id=observation.observation_id,
62
+ hypothesis=hypothesis, category=category, gap_summary=gap,
63
+ conditions=observation.context.model_dump(mode="json", exclude_none=True))
64
+ return candidate, assist
metis/capture/loop.py ADDED
@@ -0,0 +1,191 @@
1
+ """The Metis operating loop: Observe -> Infer -> Whisper -> Confirm -> Remember.
2
+
3
+ Each stage is a Metis action emitted as a CHAP event/artefact through the adapter. A
4
+ local model may assist at the Infer/Whisper/Confirm stages; every assisted step is recorded
5
+ as a ModelAssistRecord (provenance, not authority).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from ..conditions.context import TacitContext
13
+ from ..consent.model import ConsentRecord
14
+ from ..fragment.events import fragment_to_content
15
+ from ..fragment.model import Attribution, FragmentEvidence, TacitFragment
16
+ from ..fragment.store import FragmentStore
17
+ from ..models.ollama_client import OllamaClient
18
+ from ..models.structured_outputs import AssistPurpose, ModelAssistRecord
19
+ from ..taxonomy.categories import SourcePathway
20
+ from ..validation.tier1 import OperatorResponse
21
+ from .confirm import ConfirmationResult, operator_confirm
22
+ from .infer import InferenceCandidate, infer_candidate
23
+ from .observe import Observation, build_observation
24
+ from .remember import build_fragment
25
+ from .whisper import WhisperPrompt, build_whisper
26
+
27
+
28
+ @dataclass
29
+ class CaptureResult:
30
+ observation: Observation
31
+ candidate: InferenceCandidate
32
+ whisper: WhisperPrompt
33
+ confirmation: ConfirmationResult
34
+ task_id: str
35
+ fragment: TacitFragment | None = None
36
+ fragment_artefact: str | None = None
37
+ model_assist_records: list[ModelAssistRecord] = field(default_factory=list)
38
+ used_live_model: bool = False
39
+
40
+
41
+ class CaptureLoop:
42
+ def __init__(
43
+ self,
44
+ *,
45
+ adapter,
46
+ fragment_store: FragmentStore,
47
+ operator_uri: str,
48
+ whisperer_uri: str,
49
+ mission_group_uri: str = "group:mission-group@metis.local",
50
+ model_client: OllamaClient | None = None,
51
+ capture_cell: str | None = None,
52
+ governance=None,
53
+ ) -> None:
54
+ self.adapter = adapter
55
+ self.fragments = fragment_store
56
+ self.operator_uri = operator_uri
57
+ self.whisperer_uri = whisperer_uri
58
+ self.mission_group_uri = mission_group_uri
59
+ self.model_client = model_client
60
+ self.capture_cell = capture_cell or adapter.workspace_id
61
+ self.governance = governance
62
+ self._frag_seq = 0
63
+ self._assist_seq = 0
64
+
65
+ # ---- model assist recording ------------------------------------------------
66
+ def _record_assist(self, assist: dict[str, Any], *, task_id: str, produced_by: str,
67
+ input_refs: list[str]) -> ModelAssistRecord:
68
+ self._assist_seq += 1
69
+ cfg = self.model_client.config if self.model_client else None
70
+ record = ModelAssistRecord(
71
+ assist_id=f"MA-{self._assist_seq:04d}",
72
+ provider=cfg.provider if cfg else "ollama",
73
+ model_name=cfg.name if cfg else "gemma4",
74
+ model_url=cfg.url if cfg else "http://localhost:11434",
75
+ purpose=AssistPurpose(assist["purpose"]),
76
+ prompt_template=assist["prompt"],
77
+ input_refs=input_refs,
78
+ output=assist["output"],
79
+ used_live_model=assist.get("used_live_model", False),
80
+ human_review_required=True,
81
+ human_review_status="pending",
82
+ )
83
+ art = self.adapter.append_artefact(
84
+ "tacit.model_assist_record", produced_by=produced_by,
85
+ content=record.model_dump(mode="json"), task=task_id)
86
+ record.chap_artefact_ref = art
87
+ record.chap_evidence_ref = self.adapter.artefact_evidence.get(art)
88
+ return record
89
+
90
+ # ---- the loop --------------------------------------------------------------
91
+ def run(
92
+ self,
93
+ observation_input: dict[str, Any] | Observation,
94
+ *,
95
+ consent: ConsentRecord,
96
+ response: OperatorResponse | str = OperatorResponse.confirm,
97
+ corrected_content: str | None = None,
98
+ free_text: str | None = None,
99
+ category: str | None = None,
100
+ conditions: TacitContext | None = None,
101
+ attribution: Attribution | None = None,
102
+ evidence: FragmentEvidence | None = None,
103
+ title: str | None = None,
104
+ source_pathway: SourcePathway = SourcePathway.exogenous,
105
+ use_model: bool = True,
106
+ fragment_id: str | None = None,
107
+ ) -> CaptureResult:
108
+ mc = self.model_client if use_model else None
109
+ assists: list[ModelAssistRecord] = []
110
+
111
+ # 1. Observe
112
+ if isinstance(observation_input, Observation):
113
+ observation = observation_input
114
+ else:
115
+ observation = build_observation(**observation_input)
116
+ task_id = self.adapter.create_task(
117
+ "tacit.capture", assignee=self.whisperer_uri, delegator=self.operator_uri,
118
+ task_input={"observation_id": observation.observation_id})
119
+ obs_art = self.adapter.append_artefact(
120
+ "tacit.capture_observation", produced_by=self.operator_uri,
121
+ content=observation.model_dump(mode="json"), task=task_id)
122
+
123
+ # 2. Infer (candidate only)
124
+ self._frag_seq += 1
125
+ candidate_id = f"IC-{self._frag_seq:04d}"
126
+ candidate, infer_assist = infer_candidate(observation, candidate_id=candidate_id,
127
+ model_client=mc, category=category)
128
+ cand_art = self.adapter.append_artefact(
129
+ "tacit.inference_candidate", produced_by=self.whisperer_uri,
130
+ content=candidate.model_dump(mode="json"), task=task_id, based_on=obs_art)
131
+ if infer_assist:
132
+ assists.append(self._record_assist(infer_assist, task_id=task_id,
133
+ produced_by=self.whisperer_uri, input_refs=[obs_art]))
134
+
135
+ # 3. Whisper (CHAP whisper capability)
136
+ whisper, whisper_assist = build_whisper(candidate.category, observation, model_client=mc)
137
+ prompt_art = self.adapter.whisper_ask(
138
+ sender=self.whisperer_uri, to=self.operator_uri, task_id=task_id,
139
+ question=whisper.question, options=whisper.options, deadline_ms=60000,
140
+ default_if_lapsed="defer", urgency="low", category=candidate.category)
141
+ if whisper_assist:
142
+ assists.append(self._record_assist(whisper_assist, task_id=task_id,
143
+ produced_by=self.whisperer_uri, input_refs=[cand_art]))
144
+
145
+ # 4. Confirm (Tier-1, descriptive fidelity)
146
+ confirmation, confirm_assist = operator_confirm(
147
+ whisper, response, corrected_content=corrected_content, free_text=free_text, model_client=mc)
148
+ self.adapter.whisper_answer(
149
+ sender=self.operator_uri, to=self.whisperer_uri, task_id=task_id,
150
+ prompt_artefact=prompt_art, response_type=confirmation.response.value,
151
+ text=confirmation.corrected_content or confirmation.free_text,
152
+ option_id=confirmation.response.value)
153
+ self.adapter.append_artefact(
154
+ "tacit.operator_confirmation", produced_by=self.operator_uri,
155
+ content=confirmation.model_dump(mode="json"), task=task_id, based_on=prompt_art)
156
+ if confirm_assist:
157
+ assists.append(self._record_assist(confirm_assist, task_id=task_id,
158
+ produced_by=self.operator_uri, input_refs=[prompt_art]))
159
+
160
+ result = CaptureResult(
161
+ observation=observation, candidate=candidate, whisper=whisper,
162
+ confirmation=confirmation, task_id=task_id, model_assist_records=assists,
163
+ used_live_model=any(a.used_live_model for a in assists))
164
+
165
+ # 5. Remember (only on confirm/correct)
166
+ if confirmation.response in (OperatorResponse.confirm, OperatorResponse.correct):
167
+ fid = fragment_id or f"TF-{self._frag_seq:05d}"
168
+ fragment = build_fragment(
169
+ observation, candidate, confirmation, fragment_id=fid,
170
+ capture_cell=self.capture_cell, operator_uri=self.operator_uri,
171
+ consent=consent, title=title, conditions=conditions,
172
+ attribution=attribution, evidence=evidence, source_pathway=source_pathway)
173
+ # record model-assist provenance (provenance, not authority)
174
+ if assists:
175
+ fragment.provenance.model_assist_refs = [a.assist_id for a in assists]
176
+ fragment.provenance.model_provider = assists[0].provider
177
+ fragment.provenance.model_name = assists[0].model_name
178
+ fragment.provenance.model_output_status = "draft_pending_human_review"
179
+ fragment.provenance.human_review_status = "tier1_confirmed"
180
+ fragment.provenance.source_artefacts = [obs_art, cand_art, prompt_art]
181
+ fragment.add_lineage(state=fragment.validation_state.value, by=self.operator_uri,
182
+ note="captured into Evidence layer (Tier-1 confirmed)")
183
+ frag_art = self.adapter.append_artefact(
184
+ "tacit.fragment", produced_by=self.operator_uri,
185
+ content=fragment_to_content(fragment), task=task_id, based_on=cand_art)
186
+ self.fragments.put(fragment)
187
+ result.fragment = fragment
188
+ result.fragment_artefact = frag_art
189
+ if self.governance is not None:
190
+ self.governance.register(fid, fragment_artefact=frag_art, task_id=task_id)
191
+ return result
@@ -0,0 +1,43 @@
1
+ """Observe, accept a structured situated work observation.
2
+
3
+ Inputs may come from a manual form, a CSV row, a JSON event, or a synthetic example. The
4
+ observation is recorded as a CHAP artefact of kind ``tacit.capture_observation``. Nothing
5
+ here is trusted knowledge; it is raw input to the loop.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from ..conditions.context import TacitContext
14
+
15
+
16
+ class Observation(BaseModel):
17
+ model_config = ConfigDict(extra="forbid")
18
+
19
+ observation_id: str
20
+ text: str
21
+ work_as_imagined: str | None = None
22
+ work_as_done: str | None = None
23
+ context: TacitContext = Field(default_factory=TacitContext)
24
+ source: str = "manual" # manual | csv | json | synthetic
25
+ raw: dict[str, Any] = Field(default_factory=dict)
26
+
27
+
28
+ def build_observation(
29
+ *,
30
+ observation_id: str,
31
+ text: str | None = None,
32
+ work_as_imagined: str | None = None,
33
+ work_as_done: str | None = None,
34
+ context: TacitContext | None = None,
35
+ source: str = "manual",
36
+ raw: dict[str, Any] | None = None,
37
+ ) -> Observation:
38
+ if text is None:
39
+ text = work_as_done or work_as_imagined or ""
40
+ return Observation(
41
+ observation_id=observation_id, text=text, work_as_imagined=work_as_imagined,
42
+ work_as_done=work_as_done, context=context or TacitContext(), source=source,
43
+ raw=raw or {})