From e66b1a328e8ce4bc874739a2d4b8f0aefad90090 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Wed, 16 Sep 2026 15:27:48 +0000 Subject: [PATCH] refactor(opax-mcp): introduce canonical Emma identity --- cloudbuild.deploy.yaml | 2 +- emma/emma_identity.py | 11 +++++++ opax-mcp/Dockerfile | 6 ++-- opax-mcp/emma_adapter.py | 44 ++++++++++++++++++++++++++ opax-mcp/server.py | 16 ++++++++-- opax-mcp/test_emma_adapter.py | 58 +++++++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 opax-mcp/emma_adapter.py create mode 100644 opax-mcp/test_emma_adapter.py diff --git a/cloudbuild.deploy.yaml b/cloudbuild.deploy.yaml index e69ec1e..3b7c70a 100644 --- a/cloudbuild.deploy.yaml +++ b/cloudbuild.deploy.yaml @@ -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' diff --git a/emma/emma_identity.py b/emma/emma_identity.py index 0bdb637..0db3ca7 100644 --- a/emma/emma_identity.py +++ b/emma/emma_identity.py @@ -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}""" diff --git a/opax-mcp/Dockerfile b/opax-mcp/Dockerfile index 05212d4..6a2bdbb 100644 --- a/opax-mcp/Dockerfile +++ b/opax-mcp/Dockerfile @@ -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 diff --git a/opax-mcp/emma_adapter.py b/opax-mcp/emma_adapter.py new file mode 100644 index 0000000..ac305e6 --- /dev/null +++ b/opax-mcp/emma_adapter.py @@ -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, + ) diff --git a/opax-mcp/server.py b/opax-mcp/server.py index ac92bcb..7174cae 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -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": ( diff --git a/opax-mcp/test_emma_adapter.py b/opax-mcp/test_emma_adapter.py new file mode 100644 index 0000000..081ebc0 --- /dev/null +++ b/opax-mcp/test_emma_adapter.py @@ -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()