Add opt-in local embedding adapter
ober
8fc971fb33ffff916af865cfd54cf73bd3b9a5ae
--- a/GAPS.md +++ b/GAPS.md @@ -110,6 +110,12 @@ Acceptance criteria: `sniff` can use `all-MiniLM-L6-v2` embeddings. This project has no embedding provider interface. +Status: implemented as an explicit `--embeddings` local-provider adapter using +`local_embedding_provider_command` or `JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER`. +The adapter requires an absolute command outside the scanned repository, +returns secondary `local-embedding-similarity` evidence, and records +`network_used:false`. + Acceptance criteria: - Define a local embedding-provider contract equivalent in safety to the local --- a/main-binary.ss +++ b/main-binary.ss @@ -39,7 +39,7 @@ xs))) (def (usage) - (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--first-parent|--all-parents] [--min-lines N] [--threshold HUMAN,AI] [--no-llm|--llm] [--provider NAME] [--max-files N] [--max-added-lines N] [--max-note-bytes N] [--format table|json|jsonl|markdown]") + (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--first-parent|--all-parents] [--min-lines N] [--threshold HUMAN,AI] [--no-llm|--llm] [--provider NAME] [--embeddings|--no-embeddings] [--max-files N] [--max-added-lines N] [--max-note-bytes N] [--format table|json|jsonl|markdown]") (displayln " jerboa main-binary.ss explain REV [PATH] [--format json|markdown|table]") (displayln " jerboa main-binary.ss stats [PATH] [--count N]") (displayln " jerboa main-binary.ss verify-authorship [PATH] [--count N]")) @@ -715,9 +715,12 @@ (def current-analysis-provider "offline-heuristics") (def current-requested-provider "none") (def current-llm-used? #f) +(def current-embeddings-used? #f) (def current-network-used? #f) (def current-llm-requested? #f) +(def current-embeddings-requested? #f) (def current-local-provider-command '()) +(def current-local-embedding-provider-command '()) (def local-provider-input-line-limit 80) (def (set-provider! name) @@ -725,17 +728,25 @@ (def (request-llm!) (set! current-llm-requested? #t)) +(def (request-embeddings!) + (set! current-embeddings-requested? #t)) (def (disable-llm!) (set! current-llm-requested? #f) (set! current-llm-used? #f) (set! current-network-used? #f) (set! current-analysis-provider "offline-heuristics")) +(def (disable-embeddings!) + (set! current-embeddings-requested? #f) + (set! current-embeddings-used? #f)) (def (llm-warnings) '()) (def (set-local-provider-command! parts) (if (list? parts) (set! current-local-provider-command (filter string? parts)))) +(def (set-local-embedding-provider-command! parts) + (if (list? parts) + (set! current-local-embedding-provider-command (filter string? parts)))) (def (local-provider-command) (if (pair? current-local-provider-command) current-local-provider-command @@ -743,6 +754,13 @@ (if (and (string? path) (not (string-empty? path))) (list path) '())))) +(def (local-embedding-provider-command) + (if (pair? current-local-embedding-provider-command) + current-local-embedding-provider-command + (let ([path (getenv "JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER")]) + (if (and (string? path) (not (string-empty? path))) + (list path) + '())))) (def (local-provider-payload repo rev subject paths lines signals score) (json-string (list (cons 'detector_version detector-version) @@ -828,6 +846,49 @@ (list '() '("local provider returned malformed JSON or omitted numeric score")))) (list '() '("local provider command failed; using offline heuristics"))))))])) +(def (local-embedding-provider-payload repo rev subject paths lines) + (json-string + (list (cons 'detector_version detector-version) + (cons 'repository repo) + (cons 'commit rev) + (cons 'subject subject) + (cons 'files paths) + (cons 'added_lines_sample (bounded-list lines local-provider-input-line-limit))))) +(def (local-embedding-provider-warning repo cmd) + (cond [(null? cmd) + "embedding provider requested but no local_embedding_provider_command config or JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER is set"] + [(not (path-absolute? (car cmd))) + "embedding provider command must be an absolute path outside the scanned repository"] + [(path-inside-root? (car cmd) repo) + "embedding provider command inside the scanned repository was refused"] + [else #f])) +(def (local-embedding-provider-signal obj) + (let ([score (provider-score obj)]) + (if score + (list (sig "local-embedding-similarity" "model" score 0.08 "low" + (provider-reason obj) + (provider-evidence obj) + "local embedding output is secondary evidence; network access is never used by this adapter")) + '()))) +(def (local-embedding-provider-result repo rev subject paths lines) + (if (not current-embeddings-requested?) + (list '() '()) + (let* ([cmd (local-embedding-provider-command)] + [command-warning (local-embedding-provider-warning repo cmd)]) + (if command-warning + (list '() (list command-warning)) + (let* ([payload (local-embedding-provider-payload repo rev subject paths lines)] + [output (try-result (run-process (append cmd (list payload))))]) + (if (ok? output) + (let ([obj (parse-json-object (unwrap output))]) + (if (and (hash-table? obj) (provider-score obj)) + (begin + (set! current-analysis-provider "offline-heuristics+local-embeddings") + (set! current-embeddings-used? #t) + (set! current-network-used? #f) + (list (local-embedding-provider-signal obj) (local-provider-input-warnings lines))) + (list '() '("embedding provider returned malformed JSON or omitted numeric score")))) + (list '() '("embedding provider command failed; using offline heuristics")))))))) (def (weight-for-category cfg category fallback) (cond [(same-public-string? category "text") (scan-config-text-weight cfg)] [(same-public-string? category "code") (scan-config-code-weight cfg)] @@ -1035,8 +1096,9 @@ '())] [weighted-signals (if metadata-only? '() (map (lambda (s) (with-config-weight current-config s)) raw-signals))] [preliminary-score (if metadata-only? 0.0 (aggregate-score weighted-signals))] - [provider-pair (local-provider-result repo id subject paths lines weighted-signals preliminary-score)] - [signals (append weighted-signals (car provider-pair))] + [embedding-pair (local-embedding-provider-result repo id subject paths lines)] + [provider-pair (local-provider-result repo id subject paths lines (append weighted-signals (car embedding-pair)) preliminary-score)] + [signals (append weighted-signals (car embedding-pair) (car provider-pair))] [score (if metadata-only? 0.0 (aggregate-score signals))] [insufficient? (and (null? signals) (null? metadata) (string-empty? note))] [v (cond [insufficient? "insufficient-evidence"] @@ -1044,6 +1106,7 @@ [else (verdict score metadata note)])]) (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution metadata signals score v (append (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents shallow? binary-count rename-copy-count missing-parent?) + (cadr embedding-pair) (cadr provider-pair))) (cadr sim-pair)))) @@ -1112,7 +1175,7 @@ (def (finding-json repo f) (list (cons 'detector_version detector-version) (cons 'config_hash (scan-config-hash current-config)) (cons 'analysis_provider current-analysis-provider) (cons 'provider current-requested-provider) - (cons 'llm_used current-llm-used?) (cons 'network_used current-network-used?) + (cons 'llm_used current-llm-used?) (cons 'embeddings_used current-embeddings-used?) (cons 'network_used current-network-used?) (cons 'repository repo) (cons 'commit (finding-commit f)) (cons 'parent (finding-parent f)) (cons 'author_name (finding-author-name f)) (cons 'author_email (finding-author-email f)) (cons 'time (finding-time f)) (cons 'subject (finding-subject f)) (cons 'files (finding-files f)) @@ -1127,7 +1190,7 @@ (def (report-json repo findings) (list (cons 'detector_version detector-version) (cons 'config_hash (scan-config-hash current-config)) (cons 'analysis_provider current-analysis-provider) (cons 'provider current-requested-provider) - (cons 'llm_used current-llm-used?) (cons 'network_used current-network-used?) + (cons 'llm_used current-llm-used?) (cons 'embeddings_used current-embeddings-used?) (cons 'network_used current-network-used?) (cons 'repository repo) (cons 'count (length findings)) (cons 'findings (map (lambda (f) (finding-json repo f)) findings)))) @@ -1210,7 +1273,8 @@ (strategy-catalog-entry "history-timing" "history" 0.15 "weight_history" "Git commit timestamps are not typing timestamps") (strategy-catalog-entry "author-baseline" "baseline" 0.00 "weight_baseline" "missing or anomalous baseline is contextual evidence") (strategy-catalog-entry "repository-baseline" "baseline" 0.00 "weight_baseline" "repository baseline uses commit-size history only") - (strategy-catalog-entry "local-llm-opinion" "model" 0.10 "provider" "local model output is secondary evidence and may be wrong"))) + (strategy-catalog-entry "local-llm-opinion" "model" 0.10 "provider" "local model output is secondary evidence and may be wrong") + (strategy-catalog-entry "local-embedding-similarity" "model" 0.08 "local_embedding_provider_command" "local embedding output is secondary evidence and network access is never used"))) (def (alist-ref/default pairs name fallback) (let ([hit (assoc name pairs)]) @@ -1278,6 +1342,7 @@ (cons 'analysis_provider current-analysis-provider) (cons 'provider current-requested-provider) (cons 'llm_used current-llm-used?) + (cons 'embeddings_used current-embeddings-used?) (cons 'network_used current-network-used?) (cons 'repository repo) (cons 'count (length findings)) @@ -1412,6 +1477,7 @@ (set! current-config (config-scan-config obj)) (set! current-excludes (append (config-string-list obj "exclude" '()) current-excludes)) (set-local-provider-command! (config-string-list obj "local_provider_command" current-local-provider-command)) +(set-local-embedding-provider-command! (config-string-list obj "local_embedding_provider_command" current-local-embedding-provider-command)) (make-options (options-command opts) (config-string obj "path" (options-path opts)) (config-number obj "count" (options-count opts)) @@ -1529,10 +1595,18 @@ (begin (disable-llm!) (loop (cdr xs) opts path-set?))] +[(string=? (car xs) "--no-embeddings") + (begin + (disable-embeddings!) + (loop (cdr xs) opts path-set?))] [(string=? (car xs) "--llm") (begin (request-llm!) (loop (cdr xs) opts path-set?))] +[(string=? (car xs) "--embeddings") + (begin + (request-embeddings!) + (loop (cdr xs) opts path-set?))] [(and (string=? (car xs) "--provider") (pair? (cdr xs))) (begin (set-provider! (cadr xs)) --- a/tests/fixture-smoke.sh +++ b/tests/fixture-smoke.sh @@ -50,6 +50,7 @@ printf '%s\n' "$json" | grep -q '"config_hash":"[0-9]' printf '%s\n' "$json" | grep -q '"analysis_provider":"offline-heuristics"' printf '%s\n' "$json" | grep -q '"provider":"none"' printf '%s\n' "$json" | grep -q '"llm_used":false' +printf '%s\n' "$json" | grep -q '"embeddings_used":false' printf '%s\n' "$json" | grep -q '"network_used":false' printf '%s\n' "$json" | grep -q '"verdict":"recorded-ai-authorship"' printf '%s\n' "$json" | grep -q '"recorded_ai_note_present":true' @@ -260,6 +261,12 @@ repo_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$repo_pr printf '%s\n' "$repo_provider_json" | grep -q '"llm_used":false' printf '%s\n' "$repo_provider_json" | grep -q 'local provider command inside the scanned repository was refused' +repo_embedding_config="$fixture/repo-local-embedding-provider.json" +printf '{"local_embedding_provider_command":["%s"]}\n' "$repo_provider_script" > "$repo_embedding_config" +repo_embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$repo_embedding_config" --format json --count 1 --embeddings) +printf '%s\n' "$repo_embedding_json" | grep -q '"embeddings_used":false' +printf '%s\n' "$repo_embedding_json" | grep -q 'embedding provider command inside the scanned repository was refused' + provider_script="$provider_tmp/local-provider.sh" { printf '#!/usr/bin/env sh\n' @@ -291,6 +298,28 @@ malformed_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$ma printf '%s\n' "$malformed_provider_json" | grep -q '"llm_used":false' printf '%s\n' "$malformed_provider_json" | grep -q 'local provider returned malformed JSON or omitted numeric score' +embedding_script="$provider_tmp/local-embedding-provider.sh" +{ + printf '#!/usr/bin/env sh\n' + printf 'printf '\''{"score":0.84,"reason":"fixture embedding provider","evidence":["embedding matched generated helper text"]}\\n'\''\n' +} > "$embedding_script" +chmod +x "$embedding_script" +embedding_config="$fixture/local-embedding-provider.json" +printf '{"local_embedding_provider_command":["%s"]}\n' "$embedding_script" > "$embedding_config" +embedding_not_requested_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$embedding_config" --format json --count 1) +printf '%s\n' "$embedding_not_requested_json" | grep -q '"embeddings_used":false' +if printf '%s\n' "$embedding_not_requested_json" | grep -q '"name":"local-embedding-similarity"'; then + echo "embedding provider should require explicit --embeddings opt-in" >&2 + exit 1 +fi +embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$embedding_config" --format json --count 1 --embeddings) +printf '%s\n' "$embedding_json" | grep -q '"analysis_provider":"offline-heuristics+local-embeddings"' +printf '%s\n' "$embedding_json" | grep -q '"llm_used":false' +printf '%s\n' "$embedding_json" | grep -q '"embeddings_used":true' +printf '%s\n' "$embedding_json" | grep -q '"network_used":false' +printf '%s\n' "$embedding_json" | grep -q '"name":"local-embedding-similarity"' +printf '%s\n' "$embedding_json" | grep -q '"evidence":\["embedding matched generated helper text"\]' + no_llm_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --llm --provider local --no-llm) if printf '%s\n' "$no_llm_json" | grep -q 'local provider requested'; then echo "no-llm should disable requested LLM warning" >&2 @@ -331,6 +360,7 @@ printf '%s\n' "$stats_json" | grep -q '"strategy_hits":\[{"name":"message-style" printf '%s\n' "$stats_json" | grep -q '{"name":"cadence-diff-shape","count":1' printf '%s\n' "$stats_json" | grep -q '"strategy_catalog":\[{"name":"message-style","category":"text","enabled":true' printf '%s\n' "$stats_json" | grep -q '{"name":"local-llm-opinion","category":"model","enabled":true' +printf '%s\n' "$stats_json" | grep -q '{"name":"local-embedding-similarity","category":"model","enabled":true' printf '%s\n' "$stats_json" | grep -q '"uncataloged_strategies":\[\]' printf '%s\n' "$stats_json" | grep -q '"warning_counts":\['