refactor(opax-mcp): introduce canonical Emma identity

This commit is contained in:
Chris Christiansen 2026-09-16 15:27:48 +00:00
parent 849ec9dea0
commit e66b1a328e
6 changed files with 131 additions and 6 deletions

View File

@ -9,7 +9,7 @@ steps:
- 'opax-mcp/Dockerfile'
- '-t'
- '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/opax-mcp:$BUILD_ID'
- 'opax-mcp'
- '.'
# Steg 2: Push det unike imaget til Artifact Registry
- name: 'gcr.io/cloud-builders/docker'

View File

@ -34,3 +34,14 @@ Kollega: {i['colleague']}
HARD REGLER:
{rules}"""
def get_runtime_system_prompt() -> str:
"""Returnerer en trygg system-prompt uten sensitiv topologi."""
i = EMMA_IDENTITY
rules = "\n".join(f"- {r}" for r in i["rules"])
return f"""Du er {i['name']} ({i['email']}), {i['role']}.
Du rapporterer til {i['reports_to']}.
Kollega: {i['colleague']}.
HARD REGLER:
{rules}"""

View File

@ -9,10 +9,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY requirements.txt .
COPY opax-mcp/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY opax-mcp/ /app/
COPY emma/emma_identity.py /app/emma_identity.py
ENV PORT=8080
ENV PYTHONPATH=/app

44
opax-mcp/emma_adapter.py Normal file
View File

@ -0,0 +1,44 @@
"""
Emma Adapter to provide a canonical, consistent interface to the Emma agent core.
"""
# This will be copied to /app/emma_identity.py by the Dockerfile
from emma_identity import get_runtime_system_prompt
class CanonicalEmma:
"""
A facade for the Emma agent that enforces a canonical identity and contract,
while allowing the underlying chat function to be injected as a dependency.
"""
def __init__(self, chat_function, model, system_prompt=None):
"""
Initializes the CanonicalEmma adapter.
Args:
chat_function: The async function to call for the LLM interaction.
model: The name of the model to use.
system_prompt: An optional system prompt to override the default.
"""
self._chat_function = chat_function
self._model = model
self._system_prompt = system_prompt or get_runtime_system_prompt()
async def run(self, prompt, history=None):
"""
Runs the Emma agent with the given prompt.
In Phase 1, this is a simple pass-through to the injected chat_function,
ensuring the canonical system prompt is used. History is ignored for now.
Args:
prompt: The user's prompt.
history: The conversation history (currently ignored).
Returns:
The raw dictionary response from the chat_function.
"""
# Phase 1 does not use history or memory.
return await self._chat_function(
self._model,
prompt,
self._system_prompt,
)

View File

@ -45,7 +45,7 @@ try:
except Exception as e:
print(f"Failed to load provision_new_mcp_module: {e}")
provision_new_mcp_module = None
from email.mime.text import MIMEText
from emma_adapter import CanonicalEmma
from email.mime.text import MIMEText
from datetime import datetime, timezone, timedelta
from typing import Any, Optional, Dict, List
@ -730,6 +730,11 @@ async def _ollama_chat(model: str, prompt: str, system: str = "") -> dict:
logger.error(f"[OLLAMA ERROR] model={model} {type(e).__name__}: {e}")
raise
canonical_emma = CanonicalEmma(
chat_function=_ollama_chat,
model="gemma3:4b"
)
async def _ollama_models() -> list:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{OLLAMA_BASE_URL}/api/tags")
@ -804,7 +809,12 @@ async def tui_command(p): return await _agent_post("/tui-comman
async def run_jason(p):
"""Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk."""
return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
async def run_emma(p): return await _ollama_chat("gemma3:4b", p.get("prompt", p.get("message", "")), p.get("system", "Du er Emma Vauger..."))
async def run_emma(p: dict) -> dict:
"""Kaller den kanoniske Emma-agenten med en prompt."""
return await canonical_emma.run(
prompt=p.get("prompt", p.get("message", "")),
history=[],
)
async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent..."))
async def run_qwen(p): return await _ollama_chat(EMMA_LIGHT_MODEL, p.get("prompt", p.get("message", "")))
async def list_emma_models(p): return await _ollama_models()
@ -949,7 +959,7 @@ TOOLS = {
),
# AI Agents
"run_jason": (run_jason, "Kjør Jason-agenten med en prompt", {"type":"object","properties":{"prompt":{"type":"string"},"mode":{"type":"string"}},"required":["prompt"]}),
"run_emma": (run_emma, "Emma Vauger (gemma3:27b) — primær lokal AI", {"type":"object","properties":{"prompt":{"type":"string"}}}),
"run_emma": (run_emma, "Emma Vauger (gemma3:4b) — primær lokal AI", {"type":"object","properties":{"prompt":{"type":"string"}}}),
# Local models — read-only discovery
"list_emma_models": (

View File

@ -0,0 +1,58 @@
import unittest
from unittest.mock import AsyncMock, patch
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parents[1]
# Samme flate modulstruktur som i Docker-runtime /app:
sys.path.insert(0, str(REPO_ROOT / "opax-mcp"))
sys.path.insert(0, str(REPO_ROOT / "emma"))
from emma_adapter import CanonicalEmma
from emma_identity import get_runtime_system_prompt
class TestCanonicalEmmaPhase1B(unittest.IsolatedAsyncioTestCase):
def test_runtime_prompt_contains_canonical_identity(self):
"""(1) Verifies the runtime prompt contains the core identity and rules."""
prompt = get_runtime_system_prompt()
self.assertIn("Du er Emma", prompt)
self.assertIn("HARD REGLER:", prompt)
self.assertNotIn("gcp_project", prompt)
self.assertNotIn("opax_url", prompt)
async def test_adapter_injects_model_prompt_and_system_prompt(self):
"""(2) Verifies the adapter calls the injected function with correct args."""
mock_chat_fn = AsyncMock()
model = "test-model-456"
prompt = "test prompt 123"
adapter = CanonicalEmma(chat_function=mock_chat_fn, model=model)
await adapter.run(prompt)
mock_chat_fn.assert_awaited_once()
call_args = mock_chat_fn.call_args
self.assertEqual(call_args.args[0], model)
self.assertEqual(call_args.args[1], prompt)
self.assertEqual(call_args.args[2], get_runtime_system_prompt())
async def test_adapter_returns_raw_response_unchanged(self):
"""(3) Verifies the adapter returns the original response from the chat function."""
mock_response = {"model": "test-model", "response": "test-response", "done": True}
mock_chat_fn = AsyncMock(return_value=mock_response)
adapter = CanonicalEmma(chat_function=mock_chat_fn, model="any-model")
result = await adapter.run("any-prompt")
self.assertEqual(result, mock_response)
def test_adapter_has_no_memory_or_tool_execution(self):
"""(4) Verifies no memory or tool execution is implicitly activated."""
mock_chat_fn = AsyncMock()
adapter = CanonicalEmma(chat_function=mock_chat_fn, model="any-model")
self.assertFalse(hasattr(adapter, '_memory'))
self.assertFalse(hasattr(adapter, '_tool_registry'))
if __name__ == '__main__':
unittest.main()