#!/bin/sh
#
# decide - Typed decisions on text with a small local model.
#
# A decision model reads a text and answers closed questions (a choice
# or yes/no) with probabilities, in one forward pass and without
# generating tokens. decide drives laya (package ggmlc) with the Kev
# models and ships ready-made questions for SliTaz: cook failures and
# shell command risk.
#
# Copyright (C) 2026 SliTaz GNU/Linux - BSD License
#

VERSION="0.3"

# Question files. Overridable to run from a source checkout:
# DECIDE_SHARE=share ./decide cooklog foo
DECIDE_SHARE="${DECIDE_SHARE:-/usr/share/decide}"

# Models: a user layer, then a system layer shared by every account
# (filled by root with "decide pull --system-wide"). The user layer wins.
DECIDE_DIR="${DECIDE_DIR:-$HOME/.decide/models}"
DECIDE_SYSTEM_DIR="${DECIDE_SYSTEM_DIR:-/var/lib/decide/models}"

# Cook log directories (LOG_DIRS) come from this file.
DECIDE_CONF="${DECIDE_CONF:-/etc/slitaz/decide.conf}"
LOG_DIRS=""
[ -f "$DECIDE_CONF" ] && . "$DECIDE_CONF"
LOG_DIRS="${DECIDE_LOG_DIRS:-$LOG_DIRS}"

DECIDE_MODEL="${DECIDE_MODEL:-kev-0.8b}"
LAYA="${LAYA:-laya}"

# Kev ranks well but its probabilities are squeezed toward the middle:
# 0.3 is the measured split between safe and risky commands.
SHELL_THRESHOLD="${SHELL_THRESHOLD:-0.3}"

# Below this probability a cooklog answer is marked unsure ("cause?").
# Measured on 224 labelled logs: 94-99% right above 0.5, much less below.
COOKLOG_THRESHOLD="${COOKLOG_THRESHOLD:-0.5}"

# Longest text sent to the model, in bytes. Kev reads 2048 tokens.
MAX_TEXT=6000

# Model catalog: name file sha256 url.
catalog() {
	cat << EOT
kev-0.8b kev_0.8b_q8_0.gguf 12f77506dbfaa3c1d12f7e3f8da04feceda276f2d5aa322cd0858a8ce11e6c6c https://huggingface.co/mys/kev-0.8b-GGUF/resolve/main/kev_0.8b_q8_0.gguf
kev-4b kev_4b_q8_0.gguf 51c9353a5ff0b8e2f7cd7262359c0b26ea54b327214fe26a5da52b505b2978e8 https://huggingface.co/mys/kev-4b-GGUF/resolve/main/kev_4b_q8_0.gguf
laya-english laya_english_q8_0.gguf 6243305fb16cf22e53b932c220bd029be2adbfbec6ce120356517b8ae2f38382 https://huggingface.co/mys/laya-GGUF/resolve/main/laya_english_q8_0.gguf
laya-multilingual laya_multilingual_q8_0.gguf 757c1a4b1f0f41824113dde76d6cd06b0881d37b796103a338de77f9f9b935c3 https://huggingface.co/mys/laya-multilingual-GGUF/resolve/main/laya_multilingual_q8_0.gguf
EOT
}

usage() {
	cat << EOT
Usage: decide [options] <command> [args]

Typed decisions on text with a small local model.

Commands:
  cooklog <pkg|file>        Probable cause of a failed cook (log found by name),
                            "cause?" when unsure
  shell <command>           Is a shell command risky? (exit 1 if so)
  yesno <question>          Answer a yes/no question about stdin (exit 1 if no)
  ask <question> <choice>.. Pick one choice for stdin (choice or name=description)
  pull [--system-wide] [model]
                            Download a model (default: $DECIDE_MODEL), in
                            ~/.decide/models or, as root, $DECIDE_SYSTEM_DIR
  models                    List models and where they are installed

Options:
  --arch=ARCH               Cook logs of i486 or x86_64 (default: search both)
  --model=NAME              Model to use (default: $DECIDE_MODEL)
  --threads=N               CPU threads (default: half the CPUs, max 6)
  --quiet                   Print only the answer
  --version                 Show version and exit
  --help                    Show this help and exit

Examples:
  decide cooklog mesa
  decide --arch=i486 cooklog mesa
  cd /home/slitaz/log && decide cooklog mesa.log
  decide shell 'hg push' || echo "ask first"
  dmesg | tail -50 | decide yesno 'Is there a disk error?'
  decide ask 'What is the tone?' calm angry < mail.txt
EOT
}

