Bot & Automation
jorgasisten
/root/hermes-projects/jorgasisten
apps/jorgasisten/services/conversation_context.py
text
import re
from dataclasses import dataclass, field
from jorgasisten.schemas import AssistantDecision, SheetRow, SheetSchema
from jorgasisten.services.formatters import select_identity_headers
from jorgasisten.services.sheet_router import (
find_header_by_alias_priority,
score_schema,
select_best_schema,
)
from jorgasisten.services.sheets import resolve_header
from jorgasisten.services.text import safe_string, tokenize
FOLLOW_UP_FIELD_ALIASES: list[tuple[set[str], list[str]]] = [
(
{"stok", "stock", "qty", "jumlah", "kuantiti", "kuantitas", "sisa"},
["stok", "stock", "qty", "jumlah", "kuantiti", "kuantitas", "sisa"],
),
(
{"harga", "price", "biaya", "nominal", "tarif"},
["harga", "price", "biaya", "nominal", "tarif"],
),
(
{"status", "state", "kondisi"},
["status", "state", "kondisi"],
),
(
{"asal", "daerah", "kota", "origin"},
["asal", "daerah", "kota", "origin"],
),
(
{"area", "berada", "dimana", "gudang", "lemari", "letak", "lokasi", "mana", "posisi", "rak", "tempat"},
["area", "lokasi", "posisi", "rak", "lemari", "letak", "tempat", "gudang"],
),
(
{"tanggal", "date", "periode", "kapan"},
["tanggal", "date", "periode"],
),
(
{"supplier", "suppliernya", "vendor", "pemasok"},
["supplier", "vendor", "pemasok"],
),
]
IDENTITY_ALIAS_GROUPS: list[tuple[set[str], list[str]]] = [
(
{"kode", "sku", "id", "invoice", "faktur", "nota"},
["kode", "sku", "id", "invoice", "faktur", "nota"],
),
(
{"nama", "produk", "barang", "item", "customer", "pelanggan", "motor"},
["nama barang", "nama produk", "produk", "barang", "item", "nama", "customer", "pelanggan", "motor"],
),
(
{"wa", "whatsapp", "phone", "hp", "telepon"},
["wa", "whatsapp", "phone", "hp", "telepon"],
),
]
WEAK_QUERY_TOKENS = {
"ada",
"apakah",
"area",
"barang",
"berada",
"data",
"dimana",
"harga",
"harganya",
"ini",
"itu",
"jumlah",
"kapan",
"kondisi",
"kuantiti",
"kuantitas",
"lemari",
"letak",
"lokasi",
"mana",
"nominal",
"posisi",
"produk",
"qty",
"rak",
"sisa",
"status",
"statusnya",
"stok",
"stoknya",
"tanggal",
"tempat",
"tersebut",
}
TOKEN_RE = re.compile(r"[a-z0-9]+", re.IGNORECASE)
@dataclass(slots=True)
class DataConversationContext:
spreadsheet_id: str
sheet_name: str
identity_values: dict[str, str] = field(default_factory=dict)
def build_conversation_context(
spreadsheet_id: str,
decision: AssistantDecision,
rows: list[SheetRow],
) -> DataConversationContext | None:
if not spreadsheet_id or not rows:
return None
identity_values: dict[str, str] = {}
first_row_headers = list(rows[0].values.keys())
for header in select_identity_headers(first_row_headers):
common_value = common_row_value(rows, header)
if common_value:
identity_values[header] = common_value
for key, value in decision.filters.items():
value_text = safe_string(value)
if key == "query" or not value_text or is_weak_query_value(value_text):
continue
identity_values.setdefault(key, value_text)
if not identity_values:
return None
return DataConversationContext(
spreadsheet_id=spreadsheet_id,
sheet_name=decision.sheet_name,
identity_values=identity_values,
)
def apply_conversation_context(
message: str,
decision: AssistantDecision,
catalog: list[SheetSchema],
context: DataConversationContext | None,
*,
spreadsheet_id: str,
) -> AssistantDecision:
if context is None or context.spreadsheet_id != spreadsheet_id or not context.identity_values:
return decision
if decision.operation not in {"read", "ask_clarification"}:
return decision
if not should_use_conversation_context(message, decision):
return decision
schema = pick_contextual_schema(message, decision, catalog, context)
if schema is None:
return decision
filters = build_context_filters_for_schema(schema, context.identity_values)
if not filters:
return decision
return decision.model_copy(
update={
"intent": "tanya_data",
"operation": "read",
"sheet_name": schema.title,
"filters": filters,
"confidence": max(decision.confidence, 82),
}
)
def should_use_conversation_context(message: str, decision: AssistantDecision) -> bool:
if not detect_requested_field_aliases(message):
return False
if has_strong_identity_filters(decision.filters):
return False
if not decision.filters:
return True
if set(decision.filters) == {"query"}:
return is_weak_query_value(safe_string(decision.filters.get("query")))
return all(is_weak_query_value(safe_string(value)) for value in decision.filters.values())
def has_strong_identity_filters(filters: dict[str, object]) -> bool:
for key, value in filters.items():
value_text = safe_string(value)
if not value_text:
continue
if key == "query":
if not is_weak_query_value(value_text):
return True
continue
if not is_weak_query_value(value_text):
return True
return False
def is_weak_query_value(value: str) -> bool:
tokens = expanded_tokens(value)
return bool(tokens) and tokens.issubset(WEAK_QUERY_TOKENS)
def pick_contextual_schema(
message: str,
decision: AssistantDecision,
catalog: list[SheetSchema],
context: DataConversationContext,
) -> SheetSchema | None:
requested_aliases = detect_requested_field_aliases(message)
if not catalog:
return None
best_schema: SheetSchema | None = None
best_score = -1
for schema in catalog:
identity_filters = build_context_filters_for_schema(schema, context.identity_values)
if not identity_filters:
continue
score = score_schema(message, schema)
if schema.title == decision.sheet_name:
score += 10
if schema.title == context.sheet_name:
score += 8
if requested_aliases and find_header_by_alias_priority(schema.headers, requested_aliases):
score += 60
score += 40
if score > best_score:
best_score = score
best_schema = schema
if best_schema is not None:
return best_schema
fallback_schema, _ = select_best_schema(message, catalog)
return fallback_schema
def build_context_filters_for_schema(schema: SheetSchema, identity_values: dict[str, str]) -> dict[str, str]:
for header, value in prioritized_identity_items(identity_values):
mapped_header = resolve_header(header, schema.headers) or map_identity_header(schema.headers, header)
if mapped_header and value:
return {mapped_header: value}
return {}
def map_identity_header(headers: list[str], source_header: str) -> str | None:
source_tokens = expanded_tokens(source_header)
for aliases_tokens, aliases in IDENTITY_ALIAS_GROUPS:
if source_tokens & aliases_tokens:
matched = find_header_by_alias_priority(headers, aliases)
if matched:
return matched
return None
def detect_requested_field_aliases(message: str) -> list[str]:
message_tokens = expanded_tokens(message)
aliases: list[str] = []
for trigger_tokens, target_aliases in FOLLOW_UP_FIELD_ALIASES:
if message_tokens & trigger_tokens:
aliases.extend(target_aliases)
return aliases
def expanded_tokens(value: str) -> set[str]:
tokens = {token.lower() for token in TOKEN_RE.findall(value)}
expanded = set(tokens)
for token in list(tokens):
if token.endswith("nya") and len(token) > 3:
expanded.add(token[:-3])
expanded |= tokenize(value)
return {token for token in expanded if token}
def prioritized_identity_items(identity_values: dict[str, str]) -> list[tuple[str, str]]:
def priority(item: tuple[str, str]) -> tuple[int, str]:
header = item[0].lower()
if any(token in header for token in ("kode", "sku", "id", "invoice")):
return (0, header)
if any(token in header for token in ("nama", "produk", "barang", "item")):
return (1, header)
return (2, header)
return sorted(identity_values.items(), key=priority)
def common_row_value(rows: list[SheetRow], header: str) -> str:
values = {
safe_string(row.values.get(header))
for row in rows
if safe_string(row.values.get(header))
}
if len(values) != 1:
return ""
return next(iter(values))