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))