die() {
	echo "decide: $*" >&2
	exit 2
}

# Half the CPUs, capped at 6: hybrid CPUs lose speed on E-cores
# (measured on an i7-13700H: 6 threads 2.0 s, 14 threads 2.8 s).
default_threads() {
	n=$(grep -c ^processor /proc/cpuinfo)
	n=$((n / 2))
	[ "$n" -lt 1 ] && n=1
	[ "$n" -gt 6 ] && n=6
	echo $n
}

model_line() {
	catalog | awk -v m="$1" '$1 == m'
}

# Installed path of a model, user layer first; empty if not installed.
model_path() {
	file=$(model_line "$1" | cut -d' ' -f2)
	[ -n "$file" ] || return 0
	for dir in "$DECIDE_DIR" "$DECIDE_SYSTEM_DIR"; do
		if [ -f "$dir/$file" ]; then
			echo "$dir/$file"
			return 0
		fi
	done
}

cmd_models() {
	catalog | while read name file sum url; do
		if [ -f "$DECIDE_DIR/$file" ]; then
			state="user"
		elif [ -f "$DECIDE_SYSTEM_DIR/$file" ]; then
			state="system"
		else
			state="-"
		fi
		printf '%-20s %s\n' "$name" "$state"
	done
	echo "user: $DECIDE_DIR  system: $DECIDE_SYSTEM_DIR"
}

cmd_pull() {
	dir="$DECIDE_DIR"
	name=""
	for arg in "$@"; do
		case "$arg" in
			--system-wide) dir="$DECIDE_SYSTEM_DIR" ;;
			*) name="$arg" ;;
		esac
	done
	name="${name:-$DECIDE_MODEL}"
	line=$(model_line "$name")
	[ -n "$line" ] || die "unknown model: $name (see: decide models)"
	set -- $line
	file="$2" sum="$3" url="$4"
	if [ -f "$dir/$file" ]; then
		echo "$name is already installed in $dir"
		return 0
	fi
	# Checked before a download of hundreds of megabytes.
	mkdir -p "$dir" 2> /dev/null && [ -w "$dir" ] ||
		die "cannot write to $dir (for --system-wide, run it as root)"
	echo "Downloading $name..."
	wget -c -O "$dir/$file.part" "$url" || die "download failed"
	echo "$sum  $dir/$file.part" | sha256sum -c -s ||
		die "sha256 mismatch, $dir/$file.part kept for checking"
	mv "$dir/$file.part" "$dir/$file"
	echo "$name installed in $dir"
}

# Escape a string for a JSON literal: backslash, quote, tab, newline.
json_str() {
	printf '%s\n' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' |
		awk '{ printf "%s%s", (NR > 1 ? "\\n" : ""), $0 }'
}

# Checks before a decision, out of any subshell so die() stops decide.
check_ready() {
	command -v "$LAYA" > /dev/null || die "laya not found (install ggmlc)"
	grep -q avx2 /proc/cpuinfo || die "this CPU has no AVX2, laya needs it"
	model_line "$DECIDE_MODEL" | grep -q . ||
		die "unknown model: $DECIDE_MODEL (see: decide models)"
	model=$(model_path "$DECIDE_MODEL")
	[ -n "$model" ] || die "model $DECIDE_MODEL not installed (run: decide pull)"
}

# Run laya with the questions file $3 on a one-field state {"$1": $2}
# and print its report. The field name gives the model context: the
# questions were measured on {"command": ...} and {"log": ...}.
run_laya() {
	text=$(printf '%s' "$2" | tail -c $MAX_TEXT)
	[ -n "$text" ] || die "empty text"
	"$LAYA" decide "$model" --questions-file "$3" \
		--state "{\"$1\": \"$(json_str "$text")\"}" \
		--device cpu --threads "$THREADS" 2> /dev/null ||
		die "laya failed on $model"
}

