Bot & Automation
jorgasisten
/root/hermes-projects/jorgasisten
apps/jorgasisten/services/operations.py
text
import json
import logging
from datetime import UTC, datetime, tzinfo
from typing import Any, Protocol
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from jorgasisten.core.config import Settings, get_settings
from jorgasisten.schemas import AssistantDecision, OperationResult, SheetRow, SheetSchema
from jorgasisten.services.access_control import (
can_delete_data,
can_manage_structure,
can_write_data,
)
from jorgasisten.services.formatters import format_rows, format_summary
from jorgasisten.services.sheets import resolve_header
from jorgasisten.services.text import normalize_key, safe_string
logger = logging.getLogger(__name__)
STRUCTURE_WRITE_OPERATIONS = {"create_sheet", "update_headers", "rename_sheet"}
DELETE_OPERATIONS = {"delete", "delete_sheet"}
WRITE_OPERATIONS = {"create", "update"} | STRUCTURE_WRITE_OPERATIONS | DELETE_OPERATIONS
RISKY_CONFIRMATION_OPERATIONS = {"delete", "update_headers", "rename_sheet", "delete_sheet"}
AUDIT_LOG_SHEET_NAME = "Bot_Log"
AUDIT_LOG_HEADERS = [
"Tanggal",
"User Telegram",
"Aksi",
"Sheet",
"Target Row",
"Status",
"Confidence",
"Detail",
"Reason",
]
class SheetsGateway(Protocol):
def get_catalog(self) -> list[SheetSchema]: ...
def get_schema(self, sheet_name: str) -> SheetSchema: ...
def find_rows(self, sheet_name: str, filters: dict[str, Any], limit: int | None = None) -> list[SheetRow]: ...
def read_rows(self, sheet_name: str) -> list[SheetRow]: ...
def append_row(self, sheet_name: str, data: dict[str, Any]) -> SheetRow: ...
def update_row(self, sheet_name: str, row_number: int, data: dict[str, Any]) -> SheetRow: ...
def soft_delete_row(self, sheet_name: str, row_number: int) -> bool: ...
def delete_row(self, sheet_name: str, row_number: int) -> None: ...
def create_sheet(self, sheet_name: str, headers: list[str]) -> SheetSchema: ...
def update_headers(self, sheet_name: str, headers: list[str]) -> SheetSchema: ...
def rename_sheet(self, sheet_name: str, new_sheet_name: str) -> SheetSchema: ...
def delete_sheet_full(self, sheet_name: str) -> None: ...
class OperationExecutor:
def __init__(self, sheets: SheetsGateway, settings: Settings | None = None) -> None:
self.sheets = sheets
self.settings = settings or get_settings()
def execute(
self,
decision: AssistantDecision,
actor_role: str,
*,
confirmed: bool = False,
prepared_create_data: dict[str, Any] | None = None,
actor_label: str = "",
) -> OperationResult:
if decision.operation == "chat":
return OperationResult(
ok=True,
user_message=decision.user_reply or "bisaa pren, tanya aja. gw bantu jawab sebisanya yaa.",
decision=decision,
)
if decision.operation == "ask_clarification":
return OperationResult(
ok=False,
user_message=decision.user_reply or "gw perlu klarifikasi dulu nih pren.",
decision=decision,
)
if decision.operation in {"create", "update"} and not can_write_data(actor_role): # type: ignore[arg-type]
return OperationResult(
ok=False,
user_message="role akun ini belum boleh input atau update data, pren.",
decision=decision,
)
if decision.operation in DELETE_OPERATIONS and not can_delete_data(actor_role): # type: ignore[arg-type]
return OperationResult(
ok=False,
user_message="role akun ini belum boleh hapus data atau sheet, pren.",
decision=decision,
)
if decision.operation in STRUCTURE_WRITE_OPERATIONS | {"delete_sheet"} and not can_manage_structure(
actor_role # type: ignore[arg-type]
):
return OperationResult(
ok=False,
user_message="role akun ini belum boleh ubah struktur sheet, pren.",
decision=decision,
)
if decision.operation in {"create", "update", *STRUCTURE_WRITE_OPERATIONS} and (
decision.confidence < self.settings.min_write_confidence
):
return OperationResult(
ok=False,
user_message=(
decision.user_reply
or "confidence-nya belum cukup buat ngubah data, bestii. coba perjelas instruksinya yaa."
),
decision=decision,
)
if decision.operation in DELETE_OPERATIONS and decision.confidence < self.settings.min_delete_confidence:
return OperationResult(
ok=False,
user_message="target hapusnya belum jelas, pren. sebutin data yang mau dihapus lebih spesifik yaa.",
decision=decision,
)
if not decision.sheet_name:
return OperationResult(
ok=False,
user_message=(
decision.user_reply or "sheet tujuannya belum kebaca jelas, bestii. ini masuk data apa yaa?"
),
decision=decision,
)
result: OperationResult | None = None
if decision.operation == "read":
result = self._read(decision)
elif decision.operation == "create":
result = self._create(decision, confirmed=confirmed, prepared_create_data=prepared_create_data)
elif decision.operation == "update":
result = self._update(decision, confirmed=confirmed, prepared_create_data=prepared_create_data)
elif decision.operation == "delete":
result = self._delete(decision, confirmed=confirmed)
elif decision.operation == "summarize":
result = self._summarize(decision)
elif decision.operation == "create_sheet":
result = self._create_sheet(decision)
elif decision.operation == "update_headers":
result = self._update_headers(decision, confirmed=confirmed)
elif decision.operation == "rename_sheet":
result = self._rename_sheet(decision, confirmed=confirmed)
elif decision.operation == "delete_sheet":
result = self._delete_sheet(decision, confirmed=confirmed)
if result is None:
return OperationResult(
ok=False,
user_message="gw belum nangkep operasi ini, pren. coba tulis lebih jelas yaa.",
decision=decision,
)
if self._should_audit(decision, result):
self._write_audit_log(decision, result, actor_label=actor_label)
return result
def _read(self, decision: AssistantDecision) -> OperationResult:
rows = self.sheets.find_rows(decision.sheet_name, decision.filters, limit=self.settings.max_result_rows)
message = decision.user_reply or "nih gw nemu data yang paling nyambung, pren."
return OperationResult(ok=True, user_message=f"{message}\n\n{format_rows(rows)}", decision=decision, rows=rows)
def _create(
self,
decision: AssistantDecision,
*,
confirmed: bool,
prepared_create_data: dict[str, Any] | None = None,
) -> OperationResult:
if decision.missing_fields:
return OperationResult(
ok=False,
user_message=(
decision.user_reply
or "masih ada data yang perlu dilengkapin nih pren: " + ", ".join(decision.missing_fields)
),
decision=decision,
)
schema = self.sheets.get_schema(decision.sheet_name)
if confirmed and prepared_create_data:
normalized_data = normalize_data_for_headers(prepared_create_data, schema.headers)
if not any(safe_string(value) for value in normalized_data.values()):
return OperationResult(
ok=False,
user_message=(
"data siap simpan tadi udah kosong atau gak cocok sama kolom sheet, pren. "
"coba kirim ulang templatenya yaa."
),
decision=decision,
)
row = self.sheets.append_row(decision.sheet_name, normalized_data)
return OperationResult(
ok=True,
user_message=(
f"done pren, datanya udah gw catat ke sheet {decision.sheet_name}.\n\n"
f"{format_rows([row], max_rows=1)}"
),
decision=decision,
rows=[row],
)
existing_rows = self.sheets.read_rows(decision.sheet_name)
normalized_data = normalize_data_for_headers(decision.data, schema.headers)
normalized_data = autofill_sequential_fields(normalized_data, schema.headers, existing_rows)
normalized_data = normalize_dropdown_values(normalized_data, schema)
if not any(safe_string(value) for value in normalized_data.values()):
return OperationResult(
ok=False,
user_message=(
"data yang mau disimpan belum cocok sama kolom sheet, bestii. "
"kirim formatnya kayak Nama Kolom: nilai yaa."
),
decision=decision,
)
validation_error = validate_create_data(normalized_data, schema, existing_rows)
if validation_error:
return OperationResult(
ok=False,
user_message=validation_error,
decision=decision,
)
preview_row = SheetRow(
sheet_name=decision.sheet_name,
row_number=0,
values={key: value for key, value in normalized_data.items() if safe_string(value)},
)
if not confirmed:
return OperationResult(
ok=False,
user_message=build_create_preview_message(preview_row),
decision=decision,
rows=[preview_row],
requires_confirmation=True,
prepared_data=normalized_data,
)
row = self.sheets.append_row(decision.sheet_name, normalized_data)
return OperationResult(
ok=True,
user_message=(
f"done pren, datanya udah gw catat ke sheet {decision.sheet_name}.\n\n{format_rows([row], max_rows=1)}"
),
decision=decision,
rows=[row],
)
def _update(
self,
decision: AssistantDecision,
*,
confirmed: bool,
prepared_create_data: dict[str, Any] | None = None,
) -> OperationResult:
if not decision.filters:
return OperationResult(
ok=False,
user_message="target update-nya belum jelas, pren. sebutin data lama yang mau dicari dulu yaa.",
decision=decision,
)
if not decision.data:
return OperationResult(
ok=False,
user_message="data barunya belum lu sebutin, bestii. mau diganti jadi apa?",
decision=decision,
)
rows = self.sheets.find_rows(decision.sheet_name, decision.filters, limit=3)
if len(rows) != 1:
return OperationResult(
ok=False,
user_message=build_ambiguous_target_message("update", rows),
decision=decision,
rows=rows,
)
schema = self.sheets.get_schema(decision.sheet_name)
normalized_data = normalize_data_for_headers(decision.data, schema.headers)
normalized_data = normalize_dropdown_values(normalized_data, schema)
changed_data = {key: value for key, value in normalized_data.items() if safe_string(value)}
if not changed_data:
return OperationResult(
ok=False,
user_message="data update-nya belum cocok sama kolom sheet, pren. isi field yang mau diubah yaa.",
decision=decision,
)
if not confirmed:
return OperationResult(
ok=False,
user_message=build_update_preview_message(rows[0], changed_data),
decision=decision,
rows=rows,
requires_confirmation=True,
prepared_data=changed_data,
)
update_payload = prepared_create_data or changed_data
updated = self.sheets.update_row(decision.sheet_name, rows[0].row_number, update_payload)
return OperationResult(
ok=True,
user_message=f"beres pren, datanya udah gw update.\n\n{format_rows([updated], max_rows=1)}",
decision=decision,
rows=[updated],
)
def _delete(self, decision: AssistantDecision, *, confirmed: bool) -> OperationResult:
if not decision.filters:
return OperationResult(
ok=False,
user_message="target hapusnya belum jelas, pren. sebutin data yang mau dihapus lebih spesifik yaa.",
decision=decision,
)
rows = self.sheets.find_rows(decision.sheet_name, decision.filters, limit=3)
if len(rows) != 1:
return OperationResult(
ok=False,
user_message=build_ambiguous_target_message("hapus", rows),
decision=decision,
rows=rows,
)
if not confirmed:
return OperationResult(
ok=False,
user_message=build_delete_preview_message(rows[0]),
decision=decision,
rows=rows,
requires_confirmation=True,
)
if self.settings.safe_delete_strategy == "soft":
deleted = self.sheets.soft_delete_row(decision.sheet_name, rows[0].row_number)
if not deleted:
return OperationResult(
ok=False,
user_message=(
"sheet ini belum punya kolom status/delete buat safe delete, pren. "
"tambahin kolom status atau ubah SAFE_DELETE_STRATEGY=row kalau mau hapus row fisik."
),
decision=decision,
rows=rows,
)
else:
self.sheets.delete_row(decision.sheet_name, rows[0].row_number)
return OperationResult(
ok=True,
user_message=f"oke pren, data row {rows[0].row_number} di sheet {decision.sheet_name} udah gw hapus.",
decision=decision,
rows=rows,
)
def _summarize(self, decision: AssistantDecision) -> OperationResult:
rows = self.sheets.find_rows(decision.sheet_name, decision.filters, limit=self.settings.max_result_rows)
if not rows:
rows = self.sheets.read_rows(decision.sheet_name)[-self.settings.max_result_rows :]
return OperationResult(
ok=True,
user_message=format_summary(decision.sheet_name, rows),
decision=decision,
rows=rows,
)
def _create_sheet(self, decision: AssistantDecision) -> OperationResult:
headers = normalize_headers(decision.data)
if not headers:
return OperationResult(
ok=False,
user_message="kolomnya belum jelas, pren. contoh: buat sheet Booking dengan kolom Nama, WA, Motor.",
decision=decision,
)
schema = self.sheets.create_sheet(decision.sheet_name, headers)
return OperationResult(
ok=True,
user_message=(f"done pren, sheet {schema.title} udah gw buatin.\nkolomnya: {', '.join(schema.headers)}"),
decision=decision,
)
def _update_headers(self, decision: AssistantDecision, *, confirmed: bool) -> OperationResult:
headers = normalize_headers(decision.data)
if not headers:
return OperationResult(
ok=False,
user_message="header barunya belum jelas, bestii. kirim daftar kolomnya yaa.",
decision=decision,
)
if not confirmed:
return self._confirmation_required(
decision,
"ganti header",
f"ganti struktur kolom sheet {decision.sheet_name} jadi: {', '.join(headers)}",
)
schema = self.sheets.update_headers(decision.sheet_name, headers)
return OperationResult(
ok=True,
user_message=(
f"beres pren, header sheet {schema.title} udah gw ganti.\nkolom sekarang: {', '.join(schema.headers)}"
),
decision=decision,
)
def _rename_sheet(self, decision: AssistantDecision, *, confirmed: bool) -> OperationResult:
new_sheet_name = normalize_sheet_name(
decision.data.get("new_sheet_name") or decision.data.get("nama_baru") or decision.data.get("new_name")
)
if not new_sheet_name:
return OperationResult(
ok=False,
user_message="nama sheet barunya belum lu sebutin, pren.",
decision=decision,
)
if not confirmed:
return self._confirmation_required(
decision,
"rename sheet",
f"rename sheet {decision.sheet_name} jadi {new_sheet_name}",
)
schema = self.sheets.rename_sheet(decision.sheet_name, new_sheet_name)
return OperationResult(
ok=True,
user_message=f"done pren, sheet {decision.sheet_name} udah gw rename jadi {schema.title}.",
decision=decision,
)
def _delete_sheet(self, decision: AssistantDecision, *, confirmed: bool) -> OperationResult:
if not confirmed and not is_truthy(decision.data.get("confirm_delete")):
return OperationResult(
ok=False,
user_message=(
"hapus sheet penuh itu permanen, pren. kalau yakin, tulis jelas: "
f"hapus sheet penuh {decision.sheet_name}"
),
decision=decision,
)
if not confirmed:
return self._confirmation_required(
decision,
"hapus sheet penuh",
f"hapus seluruh sheet {decision.sheet_name}",
)
self.sheets.delete_sheet_full(decision.sheet_name)
return OperationResult(
ok=True,
user_message=f"oke pren, sheet {decision.sheet_name} udah gw hapus penuh.",
decision=decision,
)
def _confirmation_required(
self,
decision: AssistantDecision,
action_label: str,
summary: str,
rows: list[SheetRow] | None = None,
) -> OperationResult:
row_text = ""
if rows:
row_refs = ", ".join(f"row {row.row_number}" for row in rows)
row_text = f"\ntarget: {row_refs}"
return OperationResult(
ok=False,
user_message=(
f"sebelum lanjut, konfirmasi dulu ya pren.\n\n"
f"aksi: {action_label}\n"
f"detail: {summary}{row_text}\n\n"
"pilih lanjut kalau targetnya udah bener."
),
decision=decision,
rows=rows or [],
requires_confirmation=True,
)
def _should_audit(self, decision: AssistantDecision, result: OperationResult) -> bool:
if not result.ok or result.requires_confirmation:
return False
if decision.operation not in WRITE_OPERATIONS:
return False
audit_sheet_name = self._audit_sheet_name()
return decision.sheet_name != audit_sheet_name and bool(getattr(self.settings, "audit_log_enabled", True))
def _write_audit_log(self, decision: AssistantDecision, result: OperationResult, actor_label: str) -> None:
try:
self._ensure_audit_sheet()
self.sheets.append_row(
self._audit_sheet_name(),
{
"Tanggal": current_timestamp(getattr(self.settings, "data_timezone", "Asia/Jakarta")),
"User Telegram": actor_label or "-",
"Aksi": decision.operation,
"Sheet": decision.sheet_name,
"Target Row": format_row_numbers(result.rows),
"Status": "berhasil",
"Confidence": str(decision.confidence),
"Detail": build_audit_detail(decision),
"Reason": decision.reason or "-",
},
)
except Exception as exc:
logger.exception("Failed to write audit log: %s", exc)
def _ensure_audit_sheet(self) -> None:
audit_sheet_name = self._audit_sheet_name()
if any(schema.title == audit_sheet_name for schema in self.sheets.get_catalog()):
return
self.sheets.create_sheet(audit_sheet_name, AUDIT_LOG_HEADERS)
def _audit_sheet_name(self) -> str:
return safe_string(getattr(self.settings, "audit_log_sheet_name", AUDIT_LOG_SHEET_NAME)) or AUDIT_LOG_SHEET_NAME
def normalize_data_for_headers(data: dict[str, Any], headers: list[str]) -> dict[str, Any]:
normalized = {header: "" for header in headers}
for key, value in data.items():
header = resolve_header(key, headers)
if header and safe_string(value):
normalized[header] = value
return normalized
def normalize_sheet_name(value: Any) -> str:
return safe_string(value).strip()
def normalize_headers(data: dict[str, Any]) -> list[str]:
raw_headers = (
data.get("headers")
or data.get("columns")
or data.get("kolom")
or data.get("header")
or data.get("daftar_kolom")
)
if isinstance(raw_headers, str):
parts = raw_headers.replace("|", ",").split(",")
elif isinstance(raw_headers, list):
parts = [safe_string(header) for header in raw_headers]
else:
parts = []
headers: list[str] = []
seen: set[str] = set()
for part in parts:
header = safe_string(part)
if not header:
continue
normalized = header.lower()
if normalized in seen:
continue
seen.add(normalized)
headers.append(header)
return headers
def is_truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return safe_string(value).lower() in {"true", "yes", "ya", "yakin", "1", "confirm", "confirmed"}
def build_ambiguous_target_message(action: str, rows: list[SheetRow]) -> str:
if not rows:
return f"data target buat {action} belum ketemu, pren. coba perjelas filter pencariannya yaa."
row_refs = ", ".join(f"row {row.row_number}" for row in rows)
return f"gw nemu lebih dari satu target buat {action}: {row_refs}. sebutin yang lebih spesifik yaa."
def current_timestamp(timezone_name: str) -> str:
current_tz: tzinfo
try:
current_tz = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
current_tz = UTC
return datetime.now(current_tz).strftime("%Y-%m-%d %H:%M:%S %Z")
def format_row_numbers(rows: list[SheetRow]) -> str:
if not rows:
return "-"
return ", ".join(str(row.row_number) for row in rows)
def build_audit_detail(decision: AssistantDecision) -> str:
payload = {
"data": decision.data,
"filters": decision.filters,
"missing_fields": decision.missing_fields,
}
detail = json.dumps(payload, ensure_ascii=False, default=str)
return detail if len(detail) <= 900 else detail[:897] + "..."
def build_create_preview_message(row: SheetRow) -> str:
lines = ["DATA TERBACA"]
for key, value in row.values.items():
value_text = safe_string(value)
if value_text:
lines.append(f"- {key}: {value_text}")
lines.append("Apakah data sudah benar?")
return "\n".join(lines)
def build_update_preview_message(row: SheetRow, changed_data: dict[str, Any]) -> str:
lines = ["PERUBAHAN TERBACA", f"- Row: {row.row_number}", "", "Data lama:"]
for key, _new_value in changed_data.items():
old_value = safe_string(row.values.get(key)) or "-"
lines.append(f"- {key}: {old_value}")
lines.append("")
lines.append("Data baru:")
for key, new_value in changed_data.items():
value_text = safe_string(new_value)
if value_text:
lines.append(f"- {key}: {value_text}")
lines.append("Apakah perubahan sudah benar?")
return "\n".join(lines)
def build_delete_preview_message(row: SheetRow) -> str:
lines = ["DATA YANG AKAN DIHAPUS", f"- Row: {row.row_number}"]
for key, value in row.values.items():
value_text = safe_string(value)
if value_text:
lines.append(f"- {key}: {value_text}")
lines.append("Yakin mau hapus data ini?")
return "\n".join(lines)
def autofill_sequential_fields(data: dict[str, Any], headers: list[str], rows: list[SheetRow]) -> dict[str, Any]:
result = dict(data)
number_header = next((header for header in headers if normalize_key(header) in {"no", "nomor", "number"}), None)
if number_header and not safe_string(result.get(number_header)):
result[number_header] = str(next_sequence_number(number_header, rows))
return result
def next_sequence_number(header: str, rows: list[SheetRow]) -> int:
numbers: set[int] = set()
for row in rows:
value = safe_string(row.values.get(header))
if value.isdigit():
number = int(value)
if number > 0:
numbers.add(number)
candidate = 1
while candidate in numbers:
candidate += 1
return candidate
def validate_create_data(
data: dict[str, Any],
schema: SheetSchema,
rows: list[SheetRow],
) -> str:
for header, options in schema.field_options.items():
value = safe_string(data.get(header))
if value and options and value not in options:
options_text = ", ".join(options[:8])
return f"nilai `{header}` gak valid, pren. pilih salah satu: {options_text}"
duplicate_error = detect_duplicate_row(data, rows)
if duplicate_error:
return duplicate_error
return ""
def detect_duplicate_row(data: dict[str, Any], rows: list[SheetRow]) -> str:
populated = {key: safe_string(value) for key, value in data.items() if safe_string(value)}
if not populated:
return ""
exact_duplicate = next(
(
row
for row in rows
if all(safe_string(row.values.get(key)).lower() == value.lower() for key, value in populated.items())
),
None,
)
if exact_duplicate:
return f"data yang sama sudah ada di row {exact_duplicate.row_number}, pren. cek lagi biar gak dobel."
identity_headers = {"no", "id", "kode", "sku", "invoice", "wa", "nomorhp", "phone"}
for key, value in populated.items():
if normalize_key(key) not in identity_headers:
continue
duplicate = next((row for row in rows if safe_string(row.values.get(key)).lower() == value.lower()), None)
if duplicate:
return f"nilai `{key}` sudah kepakai di row {duplicate.row_number}, pren. cek lagi biar gak dobel."
return ""
def normalize_dropdown_values(data: dict[str, Any], schema: SheetSchema) -> dict[str, Any]:
result = dict(data)
for header, options in schema.field_options.items():
value = safe_string(result.get(header))
if not value:
continue
normalized_options = {safe_string(option).lower(): option for option in options}
matched = normalized_options.get(value.lower())
if matched:
result[header] = matched
return result