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.
- metis/__init__.py +16 -0
- metis/api/__init__.py +6 -0
- metis/api/routes.py +167 -0
- metis/api/server.py +19 -0
- metis/audit/__init__.py +4 -0
- metis/audit/export.py +28 -0
- metis/audit/replay.py +40 -0
- metis/capture/__init__.py +12 -0
- metis/capture/confirm.py +46 -0
- metis/capture/infer.py +64 -0
- metis/capture/loop.py +191 -0
- metis/capture/observe.py +43 -0
- metis/capture/remember.py +64 -0
- metis/capture/whisper.py +73 -0
- metis/cli/__init__.py +3 -0
- metis/cli/commands/__init__.py +1 -0
- metis/cli/commands/audit.py +35 -0
- metis/cli/commands/capture.py +22 -0
- metis/cli/commands/config.py +22 -0
- metis/cli/commands/demo.py +39 -0
- metis/cli/commands/fragment.py +32 -0
- metis/cli/commands/init.py +23 -0
- metis/cli/commands/memory.py +55 -0
- metis/cli/commands/model.py +55 -0
- metis/cli/commands/retrieve.py +22 -0
- metis/cli/commands/workspace.py +15 -0
- metis/cli/main.py +52 -0
- metis/cli/state.py +98 -0
- metis/conditions/__init__.py +4 -0
- metis/conditions/context.py +46 -0
- metis/conditions/exclusions.py +25 -0
- metis/conditions/matcher.py +70 -0
- metis/consent/__init__.py +9 -0
- metis/consent/contestability.py +27 -0
- metis/consent/model.py +61 -0
- metis/consent/revocation.py +37 -0
- metis/engine.py +118 -0
- metis/fragment/__init__.py +15 -0
- metis/fragment/events.py +13 -0
- metis/fragment/model.py +152 -0
- metis/fragment/schema.py +28 -0
- metis/fragment/store.py +56 -0
- metis/governance/__init__.py +14 -0
- metis/governance/authority.py +38 -0
- metis/governance/lifecycle.py +296 -0
- metis/governance/policy.py +59 -0
- metis/governance/runtime_rules.py +14 -0
- metis/integrations/__init__.py +1 -0
- metis/integrations/chap/__init__.py +20 -0
- metis/integrations/chap/adapter.py +337 -0
- metis/integrations/chap/artefacts.py +95 -0
- metis/integrations/chap/compliance.py +79 -0
- metis/integrations/chap/participants.py +54 -0
- metis/integrations/chap/review.py +23 -0
- metis/memory/__init__.py +17 -0
- metis/memory/agent_context.py +59 -0
- metis/memory/broker.py +132 -0
- metis/memory/episodic.py +35 -0
- metis/memory/procedural.py +28 -0
- metis/memory/semantic.py +51 -0
- metis/memory/tacit.py +153 -0
- metis/models/__init__.py +20 -0
- metis/models/model_config.py +78 -0
- metis/models/ollama_client.py +149 -0
- metis/models/prompts.py +41 -0
- metis/models/structured_outputs.py +82 -0
- metis/py.typed +0 -0
- metis/resources.py +46 -0
- metis/retrieval/__init__.py +9 -0
- metis/retrieval/blocked_reasons.py +33 -0
- metis/retrieval/decision.py +40 -0
- metis/retrieval/explain.py +31 -0
- metis/retrieval/gate.py +167 -0
- metis/scenarios.py +279 -0
- metis/storage/__init__.py +4 -0
- metis/storage/jsonl_store.py +25 -0
- metis/storage/sqlite_store.py +71 -0
- metis/taxonomy/__init__.py +20 -0
- metis/taxonomy/categories.py +176 -0
- metis/taxonomy/mapping.py +40 -0
- metis/validation/__init__.py +14 -0
- metis/validation/mission_group.py +28 -0
- metis/validation/promotion.py +22 -0
- metis/validation/re_elicitation.py +17 -0
- metis/validation/rejection.py +17 -0
- metis/validation/states.py +42 -0
- metis/validation/tier1.py +27 -0
- metis/validation/tier2.py +52 -0
- metis_memory-0.1.0.dist-info/METADATA +205 -0
- metis_memory-0.1.0.dist-info/RECORD +94 -0
- metis_memory-0.1.0.dist-info/WHEEL +5 -0
- metis_memory-0.1.0.dist-info/entry_points.txt +2 -0
- metis_memory-0.1.0.dist-info/licenses/LICENSE +94 -0
- 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
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()
|
metis/audit/__init__.py
ADDED
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
|
+
]
|
metis/capture/confirm.py
ADDED
|
@@ -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
|
metis/capture/observe.py
ADDED
|
@@ -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 {})
|