# From a laya report, print "answer probability" for a choice question.
choice_answer() {
	awk '
		/^  choice: / { c = $2; next }
		c != "" && $NF == c && $2 ~ /^[0-9.]+$/ { print c, $2; exit }'
}

# From a laya report, print P(true) for a yes/no question.
noul_answer() {
	awk '/noul \(P\(true\)\):/ { print $NF; exit }'
}

# Print answer and probability, or the answer alone with --quiet.
say() {
	if [ -n "$QUIET" ]; then
		echo "$1"
	else
		echo "$1 ($2)"
	fi
}

# Strip the color codes cook writes in its logs (busybox sed has no \x).
nocolor() {
	sed "s/$(printf '\033')\[[0-9;]*m//g"
}

# SliTaz arch name of this host.
host_arch() {
	case "$(uname -m)" in
		x86_64) echo x86_64 ;;
		*) echo i486 ;;
	esac
}

# Cook log directories for arch $1, one per line, in search order: the
# native cook (host arch only), the tazlab build tree, then LOG_DIRS
# from decide.conf. %ARCH% is replaced by the arch.
log_dirs() {
	{
		if [ "$1" = "$(host_arch)" ] && [ -f /etc/slitaz/cook.conf ]; then
			(. /etc/slitaz/cook.conf; echo "$LOGS")
		fi
		if [ -f /etc/slitaz/tazlab.conf ]; then
			(. /etc/slitaz/tazlab.conf; echo "$SLITAZ_HOME/%ARCH%/log")
		fi
		for dir in $LOG_DIRS; do
			echo "$dir"
		done
	} | sed "s|%ARCH%|$1|g"
}

