Bot & Automation
jorgasisten
/root/hermes-projects/jorgasisten
apps/jorgasisten/services/assistant.py
text
import asyncio
import logging
import re
from dataclasses import dataclass
from typing import Any
from jorgasisten.core.config import Settings, get_settings
from jorgasisten.schemas import AssistantDecision, OperationResult, SheetRow, SheetSchema
from jorgasisten.services.ai_client import OpenAICompatibleClient
from jorgasisten.services.conversation_context import (
DataConversationContext,
apply_conversation_context,
detect_requested_field_aliases,
)
from jorgasisten.services.formatters import build_grounded_data_answer, format_operation_error
from jorgasisten.services.operations import OperationExecutor
from jorgasisten.services.sheet_router import (
build_read_filters,
enrich_read_decision,
fallback_decision,
find_header_by_alias_priority,
is_likely_data_question,
score_schema,
should_prefer_sheet_read,
)
from jorgasisten.services.sheets import GoogleSheetsClient, resolve_header, score_row_against_filters
from jorgasisten.services.text import compact_search_token_sequence, safe_string, tokenize
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class ResolvedReadRows:
sheet_name: str
rows: list[SheetRow]
filters: dict[str, str]
supports_requested_field: bool
class AssistantService:
MAX_CROSS_SHEET_MATCHES = 500
IDENTITY_HEADER_ALIASES = [
"kode barang",
"kode produk",
"kode",
"sku",
"serial unit",
"serial",
"invoice",
"nama barang",
"nama produk",
"produk",
"barang",
"item",
"nama",
"customer",
"pelanggan",
]
def __init__(
self,
sheets: GoogleSheetsClient | None = None,
ai_client: OpenAICompatibleClient | None = None,
settings: Settings | None = None,
) -> None:
self.settings = settings or get_settings()
self.sheets = sheets or GoogleSheetsClient(self.settings)
self.ai_client = ai_client or OpenAICompatibleClient(self.settings)
self.executor = OperationExecutor(self.sheets, self.settings)
async def process_message(
self,
message: str,
actor_role: str,
actor_label: str = "",
conversation_context: DataConversationContext | None = None,
) -> OperationResult:
if not self.sheets.is_configured():
decision = fallback_decision(message, [])
return OperationResult(
ok=False,
user_message=(
"google sheets belum aktif, pren. isi GOOGLE_SHEETS_SPREADSHEET_ID di .env "
"atau tambah spreadsheet lewat menu Spreadsheet. credential service account tetap wajib ada yaa."
),
decision=decision,
)
try:
logger.info("Process message: '%s' from actor_role='%s' (label='%s')", message, actor_role, actor_label)
# A data answer must start from a new Google Sheets snapshot. This deliberately
# invalidates the short-lived performance cache before each user request.
refresh = getattr(self.sheets, "refresh_runtime_caches", None)
if callable(refresh):
await asyncio.to_thread(refresh)
catalog = await asyncio.to_thread(self.sheets.get_catalog)
local_decision = fallback_decision(message, catalog)
is_data_request = local_decision.operation == "summarize" or is_likely_data_question(message, catalog)
if not is_data_request and self._is_potential_search_query(message):
search_result = await asyncio.to_thread(self._try_search_all_sheets, message, catalog)
if search_result:
sheet_name, filters = search_result
local_decision = AssistantDecision(
intent="tanya_data",
sheet_name=sheet_name,
operation="read",
confidence=85,
filters=filters,
reason="Pencarian lintas-sheet menemukan data yang sesuai.",
)
is_data_request = True
# Read-only data questions never depend on the model decision. The model may
# still assist with controlled write parsing, but it cannot invent read answers.
if is_data_request:
decision = local_decision
if decision.operation == "summarize":
decision = decision.model_copy(update={"operation": "read", "intent": "tanya_data"})
logger.info("Using deterministic Google Sheets read decision: %s", decision)
else:
if local_decision.operation == "read":
return OperationResult(
ok=False,
user_message="Informasi tersebut belum tersedia di Google Sheets. Silakan hubungi admin.",
decision=local_decision,
)
ai_decision = await self.ai_client.create_decision(message, catalog)
logger.info("AI Decision: %s", ai_decision)
decision = ai_decision or local_decision
# Convert a free-form lookup into a read only after a real Sheets match exists.
if decision.operation == "chat" and self._is_potential_search_query(message):
search_results = await asyncio.to_thread(
self._try_search_all_sheets,
message,
catalog
)
if not search_results:
refreshed_catalog = await asyncio.to_thread(self._refresh_catalog)
if refreshed_catalog is not None:
catalog = refreshed_catalog
search_results = await asyncio.to_thread(
self._try_search_all_sheets,
message,
catalog,
)
if search_results:
sheet_name, filters = search_results
decision = AssistantDecision(
intent="tanya_data",
sheet_name=sheet_name,
operation="read",
confidence=85,
filters=filters,
reason="Diubah dari chat ke read karena kata kunci cocok dengan data di Google Sheets.",
)
logger.info(
"Converted general 'chat' query to contextual 'read' on sheet '%s': %s",
sheet_name,
decision,
)
decision = enrich_read_decision(message, decision, catalog)
spreadsheet_id = safe_string(getattr(self.sheets, "spreadsheet_id", ""))
decision = apply_conversation_context(
message,
decision,
catalog,
conversation_context,
spreadsheet_id=spreadsheet_id,
)
if decision.operation == "ask_clarification" and should_prefer_sheet_read(message, catalog):
refreshed_decision = await asyncio.to_thread(
self._refresh_read_decision,
message,
conversation_context,
spreadsheet_id,
)
if refreshed_decision is not None:
decision, catalog = refreshed_decision
logger.info("Final Decision to execute: %s", decision)
if decision.operation == "chat":
return OperationResult(
ok=False,
user_message="Informasi tersebut belum tersedia di Google Sheets. Silakan hubungi admin.",
decision=decision,
)
result = await asyncio.to_thread(
self.executor.execute,
decision,
actor_role,
actor_label=actor_label,
)
logger.info(
"Execution Result: ok=%s, rows_count=%s, msg='%s'",
result.ok,
len(result.rows) if result.rows else 0,
result.user_message,
)
return await self._finalize_result(message, decision, result, catalog)
except Exception as exc:
logger.exception("Assistant processing failed: %s", exc)
decision = fallback_decision(message, [])
return OperationResult(ok=False, user_message=format_operation_error(exc), decision=decision)
async def confirm_decision(
self,
decision: AssistantDecision,
actor_role: str,
prepared_create_data: dict[str, str] | None = None,
actor_label: str = "",
) -> OperationResult:
try:
return await asyncio.to_thread(
self.executor.execute,
decision,
actor_role,
confirmed=True,
prepared_create_data=prepared_create_data,
actor_label=actor_label,
)
except Exception as exc:
logger.exception("Confirmed operation failed: %s", exc)
fallback = fallback_decision("", [])
return OperationResult(ok=False, user_message=format_operation_error(exc), decision=fallback)
async def process_decision(
self,
decision: AssistantDecision,
actor_role: str,
actor_label: str = "",
) -> OperationResult:
try:
return await asyncio.to_thread(
self.executor.execute,
decision,
actor_role,
actor_label=actor_label,
)
except Exception as exc:
logger.exception("Structured decision processing failed: %s", exc)
fallback = fallback_decision("", [])
return OperationResult(ok=False, user_message=format_operation_error(exc), decision=fallback)
async def _finalize_result(
self,
user_message: str,
decision: AssistantDecision,
result: OperationResult,
catalog: list[SheetSchema],
) -> OperationResult:
if not result.ok or result.requires_confirmation:
return result
if decision.operation == "read":
resolved = await asyncio.to_thread(
self._resolve_read_rows,
user_message,
decision,
result.rows,
catalog,
)
if resolved is not None:
result.rows = resolved.rows
result.decision = decision.model_copy(
update={"sheet_name": resolved.sheet_name, "filters": resolved.filters}
)
if not resolved.supports_requested_field:
result.user_message = self._build_missing_requested_field_message(user_message)
return result
if not result.rows:
refreshed = await asyncio.to_thread(self._refresh_read_result, user_message, decision)
if refreshed is not None:
result.rows = refreshed.rows
result.decision = decision.model_copy(
update={"sheet_name": refreshed.sheet_name, "filters": refreshed.filters}
)
catalog = await asyncio.to_thread(self.sheets.get_catalog)
if not result.rows:
query_term = ""
if decision.filters:
query_term = decision.filters.get("query") or list(decision.filters.values())[0]
if query_term:
result.user_message = self._build_not_found_message(query_term)
else:
result.user_message = self._build_not_found_message("")
return result
result.user_message = build_grounded_data_answer(
user_message,
result.decision.sheet_name,
result.rows,
max_rows=max(len(result.rows), self.settings.max_result_rows),
)
return result
return result
def _resolve_read_rows(
self,
user_message: str,
decision: AssistantDecision,
rows: list[SheetRow],
catalog: list[SheetSchema],
) -> ResolvedReadRows | None:
requested_aliases = detect_requested_field_aliases(user_message)
candidates: list[tuple[int, ResolvedReadRows]] = []
ranked_schemas = self._rank_read_schemas(user_message, decision, catalog, requested_aliases)
if requested_aliases:
field_schemas = [
schema
for schema in ranked_schemas
if find_header_by_alias_priority(schema.headers, requested_aliases)
]
if field_schemas:
ranked_schemas = field_schemas
for schema in ranked_schemas:
filter_variants = self._filter_variants_for_schema(
user_message,
decision,
schema.headers,
requested_aliases,
)
if self._requires_full_sheet_scan(user_message) and {} not in filter_variants:
# Aggregate/latest questions may contain only sheet/header words and no
# row identity. Keep a full-sheet candidate as a deterministic fallback.
filter_variants.append({})
for filters in filter_variants:
if filters:
candidate_rows = self.sheets.find_rows(
schema.title,
filters,
limit=max(self.settings.max_result_rows, self.MAX_CROSS_SHEET_MATCHES),
)
else:
candidate_rows = self.sheets.read_rows(schema.title)
if not candidate_rows:
continue
supports_requested_field = self._rows_support_requested_field(candidate_rows, requested_aliases)
resolved = ResolvedReadRows(
sheet_name=schema.title,
rows=candidate_rows,
filters=filters,
supports_requested_field=supports_requested_field,
)
best_row_score = max(score_row_against_filters(row, filters) for row in candidate_rows)
score = score_schema(user_message, schema) + min(best_row_score, 240)
score += self._requested_field_specificity(schema.headers, user_message)
if schema.title == decision.sheet_name:
score += 10
if supports_requested_field:
score += 1000
if any(key != "query" for key in filters):
score += 25
if set(filters) == {"query"} and len(filters["query"].split()) <= 2:
score -= 5
if len(candidate_rows) == 1:
score += 10
candidates.append((score, resolved))
if candidates:
candidates.sort(key=lambda item: (item[0], -len(item[1].rows), len(item[1].filters)), reverse=True)
return candidates[0][1]
initial_support = self._rows_support_requested_field(rows, requested_aliases)
if rows:
return ResolvedReadRows(
sheet_name=decision.sheet_name,
rows=rows,
filters={key: safe_string(value) for key, value in decision.filters.items() if safe_string(value)},
supports_requested_field=initial_support,
)
return None
def _refresh_read_result(
self,
user_message: str,
decision: AssistantDecision,
) -> ResolvedReadRows | None:
refresh = getattr(self.sheets, "refresh_runtime_caches", None)
if not callable(refresh):
return None
refresh()
refreshed_catalog = self.sheets.get_catalog()
return self._resolve_read_rows(user_message, decision, [], refreshed_catalog)
def _refresh_read_decision(
self,
user_message: str,
conversation_context: DataConversationContext | None,
spreadsheet_id: str,
) -> tuple[AssistantDecision, list[SheetSchema]] | None:
refresh = getattr(self.sheets, "refresh_runtime_caches", None)
if not callable(refresh):
return None
refresh()
refreshed_catalog = self.sheets.get_catalog()
refreshed_decision = enrich_read_decision(
user_message,
fallback_decision(user_message, refreshed_catalog),
refreshed_catalog,
)
refreshed_decision = apply_conversation_context(
user_message,
refreshed_decision,
refreshed_catalog,
conversation_context,
spreadsheet_id=spreadsheet_id,
)
return refreshed_decision, refreshed_catalog
def _refresh_catalog(self) -> list[SheetSchema] | None:
refresh = getattr(self.sheets, "refresh_runtime_caches", None)
if not callable(refresh):
return None
refresh()
return self.sheets.get_catalog()
def _rank_read_schemas(
self,
user_message: str,
decision: AssistantDecision,
catalog: list[SheetSchema],
requested_aliases: list[str],
) -> list[SheetSchema]:
ranked: list[tuple[int, SheetSchema]] = []
for schema in catalog:
score = score_schema(user_message, schema)
if schema.title == decision.sheet_name:
score += 40
if requested_aliases and find_header_by_alias_priority(schema.headers, requested_aliases):
score += 30
score += self._requested_field_specificity(schema.headers, user_message)
if any(resolve_header(key, schema.headers) for key in decision.filters if key != "query"):
score += 15
ranked.append((score, schema))
ranked.sort(key=lambda item: item[0], reverse=True)
return [schema for _, schema in ranked]
def _requested_field_specificity(self, headers: list[str], user_message: str) -> int:
"""Prefer the exact stock field asked for over related operational columns.
For example, a request for remaining stock must choose ``Stok Akhir``
before ``Kuantiti`` (location allocation) or ``Stok Minimum`` (MOQ).
"""
message_tokens = tokenize(user_message)
if not message_tokens:
return 0
best_score = 0
for header in headers:
header_tokens = tokenize(header)
if not header_tokens:
continue
score = 0
is_stock_header = bool(header_tokens & {"stok", "stock"})
is_quantity_header = bool(header_tokens & {"qty", "jumlah", "kuantiti", "kuantitas"})
if message_tokens & {"sisa", "akhir"}:
if is_stock_header and header_tokens & {"sisa", "akhir"}:
score = 650
elif is_stock_header and header_tokens & {"minimum", "min"}:
score = 40
elif is_stock_header:
score = 180
elif is_quantity_header:
score = 20
elif message_tokens & {"awal"}:
score = 500 if is_stock_header and "awal" in header_tokens else 0
elif message_tokens & {"masuk"}:
score = 500 if "masuk" in header_tokens else 0
elif message_tokens & {"keluar"}:
score = 500 if "keluar" in header_tokens else 0
elif message_tokens & {"stok", "stock"}:
if is_stock_header and not (header_tokens & {"minimum", "min"}):
score = 300
elif is_quantity_header:
score = 50
best_score = max(best_score, score)
return best_score
def _filters_for_schema(
self,
user_message: str,
decision: AssistantDecision,
headers: list[str],
) -> dict[str, str]:
schema_filters = build_read_filters(user_message, SheetSchema(title="", headers=headers))
if schema_filters:
return {key: safe_string(value) for key, value in schema_filters.items() if safe_string(value)}
remapped_filters: dict[str, str] = {}
for key, value in decision.filters.items():
if key == "query":
continue
header = resolve_header(key, headers)
value_text = safe_string(value)
if header and value_text:
remapped_filters[header] = value_text
if remapped_filters:
return remapped_filters
return {key: safe_string(value) for key, value in decision.filters.items() if safe_string(value)}
def _filter_variants_for_schema(
self,
user_message: str,
decision: AssistantDecision,
headers: list[str],
requested_aliases: list[str],
) -> list[dict[str, str]]:
variants: list[dict[str, str]] = []
seen: set[tuple[tuple[str, str], ...]] = set()
def add_variant(filters: dict[str, str]) -> None:
normalized = {key: safe_string(value) for key, value in filters.items() if safe_string(value)}
if not normalized:
return
fingerprint = tuple(sorted(normalized.items()))
if fingerprint in seen:
return
seen.add(fingerprint)
variants.append(normalized)
add_variant(self._filters_for_schema(user_message, decision, headers))
remapped_filters: dict[str, str] = {}
for key, value in decision.filters.items():
if key == "query":
continue
header = resolve_header(key, headers)
value_text = safe_string(value)
if header and value_text:
remapped_filters[header] = value_text
add_variant(remapped_filters)
preferred_header = find_header_by_alias_priority(headers, self.IDENTITY_HEADER_ALIASES)
for query in self._query_variants(user_message, decision.filters, requested_aliases, headers):
add_variant({"query": query})
if preferred_header and len(query.split()) >= 2:
add_variant({preferred_header: query})
return variants
def _query_variants(
self,
user_message: str,
decision_filters: dict[str, Any],
requested_aliases: list[str],
headers: list[str],
) -> list[str]:
variants: list[str] = []
seen: set[str] = set()
def add_query(raw_query: str) -> None:
query = " ".join(token for token in compact_search_token_sequence(raw_query) if token)
if not query or query in seen:
return
seen.add(query)
variants.append(query)
base_query = safe_string(decision_filters.get("query"))
add_query(base_query)
add_query(user_message)
stripped_tokens = self._strip_requested_field_tokens(
compact_search_token_sequence(base_query or user_message),
requested_aliases,
)
stripped_tokens = self._strip_query_directive_tokens(stripped_tokens, headers)
add_query(" ".join(stripped_tokens))
salient_tokens = [token for token in stripped_tokens if len(token) >= 4 or token.isdigit()]
add_query(" ".join(salient_tokens))
if len(salient_tokens) > 5:
add_query(" ".join(salient_tokens[:5]))
if len(stripped_tokens) > 6:
add_query(" ".join(stripped_tokens[:6]))
return variants
def _strip_query_directive_tokens(self, tokens: list[str], headers: list[str]) -> list[str]:
directive_tokens = {
"bandingkan",
"berapa",
"bulan",
"count",
"data",
"ini",
"jumlah",
"jumlahkan",
"terbaru",
"terakhir",
"total",
}
header_tokens = {token for header in headers for token in compact_search_token_sequence(header)}
return [token for token in tokens if token not in directive_tokens and token not in header_tokens]
def _requires_full_sheet_scan(self, message: str) -> bool:
tokens = set(compact_search_token_sequence(message))
return bool(tokens & {"bandingkan", "count", "jumlah", "jumlahkan", "terbaru", "terakhir", "total"})
def _strip_requested_field_tokens(self, tokens: list[str], requested_aliases: list[str]) -> list[str]:
excluded_tokens: set[str] = set()
for alias in requested_aliases:
excluded_tokens.update(compact_search_token_sequence(alias))
return [token for token in tokens if token not in excluded_tokens]
def _rows_support_requested_field(self, rows: list[SheetRow], requested_aliases: list[str]) -> bool:
if not rows or not requested_aliases:
return bool(rows)
return find_header_by_alias_priority(list(rows[0].values.keys()), requested_aliases) is not None
def _build_missing_requested_field_message(self, user_message: str) -> str:
requested_aliases = detect_requested_field_aliases(user_message)
field_label = requested_aliases[0] if requested_aliases else "field itu"
return (
f"datanya ada, tapi kolom {field_label} belum tersedia di sheet yang paling relevan, pren. "
"coba cek struktur kolomnya atau tanyain field lain yang memang ada."
)
def _build_not_found_message(self, query_term: str) -> str:
return (
"Data yang Anda tanyakan belum ditemukan di Google Sheets. "
"Silakan periksa kembali nama, kode, atau informasi yang digunakan."
)
def _rows_for_answer(self, rows: list[SheetRow], limit: int = 12) -> list[dict[str, str]]:
compact_rows: list[dict[str, str]] = []
for row in rows[:limit]:
compact_rows.append(
{
key: safe_string(value)
for key, value in row.values.items()
if safe_string(value)
}
)
return compact_rows
def _is_potential_search_query(self, message: str) -> bool:
cleaned = message.strip().lower()
if cleaned.startswith("/"):
return False
if cleaned.startswith(("jelaskan ", "kenapa ", "mengapa ", "bagaimana cara ", "bikinin ", "buatkan ")):
return False
from jorgasisten.services.sheet_router import GREETING_KEYWORDS
if cleaned in GREETING_KEYWORDS:
return False
words = [w for w in re.findall(r"[a-z0-9]+", cleaned) if w]
if not words:
return False
if len(words) > 40:
return False
return True
def _try_search_all_sheets(self, message: str, catalog: list[SheetSchema]) -> tuple[str, dict[str, Any]] | None:
try:
decision = fallback_decision(message, catalog)
resolved = self._resolve_read_rows(message, decision, [], catalog)
if resolved is None:
return None
logger.info("Found matching data in sheet '%s' for query '%s'", resolved.sheet_name, message)
return resolved.sheet_name, resolved.filters
except Exception as exc:
logger.debug("Failed global read search for '%s': %s", message, exc)
return None