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' - 'opax-mcp/Dockerfile'
- '-t' - '-t'
- '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/opax-mcp:$BUILD_ID' - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/opax-mcp:$BUILD_ID'
- 'opax-mcp' - '.'
# Steg 2: Push det unike imaget til Artifact Registry # Steg 2: Push det unike imaget til Artifact Registry
- name: 'gcr.io/cloud-builders/docker' - name: 'gcr.io/cloud-builders/docker'

View File

@ -34,3 +34,14 @@ Kollega: {i['colleague']}
HARD REGLER: HARD REGLER:
{rules}""" {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 WORKDIR /app
COPY requirements.txt . COPY opax-mcp/requirements.txt .
RUN pip install --no-cache-dir -r 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 PORT=8080
ENV PYTHONPATH=/app 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: except Exception as e:
print(f"Failed to load provision_new_mcp_module: {e}") print(f"Failed to load provision_new_mcp_module: {e}")
provision_new_mcp_module = None provision_new_mcp_module = None
from email.mime.text import MIMEText from emma_adapter import CanonicalEmma
from email.mime.text import MIMEText from email.mime.text import MIMEText
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Optional, Dict, List 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}") logger.error(f"[OLLAMA ERROR] model={model} {type(e).__name__}: {e}")
raise raise
canonical_emma = CanonicalEmma(
chat_function=_ollama_chat,
model="gemma3:4b"
)
async def _ollama_models() -> list: async def _ollama_models() -> list:
async with httpx.AsyncClient(timeout=10) as c: async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{OLLAMA_BASE_URL}/api/tags") 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): async def run_jason(p):
"""Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk.""" """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")}) 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_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 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() async def list_emma_models(p): return await _ollama_models()
@ -949,7 +959,7 @@ TOOLS = {
), ),
# AI Agents # 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_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 # Local models — read-only discovery
"list_emma_models": ( "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()