| """ |
| Worker Agent 3 — Embedding Agent |
| Task: Embed chunked documents into a vector store using selected model. |
| Uses mock embeddings for determinism during training. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import math |
| from pathlib import Path |
| from typing import Any, Optional |
|
|
| from workers.base_worker import BaseWorker |
|
|
| EMBEDDING_TASK_CONFIGS = { |
| "easy_embedding": {"task_id": "easy_embedding", "difficulty": "easy", "step_budget": 8, "corpus_size": 50}, |
| "medium_embedding": {"task_id": "medium_embedding", "difficulty": "medium", "step_budget": 10, "corpus_size": 100}, |
| "hard_embedding": {"task_id": "hard_embedding", "difficulty": "hard", "step_budget": 12, "corpus_size": 200}, |
| } |
|
|
| VALID_MODELS = ["all-MiniLM-L6-v2", "all-mpnet-base-v2", "paraphrase-multilingual-MiniLM-L12-v2"] |
| EMBEDDING_DIM = 384 |
|
|
|
|
| STOP_WORDS = {"does", "is", "a", "the", "does", "support", "nexacrm", "faq", "what", "are", "you", "built", "on", "with", "how", "do", "i", "can", "an", "available", "on", "for", "in", "at", "rest"} |
|
|
| def mock_embed(text: str) -> list[float]: |
| """ |
| Improved keyword-overlap embedding with stop-word filtering. |
| Prioritizes unique terms like 'Slack', 'Pricing', or 'GDPR'. |
| """ |
| |
| raw_words = text.lower().replace("?", "").replace("!", "").replace(".", "").replace(",", "").replace("\u2014", " ").split() |
| words = [w for w in raw_words if w not in STOP_WORDS and len(w) > 1] |
| |
| if not words: |
| |
| words = [w for w in raw_words if len(w) > 1] |
| |
| if not words: |
| return [0.0] * EMBEDDING_DIM |
| |
| vec = [0.0] * EMBEDDING_DIM |
| for w in words: |
| idx = int(hashlib.md5(w.encode()).hexdigest(), 16) % EMBEDDING_DIM |
| |
| vec[idx] += 1.0 |
| |
| norm = math.sqrt(sum(x * x for x in vec)) or 1.0 |
| return [x / norm for x in vec] |
|
|
|
|
| class EmbeddingEnv(BaseWorker): |
| """ |
| Worker Agent 3: Embedding Agent. |
| |
| Actions: |
| - select_model: Choose embedding model |
| - configure_batch_size: Set batch size for embedding |
| - run_embedding: Embed all chunks |
| - validate_coverage: Check coverage ratio |
| - inspect_vectors: Sample 3 vectors |
| - store_index: Persist index to memory |
| - submit: Submit index and receive terminal reward |
| """ |
|
|
| VALID_ACTIONS = [ |
| "select_model", "configure_batch_size", "run_embedding", |
| "validate_coverage", "inspect_vectors", "store_index", "submit" |
| ] |
|
|
| def __init__(self) -> None: |
| super().__init__(worker_id="worker_3", worker_name="Embedding Agent") |
| self.selected_model: Optional[str] = None |
| self.batch_size: int = 32 |
| self.index: dict[str, list[float]] = {} |
| self.chunks_embedded: int = 0 |
| self.null_embedding_count: int = 0 |
| self.coverage_ratio: float = 0.0 |
| self.index_stored: bool = False |
| self.embedding_done: bool = False |
| self.task_config: dict = {} |
| self.input_chunks: list[dict] = [] |
|
|
| def reset(self, task_id: str, input_chunks: Optional[list[dict]] = None) -> dict: |
| self._reset_episode_tracking() |
| self.task_id = task_id |
| self.task_config = EMBEDDING_TASK_CONFIGS.get(task_id, EMBEDDING_TASK_CONFIGS["easy_embedding"]) |
| self.step_budget = self.task_config["step_budget"] |
| self.step_budget_remaining = self.step_budget |
| self.selected_model = None |
| self.batch_size = 32 |
| self.index = {} |
| self.chunks_embedded = 0 |
| self.null_embedding_count = 0 |
| self.coverage_ratio = 0.0 |
| self.index_stored = False |
| self.embedding_done = False |
| self.input_chunks = input_chunks or self._load_chunks(self.task_config["corpus_size"]) |
| return self.state() |
|
|
| def step(self, action_dict: dict) -> tuple[dict, float, bool, dict]: |
| operation = action_dict.get("operation", "") |
| parameters = action_dict.get("parameters", {}) |
|
|
| if self.is_done: |
| return self.state(), 0.0, True, {"error": "episode_already_done", "action": operation} |
|
|
| if operation not in self.VALID_ACTIONS: |
| self._record_governance_event("invalid_action", "medium", f"Unknown: {operation}") |
| reward = self._clip_reward(0.0) |
| self.step_count += 1 |
| self.step_budget_remaining -= 1 |
| self.total_reward += reward |
| done = self._is_budget_exhausted() |
| if done: |
| self.is_done = True |
| info = {"error": f"invalid_action:{operation}", "action": operation} |
| self._record_action(operation, reward, info) |
| return self.state(), reward, done, info |
|
|
| reward, info = self._dispatch(operation, parameters) |
| reward = self._clip_reward(reward) |
| self.step_count += 1 |
| self.step_budget_remaining -= 1 |
| self.total_reward += reward |
| done = self.is_done or self._is_budget_exhausted() |
| if done: |
| self.is_done = True |
| self._record_action(operation, reward, info) |
| return self.state(), reward, done, info |
|
|
| def state(self) -> dict: |
| base = self._get_base_state() |
| base.update({ |
| "selected_model": self.selected_model, |
| "batch_size": self.batch_size, |
| "chunks_embedded": self.chunks_embedded, |
| "null_embedding_count": self.null_embedding_count, |
| "coverage_ratio": round(self.coverage_ratio, 4), |
| "index_stored": self.index_stored, |
| "embedding_done": self.embedding_done, |
| "input_chunk_count": len(self.input_chunks), |
| }) |
| return base |
|
|
| def generate_run_report(self) -> dict: |
| report = self._get_base_report() |
| report.update({ |
| "selected_model": self.selected_model, |
| "chunks_embedded": self.chunks_embedded, |
| "null_embedding_count": self.null_embedding_count, |
| "coverage_ratio": self.coverage_ratio, |
| "index_stored": self.index_stored, |
| "final_score": self._compute_final_score(), |
| }) |
| return report |
|
|
| def evaluate_run(self) -> dict: |
| epsilon = 1e-6 |
| score = min(max(self._compute_final_score(), epsilon), 1.0 - epsilon) |
| gates = { |
| "model_selected": self.selected_model is not None, |
| "embedding_executed": self.embedding_done, |
| "index_stored": self.index_stored, |
| "no_null_embeddings": self.null_embedding_count == 0, |
| "submitted": self.submitted, |
| } |
| return {"approved": all(gates.values()) and score >= 0.6, "gates": gates, "composite_score": score} |
|
|
| def _dispatch(self, operation: str, parameters: dict) -> tuple[float, dict]: |
| if operation == "select_model": |
| return self._select_model(parameters) |
| elif operation == "configure_batch_size": |
| return self._configure_batch_size(parameters) |
| elif operation == "run_embedding": |
| return self._run_embedding() |
| elif operation == "validate_coverage": |
| return self._validate_coverage() |
| elif operation == "inspect_vectors": |
| return self._inspect_vectors() |
| elif operation == "store_index": |
| return self._store_index() |
| elif operation == "submit": |
| return self._submit() |
| return 0.0, {"error": "unknown_operation"} |
|
|
| def _select_model(self, params: dict) -> tuple[float, dict]: |
| model = params.get("model_name", "") |
| if model not in VALID_MODELS: |
| return 0.1, {"error": f"invalid_model:{model}", "action": "select_model"} |
| self.selected_model = model |
| return 0.4, {"error": None, "action": "select_model", "model": model} |
|
|
| def _configure_batch_size(self, params: dict) -> tuple[float, dict]: |
| try: |
| bs = int(params.get("batch_size", 32)) |
| self.batch_size = max(1, min(bs, 256)) |
| return 0.2, {"error": None, "action": "configure_batch_size", "batch_size": self.batch_size} |
| except (ValueError, TypeError): |
| return 0.0, {"error": "invalid_batch_size", "action": "configure_batch_size"} |
|
|
| def _run_embedding(self) -> tuple[float, dict]: |
| if not self.selected_model: |
| self._record_governance_event("no_model", "high", "run_embedding called without model selected") |
| return 0.05, {"error": "model_not_selected", "action": "run_embedding"} |
| self.index = {} |
| self.null_embedding_count = 0 |
| for chunk in self.input_chunks: |
| cid = chunk.get("chunk_id", f"chunk_{len(self.index):03d}") |
| text = chunk.get("text", "") |
| if not text.strip(): |
| self.null_embedding_count += 1 |
| continue |
| self.index[cid] = mock_embed(text) |
| self.chunks_embedded = len(self.index) |
| total = len(self.input_chunks) |
| self.coverage_ratio = self.chunks_embedded / max(total, 1) |
| self.embedding_done = True |
| reward = 0.4 * self.coverage_ratio + 0.3 * (1.0 if self.null_embedding_count == 0 else 0.3) + 0.2 * (self.step_budget_remaining / max(self.step_budget, 1)) |
| return reward, {"error": None, "action": "run_embedding", "chunks_embedded": self.chunks_embedded} |
|
|
| def _validate_coverage(self) -> tuple[float, dict]: |
| if not self.embedding_done: |
| return 0.1, {"error": "embedding_not_done", "action": "validate_coverage"} |
| report = {"coverage_ratio": round(self.coverage_ratio, 4), "null_count": self.null_embedding_count, "total_chunks": len(self.input_chunks)} |
| reward = 0.5 if self.coverage_ratio >= 0.95 else 0.2 |
| return reward, {"error": None, "action": "validate_coverage", "report": report} |
|
|
| def _inspect_vectors(self) -> tuple[float, dict]: |
| sample = {k: v[:5] for k, v in list(self.index.items())[:3]} |
| return 0.15, {"error": None, "action": "inspect_vectors", "sample": sample} |
|
|
| def _store_index(self) -> tuple[float, dict]: |
| if not self.embedding_done: |
| return 0.05, {"error": "embedding_not_done", "action": "store_index"} |
| self.index_stored = True |
| return 0.4, {"error": None, "action": "store_index", "index_size": len(self.index)} |
|
|
| def _submit(self) -> tuple[float, dict]: |
| if not self.index_stored: |
| self._record_governance_event("premature_submit", "high", "submit called before store_index") |
| return 0.05, {"error": "index_not_stored", "action": "submit"} |
| self.submitted = True |
| self.is_done = True |
| final_score = self._compute_final_score() |
| return min(final_score + 0.15, 0.99), {"error": None, "action": "submit", "final_score": final_score} |
|
|
| def _compute_final_score(self) -> float: |
| if not self.embedding_done: |
| return 0.001 |
| score = ( |
| 0.4 * self.coverage_ratio + |
| 0.3 * (1.0 if self.null_embedding_count == 0 else 0.0) + |
| 0.2 * (1.0 if self.index_stored else 0.0) + |
| 0.1 * (self.step_budget_remaining / max(self.step_budget, 1)) |
| ) |
| return self._clip_reward(score) |
|
|
| def _load_chunks(self, size: int) -> list[dict]: |
| path = Path("data/nexacrm_corpus.json") |
| if path.exists(): |
| with open(path) as f: |
| return json.load(f)[:size] |
| return [{"chunk_id": f"chunk_{i:03d}", "text": f"NexaCRM entry {i}"} for i in range(size)] |
|
|