import os import re import shlex from agromatrix_tools import tool_dictionary_review as review CATEGORIES = {"field","crop","operation","material","unit"} def is_operator(user_id: str | None, chat_id: str | None) -> bool: allowed_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()] allowed_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip() if allowed_chat and chat_id and str(chat_id) != allowed_chat: return False if allowed_ids and user_id and str(user_id) in allowed_ids: return True return False def parse_operator_command(text: str): parts = shlex.split(text) if not parts: return None cmd = parts[0].lstrip('/') args = parts[1:] return {"cmd": cmd, "args": args} def _wrap(summary: str, details: dict | None = None): return { "status": "ok", "summary": summary, "artifacts": [], "tool_calls": [], "next_actions": [], "details": details or {} } def _extract_ref(text: str) -> str | None: m = re.search(r"pending\.jsonl:\d+", text) return m.group(0) if m else None def _extract_index(text: str) -> int | None: m = re.search(r"(\d{1,3})", text) if not m: return None try: return int(m.group(1)) except Exception: return None def _extract_limit(text: str, default: int = 10) -> int: m = re.search(r"(?:до|limit)\s*(\d{1,3})", text) if m: try: return int(m.group(1)) except Exception: return default m = re.search(r"(\d{1,3})", text) if m: try: return int(m.group(1)) except Exception: return default return default def _extract_category(text: str) -> str | None: t = text.lower() if 'один' in t or 'unit' in t: return 'unit' if 'операц' in t or 'operation' in t: return 'operation' if 'культур' in t or 'crop' in t: return 'crop' if 'матеріал' in t or 'material' in t: return 'material' if 'поле' in t or 'field' in t: return 'field' return None def _extract_canonical_id(text: str) -> str | None: m = re.search(r"(?:як|as)\s+([\w\-:.]+)", text, flags=re.IGNORECASE) if m: return m.group(1) return None def _extract_reason(text: str) -> str: if ':' in text: return text.split(':', 1)[1].strip() m = re.search(r"(?:бо|через)\s+(.+)$", text, flags=re.IGNORECASE) if m: return m.group(1).strip() return '' def _resolve_ref_by_index(last_list: list | None, idx: int | None) -> str | None: if not last_list or not idx: return None if idx < 1 or idx > len(last_list): return None item = last_list[idx - 1] return item.get('pending_ref') or item.get('ref') def handle_whoami(user_id: str | None, chat_id: str | None): summary = "user_id: {}\nchat_id: {}".format(user_id or '', chat_id or '') return _wrap(summary) def handle_pending(limit=10, category=None): items = review.list_pending(limit=limit, category=category) lines = [] pending_items = [] for item in items: sug = item.get('suggestions', [])[:5] sug_s = ', '.join([f"{s['id']}({s['score']:.2f})" for s in sug]) lines.append(f"{item['pending_ref']} | {item.get('category')} | {item.get('raw_term')} | {sug_s}") pending_items.append({ 'pending_ref': item.get('pending_ref'), 'pending_id': item.get('pending_id'), 'raw_term': item.get('raw_term'), 'category': item.get('category'), 'suggestions': [{"id": s.get('id'), "score": s.get('score')} for s in sug] }) summary = "\n".join(lines) if lines else "Немає pending записів." return _wrap(summary, {"count": len(items), "pending_items": pending_items}) def handle_pending_show(ref: str): detail = review.get_pending_detail(ref) if not detail: return _wrap('Не знайдено pending запис') lines = [ f"ref: {detail.get('ref')}", f"category: {detail.get('category')}", f"raw_term: {detail.get('raw_term')}", f"ts: {detail.get('ts')}", f"status: {detail.get('status')}" ] if detail.get('decision'): lines.append(f"decision: {detail.get('decision')}") if detail.get('reason'): lines.append(f"reason: {detail.get('reason')}") lines.append('suggestions:') suggestions = detail.get('suggestions') or [] if suggestions: for s in suggestions: score = s.get('score') score_s = f"{score:.2f}" if isinstance(score, (int, float)) else "n/a" lines.append(f"- {s.get('id')} ({score_s})") else: lines.append('- (none)') return _wrap("\n".join(lines), detail) def handle_approve(ref, map_to=None, create_new=None, category=None, name=None, id_=None, apply=False): if map_to: action = {"type": "map_to_existing", "canonical_id": map_to} elif create_new: action = {"type": "create_new_entry", "canonical_id": id_ or '', "canonical_name": name or ''} else: action = {"type": "add_synonym", "canonical_id": map_to} res = review.approve_pending(ref, action) if apply: review.apply_resolutions() return _wrap(f"approved {ref}", {"record": res, "applied": apply}) def handle_reject(ref, reason): res = review.reject_pending(ref, reason) return _wrap(f"rejected {ref}", {"record": res}) def handle_apply(): count = review.apply_resolutions() return _wrap(f"applied {count}") def handle_stats(): stats = review.stats() return _wrap(f"open={stats['open']} approved={stats['approved']} rejected={stats['rejected']}", stats) def route_operator_text(text: str, user_id: str | None, chat_id: str | None, last_pending_list: list | None = None): if not is_operator(user_id, chat_id): return { "status": "error", "summary": "Недостатньо прав", "artifacts": [], "tool_calls": [], "next_actions": [] } t = text.strip().lower() # list pending if any(k in t for k in ['покажи', 'показати', 'невпізнан', 'непізнан', 'pending', 'невідом']): category = _extract_category(t) limit = _extract_limit(t, default=10) if limit < 1: limit = 1 if limit > 100: limit = 100 if category and category not in CATEGORIES: return _wrap('unknown category') return handle_pending(limit=limit, category=category) # stats if any(k in t for k in ['статист', 'скільки pending', 'pending stats']): return handle_stats() # apply if any(k in t for k in ['застосуй', 'apply', 'застосувати', 'застосуй зміни']): return handle_apply() # details if any(k in t for k in ['деталі', 'detail', 'подробиц', 'покажи деталі']): ref = _extract_ref(t) if not ref: idx = _extract_index(t) ref = _resolve_ref_by_index(last_pending_list, idx) if not ref: return _wrap('Немає ref або контексту для деталей') return handle_pending_show(ref) # approve if any(k in t for k in ['підтверд', 'approve', 'схвали']): ref = _extract_ref(t) if not ref: idx = _extract_index(t) ref = _resolve_ref_by_index(last_pending_list, idx) if not ref: return _wrap('Немає ref або контексту для підтвердження') canonical_id = _extract_canonical_id(t) if not canonical_id: return _wrap('Вкажіть canonical_id після "як"') return handle_approve(ref, map_to=canonical_id) # reject if any(k in t for k in ['відхил', 'reject', 'забракуй']): ref = _extract_ref(t) if not ref: idx = _extract_index(t) ref = _resolve_ref_by_index(last_pending_list, idx) if not ref: return _wrap('Немає ref або контексту для відхилення') reason = _extract_reason(text) if not reason: return _wrap('Вкажіть причину відхилення') return handle_reject(ref, reason) return None def route_operator_command(text: str, user_id: str | None, chat_id: str | None): parsed = parse_operator_command(text) if not parsed: return None if not is_operator(user_id, chat_id): return { "status": "error", "summary": "Недостатньо прав", "artifacts": [], "tool_calls": [], "next_actions": [] } cmd = parsed['cmd'] args = parsed['args'] if cmd == 'whoami': return handle_whoami(user_id, chat_id) if cmd == 'pending_show': if not args: return _wrap('Потрібен ref') return handle_pending_show(args[0]) if cmd == 'pending': limit = 10 category = None i = 0 while i < len(args): if args[i] == '--limit' and i + 1 < len(args): try: limit = int(args[i+1]) except Exception: limit = 10 i += 2 continue if args[i] == '--category' and i + 1 < len(args): category = args[i+1] i += 2 continue i += 1 # clamp if limit < 1: limit = 1 if limit > 100: limit = 100 if category and category not in CATEGORIES: return _wrap('unknown category') return handle_pending(limit=limit, category=category) if cmd == 'approve': if not args: return _wrap('missing ref') ref = args[0] map_to = None create_new = False category = None name = None id_ = None apply = False i = 1 while i < len(args): if args[i] == 'map_to': map_to = args[i+1] i += 2 elif args[i] == '--apply': apply = True i += 1 elif args[i] == 'create_new': create_new = True i += 1 elif args[i] == 'category': category = args[i+1] i += 2 elif args[i] == 'name': name = args[i+1] i += 2 elif args[i] == 'id': id_ = args[i+1] i += 2 else: i += 1 if apply: allow_apply = os.getenv('AGX_ALLOW_APPLY', '0') == '1' or os.getenv('AGX_OPS_MODE', '0') == '1' if not allow_apply: return _wrap('apply_not_allowed') return handle_approve(ref, map_to=map_to, create_new=create_new, category=category, name=name, id_=id_, apply=apply) if cmd == 'reject': if len(args) < 2: return _wrap('missing ref or reason') ref = args[0] reason = ' '.join(args[1:]) return handle_reject(ref, reason) if cmd == 'apply_dict': return handle_apply() if cmd == 'pending_stats': return handle_stats() return _wrap('unknown command')