refactor(opax-mcp): introduce canonical Emma identity
This commit is contained in:
parent
849ec9dea0
commit
e66b1a328e
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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}"""
|
||||||
|
|
|
||||||
|
|
@ -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
44
opax-mcp/emma_adapter.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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": (
|
||||||
|
|
|
||||||
58
opax-mcp/test_emma_adapter.py
Normal file
58
opax-mcp/test_emma_adapter.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user