# Print the cook log for $1: a file, <pkg>.log in the current directory,
# or <pkg>.log in the log directories of one arch (--arch) or both.
find_log() {
	if [ -f "$1" ]; then
		echo "$1"
		return 0
	fi
	pkg="${1%.log}"
	if [ -f "$pkg.log" ]; then
		echo "$pkg.log"
		return 0
	fi
	if [ -n "$ARCH" ]; then
		archs="$ARCH"
	elif [ "$(host_arch)" = "x86_64" ]; then
		archs="x86_64 i486"
	else
		archs="i486 x86_64"
	fi
	found=""
	searched=""
	for arch in $archs; do
		for dir in $(log_dirs $arch); do
			searched="$searched $dir"
			if [ -f "$dir/$pkg.log" ]; then
				found="$found $dir/$pkg.log"
				break
			fi
		done
	done
	set -- $found
	[ $# -gt 0 ] || die "no cook log for $pkg in:$searched"
	[ $# -eq 1 ] || die "$pkg has logs for both arches, pick one with --arch:$found"
	echo "$1"
}

cmd_cooklog() {
	[ -n "$1" ] || die "usage: decide cooklog <pkg|file>"
	[ -f "$DECIDE_SHARE/cooklog.json" ] || die "missing $DECIDE_SHARE/cooklog.json"
	log=$(find_log "$1") || exit 2
	echo "log: $log" >&2
	# A dependency that is not a package of the wok: cook names it, no
	# model needed (60% of the failed logs of the x86_64 port).
	deps=$(nocolor < "$log" | sed -n 's/^ERROR: unknown dep "\(.*\)"$/\1/p' |
		sort -u | tr '\n' ' ')
	if [ -n "$deps" ]; then
		if [ -n "$QUIET" ]; then
			echo "unknown_dep"
		else
			echo "unknown_dep (${deps% })"
		fi
		return 0
	fi
	check_ready
	# The Debug information block at the end of the log holds the
	# error lines picked by cook; fall back to the last lines.
	text=$(nocolor < "$log" |
		sed -n '/^Debug information/,/^Post-check/p' |
		grep -v -e '^=*$' -e '^Post-check' -e '^Cook date' -e '^Wasted time')
	if [ -z "$text" ]; then
		echo "decide: no Debug information block (did this cook fail?), using the last lines" >&2
		text=$(nocolor < "$log" | tail -40)
	fi
	set -- $(run_laya log "$text" "$DECIDE_SHARE/cooklog.json" | choice_answer)
	[ -n "$1" ] || die "no answer from the model"
	cause="$1"
	awk -v p="$2" -v t="$COOKLOG_THRESHOLD" 'BEGIN { exit !(p < t) }' &&
		cause="$cause?"
	say "$cause" "$2"
}

cmd_shell() {
	[ -n "$*" ] || die "usage: decide shell <command>"
	[ -f "$DECIDE_SHARE/shell.json" ] || die "missing $DECIDE_SHARE/shell.json"
	p=$(run_laya command "$*" "$DECIDE_SHARE/shell.json" | noul_answer)
	[ -n "$p" ] || die "no answer from the model"
	if awk -v p="$p" -v t="$SHELL_THRESHOLD" 'BEGIN { exit !(p >= t) }'; then
		say risky "$p"
		return 1
	fi
	say safe "$p"
}

# Write a one-question file to $QFILE.
questions() {
	printf '%s\n' "$1" > "$QFILE"
}

cmd_yesno() {
	[ -n "$1" ] || die "usage: decide yesno <question> < text"
	questions "{\"q\": {\"type\": \"noul\", \"instructions\": \"$(json_str "$1")\"}}"
	p=$(run_laya text "$(cat)" "$QFILE" | noul_answer)
	[ -n "$p" ] || die "no answer from the model"
	if awk -v p="$p" 'BEGIN { exit !(p >= 0.5) }'; then
		say yes "$p"
	else
		say no "$p"
		return 1
	fi
}

cmd_ask() {
	[ $# -ge 3 ] || die "usage: decide ask <question> <choice> <choice>... < text"
	q="$1"
	shift
	crit=""
	for c in "$@"; do
		name="${c%%=*}"
		case "$c" in
			*=*) desc="${c#*=}" ;;
			*) desc="$name" ;;
		esac
		crit="$crit${crit:+, }\"$(json_str "$name")\": \"$(json_str "$desc")\""
	done
	questions "{\"q\": {\"type\": \"choice\", \"instructions\": \"$(json_str "$q")\", \"criteria\": {$crit}}}"
	set -- $(run_laya text "$(cat)" "$QFILE" | choice_answer)
	[ -n "$1" ] || die "no answer from the model"
	say "$1" "$2"
}

#
# Commands
#

# Options come before the command: what follows it is left as is, so
# "decide shell git reset --hard" keeps its --hard.
ARCH=""
THREADS=""
QUIET=""
while [ $# -gt 0 ]; do
	case "$1" in
		--arch=i486|--arch=x86_64) ARCH="${1#--arch=}" ;;
		--arch=*) die "unknown arch: ${1#--arch=} (i486 or x86_64)" ;;
		--model=*) DECIDE_MODEL="${1#--model=}" ;;
		--threads=*) THREADS="${1#--threads=}" ;;
		--quiet|-q) QUIET="yes" ;;
		--version) echo "decide $VERSION"; exit 0 ;;
		--help|-h) usage; exit 0 ;;
		-*) die "unknown option: $1" ;;
		*) break ;;
	esac
	shift
done
[ -n "$THREADS" ] || THREADS=$(default_threads)
cmd="$1"
[ $# -gt 0 ] && shift

QFILE=$(mktemp -t decide.XXXXXX) || exit 2
trap 'rm -f "$QFILE"' EXIT

case "$cmd" in
	cooklog) cmd_cooklog "$@" ;;
	shell) check_ready; cmd_shell "$@" ;;
	yesno) check_ready; cmd_yesno "$@" ;;
	ask) check_ready; cmd_ask "$@" ;;
	pull) cmd_pull "$@" ;;
	models) cmd_models ;;
	""|help) usage ;;
	*) die "unknown command: $cmd (see: decide help)" ;;
esac
