feat(ML-3a): POST /emma + EMMA_BACKEND flagg (vertex nå → local VM ved trigger)
This commit is contained in:
parent
ec085a91c9
commit
68802353ef
132
main.py
132
main.py
|
|
@ -17,6 +17,7 @@ CG4-credits: GET /billing/credits — kreditt-saldo, burn rate og tom-dato.
|
||||||
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
|
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
|
||||||
TERMINAL: POST /terminal/exec — whitelisted kommandoer: health, billing, build, logs, help. ✅
|
TERMINAL: POST /terminal/exec — whitelisted kommandoer: health, billing, build, logs, help. ✅
|
||||||
VM: GET /vm/ssh-key — henter public key fra Compute Engine metadata. ✅
|
VM: GET /vm/ssh-key — henter public key fra Compute Engine metadata. ✅
|
||||||
|
ML-3a: POST /emma — intern agent (Emma Vauger). EMMA_BACKEND=vertex (nå) | local (VM-trigger). ✅
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -64,6 +65,21 @@ PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
|
||||||
# Guard+-tiers som får tilgang til SMS-varsler
|
# Guard+-tiers som får tilgang til SMS-varsler
|
||||||
SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"}
|
SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"}
|
||||||
|
|
||||||
|
# ── EMMA: backend-flagg ───────────────────────────────────────────────────────
|
||||||
|
# EMMA_BACKEND=vertex → Vertex AI (nå, bruker eksisterende kreditter)
|
||||||
|
# EMMA_BACKEND=local → lokal Gemma 4 på g2-standard-4 Spot VM (trigger: første kunde)
|
||||||
|
# EMMA_LOCAL_URL → http://<VM-IP>:8000/generate (kun brukt når EMMA_BACKEND=local)
|
||||||
|
EMMA_BACKEND = os.environ.get("EMMA_BACKEND", "vertex")
|
||||||
|
EMMA_LOCAL_URL = os.environ.get("EMMA_LOCAL_URL", "http://localhost:8000/generate")
|
||||||
|
EMMA_MODEL = os.environ.get("EMMA_MODEL", "google/gemma-3-12b-it") # Vertex model ID
|
||||||
|
|
||||||
|
# Last Emma sitt system-prompt fra world.md ved oppstart
|
||||||
|
_WORLD_MD_PATH = pathlib.Path(__file__).parent / "docs" / "gemma" / "world.md"
|
||||||
|
try:
|
||||||
|
_EMMA_SYSTEM_PROMPT = _WORLD_MD_PATH.read_text(encoding="utf-8")
|
||||||
|
except FileNotFoundError:
|
||||||
|
_EMMA_SYSTEM_PROMPT = "Du er Emma Vauger, intern AI-assistent for Vauco AS. Vær analytisk og direkte."
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="OSVauco OPAX Agent",
|
title="OSVauco OPAX Agent",
|
||||||
description="Agent for OSVauco-OPAX platform.",
|
description="Agent for OSVauco-OPAX platform.",
|
||||||
|
|
@ -206,14 +222,6 @@ class TerminalExecRequest(BaseModel):
|
||||||
|
|
||||||
@app.post("/terminal/exec")
|
@app.post("/terminal/exec")
|
||||||
async def terminal_exec(req: TerminalExecRequest):
|
async def terminal_exec(req: TerminalExecRequest):
|
||||||
"""
|
|
||||||
Whitelisted terminal-kommandoer:
|
|
||||||
health → GET /health internt
|
|
||||||
billing → GET /billing/summary
|
|
||||||
build → GET /opax/build-status
|
|
||||||
logs → siste 20 linjer fra Cloud Logging
|
|
||||||
help → returner kommandoliste
|
|
||||||
"""
|
|
||||||
cmd = req.cmd.strip().lower().split()[0] if req.cmd.strip() else ""
|
cmd = req.cmd.strip().lower().split()[0] if req.cmd.strip() else ""
|
||||||
if cmd not in TERMINAL_WHITELIST:
|
if cmd not in TERMINAL_WHITELIST:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -301,10 +309,6 @@ async def terminal_exec(req: TerminalExecRequest):
|
||||||
# ── VM: GET /vm/ssh-key ────────────────────────────────────────────────────────
|
# ── VM: GET /vm/ssh-key ────────────────────────────────────────────────────────
|
||||||
@app.get("/vm/ssh-key")
|
@app.get("/vm/ssh-key")
|
||||||
async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
|
async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
|
||||||
"""
|
|
||||||
Henter public SSH-nøkkel fra Compute Engine instance metadata.
|
|
||||||
Bruker google.auth ADC — krever compute.instances.get på service account.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
creds, project = google.auth.default()
|
creds, project = google.auth.default()
|
||||||
creds.refresh(google.auth.transport.requests.Request())
|
creds.refresh(google.auth.transport.requests.Request())
|
||||||
|
|
@ -340,7 +344,7 @@ async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
|
||||||
raise HTTPException(status_code=500, detail=f"vm/ssh-key feilet: {e}")
|
raise HTTPException(status_code=500, detail=f"vm/ssh-key feilet: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ── OQ-29: GET /notify/channels — konfigurerte delivery-kanaler ───────────────
|
# ── OQ-29: GET /notify/channels ───────────────────────────────────────────────
|
||||||
@app.get("/notify/channels")
|
@app.get("/notify/channels")
|
||||||
async def get_notify_channels():
|
async def get_notify_channels():
|
||||||
return {
|
return {
|
||||||
|
|
@ -352,6 +356,104 @@ async def get_notify_channels():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── EMMA: ML-3a intern agent ──────────────────────────────────────────────────
|
||||||
|
class EmmaRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
session_id: str = "emma-default"
|
||||||
|
history: Optional[List[Dict[str, str]]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Valgfri samtalehistorikk: [{role: user|model, content: str}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmmaChatService:
|
||||||
|
"""
|
||||||
|
Håndterer Emma-kall mot enten:
|
||||||
|
- Vertex AI (EMMA_BACKEND=vertex) — nå, bruker Vertex-kreditter
|
||||||
|
- Lokal Gemma 4 VM (EMMA_BACKEND=local) — når trigger er nådd
|
||||||
|
Bytt backend: sett EMMA_BACKEND=local + EMMA_LOCAL_URL=http://<VM-IP>:8000/generate
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def chat(self, message: str, history: Optional[List[Dict]] = None) -> str:
|
||||||
|
if EMMA_BACKEND == "local":
|
||||||
|
return await self._local(message, history)
|
||||||
|
return await self._vertex(message, history)
|
||||||
|
|
||||||
|
async def _vertex(self, message: str, history: Optional[List[Dict]] = None) -> str:
|
||||||
|
try:
|
||||||
|
import vertexai
|
||||||
|
from vertexai.generative_models import GenerativeModel, Content, Part
|
||||||
|
|
||||||
|
vertexai.init(project=PROJECT_ID, location="us-central1")
|
||||||
|
model = GenerativeModel(
|
||||||
|
EMMA_MODEL,
|
||||||
|
system_instruction=_EMMA_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_history = []
|
||||||
|
for turn in (history or []):
|
||||||
|
role = turn.get("role", "user")
|
||||||
|
content = turn.get("content", "")
|
||||||
|
chat_history.append(Content(role=role, parts=[Part.from_text(content)]))
|
||||||
|
|
||||||
|
chat = model.start_chat(history=chat_history)
|
||||||
|
response = chat.send_message(message)
|
||||||
|
return response.text
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Emma (Vertex) feilet: {e}")
|
||||||
|
|
||||||
|
async def _local(self, message: str, history: Optional[List[Dict]] = None) -> str:
|
||||||
|
"""
|
||||||
|
Kaller lokal Gemma 4 på VM via HTTP.
|
||||||
|
Forventet format: POST {EMMA_LOCAL_URL} med {prompt, system_prompt}
|
||||||
|
"""
|
||||||
|
turns = ""
|
||||||
|
for turn in (history or []):
|
||||||
|
role = "User" if turn.get("role") == "user" else "Emma"
|
||||||
|
turns += f"{role}: {turn.get('content', '')}\n"
|
||||||
|
full_prompt = f"{turns}User: {message}\nEmma:"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
EMMA_LOCAL_URL,
|
||||||
|
json={"prompt": full_prompt, "system_prompt": _EMMA_SYSTEM_PROMPT, "max_tokens": 1024},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("response") or data.get("text") or data.get("generated_text", "")
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(status_code=504, detail="Emma (lokal VM) svarte ikke innen 60s")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Emma (lokal) feilet: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
_emma = EmmaChatService()
|
||||||
|
|
||||||
|
|
||||||
|
async def _emma_chat(request: Request, req: EmmaRequest):
|
||||||
|
"""
|
||||||
|
POST /emma — intern agent, kun for Chris (IAP-beskyttet).
|
||||||
|
Backend: EMMA_BACKEND env-var (vertex | local).
|
||||||
|
"""
|
||||||
|
start = time.monotonic()
|
||||||
|
response_text = await _emma.chat(req.message, req.history)
|
||||||
|
duration = round(time.monotonic() - start, 3)
|
||||||
|
store = get_store()
|
||||||
|
store.push("emma", "last_duration_s", duration)
|
||||||
|
store.push("emma", "last_backend", EMMA_BACKEND)
|
||||||
|
store.push("emma", "last_success", True)
|
||||||
|
return JSONResponse({
|
||||||
|
"response": response_text,
|
||||||
|
"backend": EMMA_BACKEND,
|
||||||
|
"model": EMMA_MODEL,
|
||||||
|
"duration_s": duration,
|
||||||
|
"session_id": req.session_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.add_api_route("/emma", endpoint=require_auth(_emma_chat), methods=["POST"])
|
||||||
|
|
||||||
|
|
||||||
# ── MODELLER ──────────────────────────────────────────────────────────────────
|
# ── MODELLER ──────────────────────────────────────────────────────────────────
|
||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
@ -756,13 +858,11 @@ async def authenticated_billing_anomalies(request: Request):
|
||||||
app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_billing_anomalies), methods=["GET"])
|
app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_billing_anomalies), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
# ── CG4-credits: GET /billing/credits ────────────────────────────────────────
|
|
||||||
async def authenticated_billing_credits(request: Request, days: int = 90):
|
async def authenticated_billing_credits(request: Request, days: int = 90):
|
||||||
try:
|
try:
|
||||||
return JSONResponse(BillingAgent().get_credits_status(days=days))
|
return JSONResponse(BillingAgent().get_credits_status(days=days))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||||
|
|
||||||
app.add_api_route("/billing/credits", endpoint=require_auth(authenticated_billing_credits), methods=["GET"])
|
app.add_api_route("/billing/credits", endpoint=require_auth(authenticated_billing_credits), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -886,7 +986,7 @@ def run_dag(req: DagRequest):
|
||||||
store = get_store()
|
store = get_store()
|
||||||
log_dag_execution(dag_id=req.session_id, agent_results=results, total_duration_s=total_dur)
|
log_dag_execution(dag_id=req.session_id, agent_results=results, total_duration_s=total_dur)
|
||||||
store.push(AGENT_ID, "last_dag_total_duration_s", total_dur)
|
store.push(AGENT_ID, "last_dag_total_duration_s", total_dur)
|
||||||
store.push(AGENT_ID, "last_dag_agent_count", len(agent_ids))
|
store.push(AGENT_ID, "last_dag_agent_count", len(tasks))
|
||||||
store.push(AGENT_ID, "last_dag_success_count", sum(1 for r in results if r["success"]))
|
store.push(AGENT_ID, "last_dag_success_count", sum(1 for r in results if r["success"]))
|
||||||
return {
|
return {
|
||||||
"total_duration_s": total_dur,
|
"total_duration_s": total_dur,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user