Compare commits

..
Author SHA1 Message Date
Richie bfad5d7df7 feat(installer): enhance NixOS installer with SSH access and encryption password input 2026-07-02 13:47:43 -04:00
Richie cf62f38a3d feat(systems): add tortoise NixOS host configuration
Registers the tortoise machine in flake.nix and adds its system
configuration with a placeholder hardware config (ZFS root pool,
x86_64) to be replaced by the installer-generated hardware-configuration.nix.
2026-07-01 23:06:34 -04:00
Richie d619bd8f16 feat(installer): add one-file installer build and custom install ISO
Replace old_installer.py with a curses TUI installer packaged as a
one-file PyInstaller binary (python/installer/build.py, wrapped by
python/installer/package.nix). The default .#installer is patched to run
on foreign Linux live media; the new .#installer-nixos variant keeps its
Nix store interpreter so it runs on NixOS.

Add systems/iso, a minimal NixOS install CD with kernel 6.18 and ZFS 2.4
matching the deployed systems and the installer on PATH; build it with
nix build .#iso. Shared logging and subprocess helpers move out of
common.py into python/logging_config.py and python/process.py.
2026-07-01 19:14:34 -04:00
Richie aa7dfa4ea4 fix(ebook): enhance EPUB ingestion with error handling and incrmental commits 2026-06-29 01:01:13 -04:00
Richie b995e9b6cb feat(ebook): add phrase metadata tables for protected phrase matching
Introduce four ORM models and their Alembic migration to support
phrase-based query matching in the ebook RAG engine:

- EbookCandidatePhrase: high-recall phrase candidates extracted per book,
  with source flags (ngram/yake/spacy/capitalized/metadata), scoring, and
  LLM judge results.
- EbookProtectedPhrase: phrases accepted by the LLM judge, with canonical
  id, importance, and nesting controls.
- EbookPhraseAlias: normalized aliases mapping to protected phrases.
- EbookChunkPhraseMention: precomputed phrase occurrences within chunks.

Export the new models from python.orm.richie and add a JSON_DOCUMENT
helper (JSON with JSONB postgres variant) for storing sample contexts.
2026-06-29 00:56:58 -04:00
318 changed files with 5815 additions and 21991 deletions

No files matched your search

-28
View File
@@ -1,28 +0,0 @@
.git
.direnv
.mypy_cache
.pytest_cache
.ruff_cache
.venv
**/.venv
.env
.cache
.claude
.coverage
.vscode
.stfolder
.literotica_data
esphome
htmlcov
data
ebooks
__pycache__
**/__pycache__
*.pyc
*.pyo
.ebook_search_bm25
result
result-*
*.egg-info
dist
build
+1 -16
View File
@@ -8,23 +8,8 @@ on:
- cron: "0 22 * * *"
jobs:
prebuild-common:
name: prebuild-common-x86-64-v3
runs-on: nix-cache-builder
steps:
- uses: actions/checkout@v4
# portal-1 is the smallest system closure: 95% of its derivations are
# shared by all five systems, so it is a maintainable common cache seed.
# Keep going so one failing package does not stop unrelated cache entries
# from being built.
- name: Build common packages
run: nixos-rebuild build --keep-going --accept-flake-config --flake ./#portal-1
- name: Copy common packages to nix-cache
run: nix copy --accept-flake-config --to unix:///host-nix/var/nix/daemon-socket/socket .#nixosConfigurations.portal-1.config.system.build.toplevel
build:
name: build-${{ matrix.system }}
needs: prebuild-common
runs-on: self-hosted
strategy:
matrix:
@@ -32,8 +17,8 @@ jobs:
- "bob"
- "brain"
- "jeeves"
- "leviathan"
- "rhapsody-in-green"
- "portal-1"
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -15,8 +15,9 @@ jobs:
steps:
- name: merge_flake_lock_update
run: >-
nix develop .#devShells.x86_64-linux.default -c
python -m python.gitea_flake_lock merge
--repo "${{ github.repository }}"
env:
JEEVES_BOT_TOKEN: ${{ secrets.JEEVES_BOT_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: https://gitea.tmmworkshop.com
+19
View File
@@ -0,0 +1,19 @@
name: pytest
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
pytest:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Run tests
run: nix develop .#devShells.x86_64-linux.default -c pytest tests
-26
View File
@@ -1,26 +0,0 @@
name: test ebook search
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
env:
UV_PYTHON_DOWNLOADS: never
UV_CACHE_DIR: /var/cache/uv
UV_LINK_MODE: copy
jobs:
test-ebook-search:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: nix develop .#devShells.x86_64-linux.ebook-search -c uv sync --locked --project python/ebook_search/docker
- name: Run ebook search tests
run: nix develop .#devShells.x86_64-linux.ebook-search -c uv run --project python/ebook_search/docker --no-sync pytest tests/ebook_search --override-ini addopts="-n auto -ra"
+2 -1
View File
@@ -18,8 +18,9 @@ jobs:
run: nix flake update
- name: Create or update flake.lock PR
env:
JEEVES_BOT_TOKEN: ${{ secrets.JEEVES_BOT_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: https://gitea.tmmworkshop.com
run: >-
nix develop .#devShells.x86_64-linux.default -c
python -m python.gitea_flake_lock update
--repo "${{ github.repository }}"
-3
View File
@@ -173,6 +173,3 @@ frontend/node_modules/
# data from testing llms
data/*
.ebook_search_bm25
# gems data
.gems
+2 -5
View File
@@ -1,17 +1,14 @@
# Generate AGE keys from SSH keys with:
# ssh-keygen -A
# nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age'
# update keys after addin/removing a key
# nix-shell -p sops --run "sops updatekeys users/secrets.yaml" users/secrets.yaml
keys:
- &admin_richie age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c # cspell:disable-line
- &system_bob age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y # cspell:disable-line
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
- &system_leviathan age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k # cspell:disable-line
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
- &system_portal_1 age1vyav6kxtvt3z4vtnkkjj38eu8hlts5m7ygyckhskvalg2gpjk52su53d0a # cspell:disable-line
creation_rules:
- path_regex: users/secrets\.yaml$
@@ -21,5 +18,5 @@ creation_rules:
- *system_bob
- *system_brain
- *system_jeeves
- *system_leviathan
- *system_rhapsody
- *system_portal_1
+3 -6
View File
@@ -10,6 +10,7 @@
"aiounifi",
"alsa",
"apiclient",
"apscheduler",
"archlinux",
"ashift",
"asrouter",
@@ -70,7 +71,6 @@
"ehci",
"emerg",
"endlessh",
"ents",
"errorlens",
"esbenp",
"esphome",
@@ -172,8 +172,6 @@
"Networkd",
"networkmanager",
"newtabpage",
"ngram",
"ngrams",
"nixfmt",
"nixos",
"nixpkgs",
@@ -300,9 +298,7 @@
"uiprotect",
"uitour",
"unifi",
"unjudged",
"unrar",
"unstorable",
"unsubmitted",
"uptimekuma",
"urlbar",
@@ -330,12 +326,13 @@
"xcursorgen",
"xdist",
"xhci",
"yake",
"yazi",
"yubikey",
"yubioath",
"yzhang",
"zeroconf",
"zerotier",
"zerotierone",
"zoxide",
"zram",
"zstd"
Generated
-1686
View File
File diff suppressed because it is too large. Load diff
-4
View File
@@ -1,4 +0,0 @@
[workspace]
resolver = "2"
members = ["rust/*"]
+19 -1
View File
@@ -17,11 +17,16 @@
./nix.nix
./programs.nix
./ssh.nix
./snapshot_manager.nix
];
boot = {
tmp.useTmpfs = lib.mkDefault true;
tmp.useTmpfs = true;
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_12;
zfs = {
package = lib.mkDefault pkgs.zfs_2_4;
forceImportRoot = lib.mkDefault false;
};
};
hardware.enableRedistributableFirmware = true;
@@ -37,6 +42,9 @@
overlays = builtins.attrValues outputs.overlays;
config = {
allowUnfree = true;
permittedInsecurePackages = [
"openssl-1.1.1w" # This is for discord-canary
];
};
};
@@ -45,6 +53,16 @@
# firmware update
fwupd.enable = true;
snapshot_manager = {
enable = lib.mkDefault true;
PYTHONPATH = "${inputs.self}/";
};
zfs = {
trim.enable = lib.mkDefault true;
autoScrub.enable = lib.mkDefault true;
};
};
powerManagement.powertop.enable = lib.mkDefault true;
+1 -5
View File
@@ -31,10 +31,6 @@ in
"flakes"
"ca-derivations"
];
system-features = lib.mkAfter [
"gccarch-x86-64-v2"
"gccarch-x86-64-v3"
];
warn-dirty = false;
flake-registry = ""; # disable global flake registries
connect-timeout = 10;
@@ -44,6 +40,6 @@ in
# Add each flake input as a registry and nix_path
registry = lib.mapAttrs (_: flake: { inherit flake; }) flakeInputs;
nix-path = lib.mapAttrsToList (n: _: "${n}=flake:${n}") flakeInputs;
nixPath = lib.mapAttrsToList (n: _: "${n}=flake:${n}") flakeInputs;
};
}
+1
View File
@@ -2,5 +2,6 @@
{
environment.systemPackages = with pkgs; [
git
my_python
];
}
@@ -22,12 +22,6 @@ hourly = 0
daily = 0
monthly = 0
["root_pool/nix_build"]
15_min = 1
hourly = 0
daily = 0
monthly = 0
["root_pool/var"]
15_min = 8
hourly = 24
@@ -1,5 +1,4 @@
{
inputs,
pkgs,
lib,
config,
@@ -7,7 +6,6 @@
}:
let
cfg = config.services.snapshot_manager;
snapshotManagerPackages = ps: with ps; [ typer ];
in
{
options = {
@@ -36,41 +34,6 @@ in
};
config = lib.mkIf cfg.enable {
nixpkgs.overlays = [
(final: _prev: {
snapshot_manager_python = final.python314.withPackages snapshotManagerPackages;
snapshot_manager_test_python = final.python314.withPackages (
ps:
snapshotManagerPackages ps
++ (with ps; [
pyfakefs
pytest
pytest-asyncio
pytest-mock
pytest-xdist
])
);
snapshot_manager_tests =
final.runCommand "snapshot-manager-tests"
{
nativeBuildInputs = [ final.snapshot_manager_test_python ];
}
''
export HOME="$TMPDIR"
cd ${inputs.self}
pytest \
-o cache_dir="$TMPDIR/pytest-cache" \
tests/test_common.py \
tests/test_signal_alert.py \
tests/test_snapshot_manager.py \
tests/test_zfs.py
touch "$out"
'';
})
];
system.checks = [ pkgs.snapshot_manager_tests ];
systemd = {
services.snapshot_manager = {
description = "ZFS Snapshot Manager";
@@ -82,7 +45,7 @@ in
};
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.snapshot_manager_python}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
ExecStart = "${pkgs.my_python}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
-1
View File
@@ -17,7 +17,6 @@
logDriver = "local";
storageDriver = "overlay2";
daemon.settings = {
live-restore = false;
experimental = true;
exec-opts = [ "native.cgroupdriver=systemd" ];
log-opts = {
+1 -1
View File
@@ -5,7 +5,7 @@
...
}:
let
monitoringInterface = "tailscale0";
monitoringInterface = "ztwfunumly";
nodeTextfileDir = "/var/lib/prometheus-node-exporter-textfile";
mkProcessNameTemplate =
-46
View File
@@ -1,46 +0,0 @@
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.signal-cli
];
users = {
groups.signal-cli = { };
users.signal-cli = {
isSystemUser = true;
group = "signal-cli";
home = "/var/lib/signal-cli";
};
};
systemd.tmpfiles.rules = [
"d /var/lib/signal-cli 0700 signal-cli signal-cli - -"
"Z /var/lib/signal-cli - signal-cli signal-cli - -"
];
systemd.services.signal-cli = {
description = "Signal CLI JSON-RPC service";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
unitConfig.RequiresMountsFor = [ "/var/lib/signal-cli" ];
serviceConfig = {
Type = "simple";
User = "signal-cli";
Group = "signal-cli";
ExecStart = "${pkgs.signal-cli}/bin/signal-cli --data-dir /var/lib/signal-cli daemon --socket /run/signal-cli/socket";
Restart = "on-failure";
RestartSec = "5s";
SuccessExitStatus = 143;
RuntimeDirectory = "signal-cli";
RuntimeDirectoryMode = "0750";
UMask = "0007";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/signal-cli" ];
};
};
}
-37
View File
@@ -1,37 +0,0 @@
{
config,
inputs,
...
}:
{
nix.settings = {
trusted-substituters = [ "http://jeeves:5000" ];
substituters = [ "http://jeeves:5000/?priority=1&want-mass-query=true" ];
trusted-public-keys = [ "cache.tmmworkshop.com:jHffkpgbmEdstQPoihJPYW9TQe6jnQbWR2LqkNGV3iA=" ];
};
services.tailscale = {
enable = true;
openFirewall = true;
authKeyFile = config.sops.secrets.tailscale_auth_key.path;
# OAuth client secrets create ephemeral nodes by default. NixOS machines
# are persistent and should enroll without interactive device approval.
authKeyParameters = {
ephemeral = false;
preauthorized = true;
};
extraUpFlags = [ "--advertise-tags=tag:nixos" ];
};
sops = {
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
secrets.tailscale_auth_key = {
sopsFile = "${inputs.self}/users/secrets.yaml";
owner = "root";
mode = "0400";
};
};
}
-15
View File
@@ -1,15 +0,0 @@
{
nixpkgs = {
hostPlatform = {
system = "x86_64-linux";
gcc = {
arch = "x86-64-v3";
tune = "generic";
};
};
# These patches repair tests and bundled dependencies that are sensitive
# to the compiler flags used by the x86-64-v3 package set.
overlays = [ (import ./patches) ];
};
}
@@ -1,20 +0,0 @@
Subject: [PATCH] abseil: include BMI2 intrinsics through the public header
GCC and Clang reject direct inclusion of bmi2intrin.h. Include immintrin.h
instead so that the compiler supplies the required intrinsic setup when
BMI2 is enabled, including builds targeting x86-64-v3.
This patch is shared by the vendored Abseil copies in Electron, rusty_v8
(Deno), and Signal's WebRTC build.
--- a/third_party/abseil-cpp/absl/container/internal/raw_hash_set.h
+++ b/third_party/abseil-cpp/absl/container/internal/raw_hash_set.h
@@ -226,7 +226,7 @@
#endif
#ifdef __BMI2__
-#include <bmi2intrin.h>
+#include <immintrin.h>
#endif // __BMI2__
namespace absl {
@@ -1,38 +0,0 @@
# Abseil accepted the upstream fix: https://github.com/abseil/abseil-cpp/pull/2071
# Keep this workaround until Electron, Deno's rusty_v8, and Signal's WebRTC
# update their bundled Abseil copies to include it.
{ prev }:
let
patchAbseilBmi2Include =
package:
package.overrideAttrs (old: {
# GCC and Clang require the public umbrella header for BMI2 intrinsics.
patches = (old.patches or [ ]) ++ [ ./bmi2-public-header.patch ];
});
electron43Unwrapped = patchAbseilBmi2Include prev.electron_43.unwrapped;
electron43 = prev.electron_43.override {
electron-unwrapped = electron43Unwrapped;
};
signalCallPackage =
path: args:
let
package = prev.callPackage path args;
in
if builtins.baseNameOf path == "webrtc.nix" then patchAbseilBmi2Include package else package;
in
prev.lib.optionalAttrs ((prev.stdenv.hostPlatform.gcc.arch or null) == "x86-64-v3") {
deno =
let
librusty_v8 = patchAbseilBmi2Include prev.deno.passthru.librusty_v8;
in
prev.deno.override { inherit librusty_v8; };
electron_43 = electron43;
signal-desktop = prev.signal-desktop.override {
electron_43 = electron43;
callPackage = signalCallPackage;
};
}
@@ -1,19 +0,0 @@
Subject: [PATCH] Compare font content in the overlap-removal regression
PFA_SKIP ends with a separator, producing an empty prefix that matches every
line. Remove the empty prefix for this test so glyph differences are checked,
and assert that tx exits successfully before inspecting the output.
--- a/tests/tx_test.py
+++ b/tests/tx_test.py
@@ -1064,6 +1064,8 @@
output_path = get_temp_file_path()
args = [TOOL, '-t1', '+V', '-o', output_path, input_path]
- subprocess.call(args)
- assert differ([expected_path, output_path, '-s', PFA_SKIP[0]])
+ subprocess.check_call(args)
+ # An empty skip prefix matches every line and hides font differences.
+ skip_headers = SPLIT_MARKER.join(filter(None, PFA_SKIP[0].split(SPLIT_MARKER)))
+ assert differ([expected_path, output_path, '-s', skip_headers])
@@ -1,10 +0,0 @@
{ afdko }:
afdko.overridePythonAttrs (old: {
# FMA changes overlap-removal coordinates by 0.01 units on x86-64-v3.
# Separate multiply/add rounding reproduces the reference Type 1 font exactly.
env = (old.env or { }) // {
NIX_CFLAGS_COMPILE = (old.env.NIX_CFLAGS_COMPILE or "") + " -ffp-contract=off";
};
patches = (old.patches or [ ]) ++ [ ./check-overlap-font-content.patch ];
})
@@ -1,19 +0,0 @@
{ prev }:
let
buildRedistHook = prev.runCommand "buildRedistHook.bash" { } ''
cp ${prev.path}/pkgs/development/cuda-modules/buildRedist/buildRedistHook.bash "$out"
chmod u+w "$out"
patch "$out" < ${./propagated-build-output-arrays.patch}
'';
in
prev.cudaPackages_12_9.overrideScope (
_cudaFinal: cudaPrev: {
buildRedist =
args:
(cudaPrev.buildRedist args).overrideAttrs (old: {
nativeBuildInputs = map (
input: if builtins.baseNameOf input == "buildRedistHook.bash" then buildRedistHook else input
) old.nativeBuildInputs;
});
}
)
@@ -1,44 +0,0 @@
Subject: [PATCH] Preserve structured CUDA output arrays
The multiple-outputs hook now accepts arrays. The old compatibility export
collapses include and lib into one array element, breaking indirect expansion.
Keep the array and iterate over each output when propagating to out.
--- a/buildRedistHook.bash
+++ b/buildRedistHook.bash
@@ -23,9 +23,6 @@
postInstallCheckHooks+=(checkCudaNonEmptyOutputs)
nixLog "added checkCudaNonEmptyOutputs to postInstallCheckHooks"
-
- preFixupHooks+=(fixupPropagatedBuildOutputsForMultipleOutputs)
- nixLog "added fixupPropagatedBuildOutputsForMultipleOutputs to preFixupHooks"
postFixupHooks+=(fixupCudaPropagatedBuildOutputsToOut)
nixLog "added fixupCudaPropagatedBuildOutputsToOut to postFixupHooks"
@@ -193,16 +190,6 @@
return 0
}
-# TODO(@connorbaker): https://github.com/NixOS/nixpkgs/issues/323126.
-# _multioutPropagateDev() currently expects a space-separated string rather than an array.
-# NOTE: Because _multioutPropagateDev is a postFixup hook, we correct it in preFixup.
-fixupPropagatedBuildOutputsForMultipleOutputs() {
- nixLog "converting propagatedBuildOutputs to a space-separated string"
- # shellcheck disable=SC2124
- export propagatedBuildOutputs="${propagatedBuildOutputs[@]}"
- return 0
-}
-
# The multiple outputs setup hook only propagates build outputs to dev.
# We want to propagate them to out as well, in case the user interpolates
# the package into a string -- in such a case, the dev output is not selected
@@ -217,7 +204,7 @@
mkdir -p "${out:?}/nix-support"
# NOTE: We must use printWords to ensure the output is a single line.
- for output in $propagatedBuildOutputs; do
+ for output in "${propagatedBuildOutputs[@]}"; do
# Propagate the other components to the out output
nixLog "adding ${!output:?} to propagatedBuildInputs of ${out:?}"
printWords "${!output:?}" >>"${out:?}/nix-support/propagated-build-inputs"
@@ -1,18 +0,0 @@
_final: prev:
(import ./abseil { inherit prev; })
// {
cudaPackages_12_9 = import ./cuda { inherit prev; };
trunk = import ./trunk { inherit (prev) trunk jq; };
gnutls = import ./gnutls { inherit (prev) gnutls; };
prometheus = import ./prometheus { inherit (prev) prometheus; };
zopfli = import ./zopfli { inherit (prev) zopfli; };
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
(_pythonFinal: pythonPrev: {
afdko = import ./afdko { inherit (pythonPrev) afdko; };
psutil = import ./psutil { inherit (pythonPrev) psutil; };
pytest-xdist = import ./pytest-xdist { inherit (pythonPrev) pytest-xdist; };
scipy = import ./scipy { inherit (pythonPrev) scipy; };
})
];
}
@@ -1,6 +0,0 @@
{ gnutls }:
gnutls.overrideAttrs (old: {
# Keep the UDP handshake test enabled on loaded builders by waiting for
# the server to bind its socket. Kept as a standalone patch for upstream.
patches = (old.patches or [ ]) ++ [ ./udp-server-readiness.patch ];
})
@@ -1,70 +0,0 @@
Subject: [PATCH] tests: wait for the UDP server socket before connecting
A fixed four-second sleep does not guarantee that gnutls-serv has bound
its UDP socket on a busy builder. Poll the local IPv4 UDP endpoint using
the existing ss/netstat discovery, with the same retry budget as the TCP
helper. Fail early if the server exits, and retain the original handshake
checks in serv-udp.sh.
Use flags common to ss and BSD/Linux netstat. Match the local endpoint
and complete port number, excluding TCP, IPv6-only and peer endpoints.
--- a/tests/scripts/common.sh
+++ b/tests/scripts/common.sh
@@ -185,10 +185,55 @@
fi
}
+check_if_udp_port_bound() {
+ local PORT=$1
+ have_port_finder
+ # Use only -an, which is shared by ss and BSD/Linux netstat. UDP has
+ # no LISTEN state. Match the local IPv4 endpoint, not a peer port or
+ # a longer port number. serv-udp.sh connects to 127.0.0.1;
+ # listen_socket() in serv.c binds IPv4 separately and requests
+ # IPV6_V6ONLY=1 for its IPv6 socket.
+ $PFCMD -an | awk -v port="$PORT" '
+ $1 == "udp" || $1 == "udp4" {
+ # ss includes a state column; netstat does not.
+ address = ($2 == "UNCONN" || $2 == "ESTAB") ? $5 : $4
+ if (address ~ ("^[0-9.]+[.:]" port "$") ||
+ address == "*." port)
+ found = 1
+ }
+ END { exit !found }
+ '
+}
+
wait_udp_server() {
local PID=$1
+ local ret
trap "test -n \"${PID}\" && kill ${PID};exit 1" 1 15 2
- sleep 4
+ local i=0
+ # Use the same retry budget as wait_for_port(), but also stop if the
+ # server exits before binding its socket.
+ while test $i -lt 90; do
+ if ! kill -0 "$PID" 2>/dev/null; then
+ fail "" "UDP server $PID exited before binding port $PORT"
+ fi
+ # Contain have_port_finder's exit so a skip also stops the server.
+ if (check_if_udp_port_bound "$PORT"); then
+ return 0
+ else
+ ret=$?
+ if test "$ret" = 77; then
+ kill "$PID" 2>/dev/null || :
+ wait "$PID" 2>/dev/null || :
+ exit 77
+ fi
+ fi
+ i=$((i + 1))
+ if test $i -lt 90; then
+ echo "try $i: waiting for UDP port $PORT"
+ sleep 2
+ fi
+ done
+ fail "$PID" "UDP server $PORT did not come up"
}
create_testdir() {
@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""Exercise patched common.sh without building GnuTLS (Python standard library only).
Usage: python3 verify-readiness.py /path/to/patched/tests/scripts/common.sh
Set SHELL to test another shell, and NETSTAT to test a netstat outside PATH.
"""
# Use unittest so this upstream companion tool needs no pytest installation.
# ruff: noqa: PT009
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
COMMON = str(Path(sys.argv.pop(1)).resolve())
SHELL = os.environ.get("SHELL", "/bin/sh")
class ReadinessTests(unittest.TestCase):
"""Check endpoint parsing and the server startup lifecycle."""
def setUp(self) -> None:
"""Create a socket-listing fixture for each check."""
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.fixture = self.root / "sockets"
self.fixture.write_text("")
self.finder = self.root / "port-finder"
self.finder.write_text('#!/bin/sh\ncat "$SOCKET_FIXTURE"\n')
self.finder.chmod(0o755)
def run_shell(self, body: str, **env: str) -> subprocess.CompletedProcess[str]:
"""Source the actual helper and run a shell scenario."""
return subprocess.run(
[SHELL, "-c", '. "$COMMON"\n' + body],
env={
**os.environ,
"COMMON": COMMON,
"SOCKET_FIXTURE": str(self.fixture),
"PFCMD": str(self.finder),
"PORT": "12345",
**env,
},
capture_output=True,
text=True,
timeout=20,
check=False,
)
def test_socket_formats_and_false_matches(self) -> None:
"""Accept IPv4 UDP local endpoints and reject unrelated sockets."""
cases = [
("udp UNCONN 0 0 0.0.0.0:12345 0.0.0.0:*", True),
("udp UNCONN 0 0 127.0.0.1:12345 0.0.0.0:*", True),
("udp 0 0 0.0.0.0:12345 0.0.0.0:*", True),
("udp4 0 0 *.12345 *.*", True),
("udp 0 0 127.0.0.1.12345 *.*", True),
("udp 0 0 *.12345 *.*", True),
("udp UNCONN 0 0 0.0.0.0:123456 0.0.0.0:*", False),
("udp 0 0 0.0.0.0:123456 0.0.0.0:*", False),
("udp ESTAB 0 0 127.0.0.1:54321 127.0.0.1:12345", False),
("udp 0 0 127.0.0.1:54321 127.0.0.1:12345", False),
("tcp LISTEN 0 128 0.0.0.0:12345 0.0.0.0:*", False),
("tcp 0 0 0.0.0.0:12345 0.0.0.0:* LISTEN", False),
("udp UNCONN 0 0 [::]:12345 [::]:*", False),
("udp UNCONN 0 0 *:12345 *:*", False),
("udp6 0 0 :::12345 :::*", False),
("udp6 0 0 *.12345 *.*", False),
("", False),
]
for row, ready in cases:
with self.subTest(row=row):
self.fixture.write_text(row + "\n")
result = self.run_shell('check_if_udp_port_bound "$PORT"')
self.assertEqual(result.returncode, 0 if ready else 1, result.stderr)
def test_exited_server_fails_immediately(self) -> None:
"""Fail without sleeping when the server has already exited."""
result = self.run_shell(
'true &\npid=$!\nwait "$pid"\nsleep() { echo "unexpected sleep" >&2; }\nwait_udp_server "$pid"'
)
self.assertEqual(result.returncode, 1)
self.assertIn("exited before binding", result.stderr)
self.assertNotIn("unexpected sleep", result.stderr)
def test_ready_socket_does_not_sleep(self) -> None:
"""Check readiness before the first sleep."""
self.fixture.write_text("udp UNCONN 0 0 0.0.0.0:12345 0.0.0.0:*\n")
result = self.run_shell('sleep() { echo "unexpected sleep" >&2; }\nwait_udp_server "$$"')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("unexpected sleep", result.stderr)
def test_missing_port_finders_skip(self) -> None:
"""Skip and stop the live server when no finder is available."""
# have_port_finder also tries these paths independently of PATH.
if any(os.access(f"{directory}/ss", os.X_OK) for directory in ("/sbin", "/usr/sbin", "/usr/local/sbin")):
self.skipTest("an absolute ss path cannot be hidden by this PATH-only fixture")
with subprocess.Popen(["sleep", "60"]) as server:
try:
result = self.run_shell(
'unset PFCMD\nPATH=/nonexistent\nwait_udp_server "$SERVER_PID"',
SERVER_PID=str(server.pid),
)
self.assertEqual(result.returncode, 77)
self.assertIn("neither ss nor netstat found", result.stderr)
server.wait(timeout=3)
self.assertLess(server.returncode, 0)
finally:
if server.poll() is None:
server.kill()
def test_timeout_is_bounded_and_cleans_up(self) -> None:
"""Stop polling after the retry budget and terminate the server."""
# Only accelerate the polling delay; keep a real live server process.
self.finder.write_text('#!/bin/sh\necho probe >&2\ncat "$SOCKET_FIXTURE"\n')
with subprocess.Popen(["sleep", "60"]) as server:
try:
result = self.run_shell(
'sleep() { echo polling-sleep; }\nwait_udp_server "$SERVER_PID"',
SERVER_PID=str(server.pid),
)
self.assertEqual(result.returncode, 1)
self.assertIn("did not come up", result.stderr)
self.assertEqual(result.stderr.count("probe\n"), 90)
self.assertEqual(result.stdout.count("polling-sleep"), 89)
server.wait(timeout=3)
self.assertLess(server.returncode, 0)
finally:
if server.poll() is None:
server.kill()
def test_server_exits_while_waiting(self) -> None:
"""Detect a startup failure that happens after polling begins."""
result = self.run_shell('sleep 1 &\npid=$!\nwait_udp_server "$pid"')
self.assertEqual(result.returncode, 1)
self.assertIn("exited before binding", result.stderr)
self.assertIn("waiting for UDP port", result.stdout)
def test_real_socket_delayed_beyond_four_seconds(self) -> None:
"""Wait for a real delayed bind with each installed port finder."""
finders = [shutil.which("ss"), os.environ.get("NETSTAT") or shutil.which("netstat")]
finders = [finder for finder in finders if finder]
if not finders:
self.skipTest("neither ss nor netstat available")
for finder in finders:
with self.subTest(finder=finder):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
code = (
"import socket,time,sys; time.sleep(6); "
"s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); "
"s.bind(('127.0.0.1',int(sys.argv[1]))); time.sleep(30)"
)
with subprocess.Popen([sys.executable, "-c", code, str(port)]) as server:
try:
started = time.monotonic()
result = self.run_shell(
'wait_udp_server "$SERVER_PID"',
SERVER_PID=str(server.pid),
PORT=str(port),
PFCMD=finder,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertGreaterEqual(time.monotonic() - started, 6)
self.assertIsNone(server.poll())
finally:
server.terminate()
server.wait(timeout=3)
if __name__ == "__main__":
unittest.main()
@@ -1,36 +0,0 @@
Subject: [PATCH] tests: finish parsing before inspecting editor state
EditorState creation has a 20 ms parsing budget. A descheduled test can
therefore observe an incomplete tree. Finish these small test documents
without an interactive deadline and publish the result with a transaction.
Keep the original completion and vector-matching assertions enabled.
--- a/module/codemirror-promql/src/test/utils-test.ts
+++ b/module/codemirror-promql/src/test/utils-test.ts
@@ -13,7 +13,7 @@
import { parser } from '@prometheus-io/lezer-promql';
import { EditorState } from '@codemirror/state';
-import { LRLanguage } from '@codemirror/language';
+import { ensureSyntaxTree, LRLanguage } from '@codemirror/language';
import nock from 'nock';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -23,10 +23,16 @@
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export function createEditorState(expr: string): EditorState {
- return EditorState.create({
+ const state = EditorState.create({
doc: expr,
extensions: lightPromQLSyntax,
});
+ // These tests need a complete tree, independent of the editor's time budget.
+ if (!ensureSyntaxTree(state, state.doc.length, Infinity)) {
+ throw new Error('Unable to parse the test expression');
+ }
+ // Publish the completed parse so syntaxTree(state) sees it too.
+ return state.update({}).state;
}
export function mockPrometheusServer(): void {
@@ -1,17 +0,0 @@
{ prometheus }:
prometheus.overrideAttrs (
old:
let
assets = old.passthru.assets.overrideAttrs (assetsOld: {
patches = (assetsOld.patches or [ ]) ++ [ ./complete-test-parsing.patch ];
});
in
{
postPatch = builtins.replaceStrings [ "${old.passthru.assets}" ] [ "${assets}" ] (
builtins.unsafeDiscardStringContext old.postPatch
);
passthru = old.passthru // {
inherit assets;
};
}
)
@@ -1,4 +0,0 @@
{ psutil }:
psutil.overridePythonAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./heap-info-zero-mmap.patch ];
})
@@ -1,17 +0,0 @@
Subject: [PATCH] Allow an empty mmap allocation total in heap_info
The allocator may satisfy all live allocations from the heap. A zero
mmap_used value is valid and depends on the worker allocation history.
Keep the heap and platform checks without requiring an mmap allocation.
--- a/tests/test_system.py
+++ b/tests/test_system.py
@@ -267,6 +267,7 @@
if MACOS:
assert m.mmap_used == 0 # not supported
else:
- assert m.mmap_used > 0
+ # A process can have no live mmap-backed malloc allocations.
+ assert m.mmap_used >= 0
if WINDOWS:
assert m.heap_count >= 0
@@ -1,29 +0,0 @@
Subject: [PATCH] tests: count replacements when checking the worker restart limit
With two workers, another in-flight test may crash after the fourth
crash requests shutdown. Either four or five failed tests is valid.
Require exactly three replacements and the failed-tests exit status,
while preserving the queued-work and no-internal-error assertions.
--- a/testing/acceptance_test.py
+++ b/testing/acceptance_test.py
@@ -1011,9 +1011,18 @@
"worker*crashed while running*",
"worker*crashed while running*",
"* xdist: maximum crashed workers reached: 3 *",
- "* 4 failed in *",
]
)
+ # A second in-flight test may crash after shutdown is requested.
+ # The restart limit constrains replacements, not concurrent failures.
+ replacements = sum(
+ line.startswith("replacing crashed worker ") for line in res.stdout.lines
+ )
+ assert replacements == 3
+ failed = res.parseoutcomes()["failed"]
+ assert failed in (4, 5)
+ res.assert_outcomes(failed=failed)
+ assert res.ret == pytest.ExitCode.TESTS_FAILED
assert "INTERNALERROR" not in res.stdout.str()
def test_max_worker_restart_die(self, pytester: pytest.Pytester) -> None:
@@ -1,12 +0,0 @@
{ pytest-xdist }:
pytest-xdist.overridePythonAttrs (old: {
patches = (old.patches or [ ]) ++ [
./concurrent-worker-crashes.patch
./worker-startup-timeout.patch
];
# The suite exercises its own worker pools. Limit the outer suite to one worker.
preCheck = builtins.replaceStrings [ "--numprocesses=$NIX_BUILD_CORES" ] [ "--numprocesses=1" ] (
old.preCheck or ""
);
})
@@ -1,19 +0,0 @@
Subject: [PATCH] tests: allow more time for remote worker events
Worker startup can exceed ten seconds on heavily loaded builders. Allow
the remote-test helper to wait up to sixty seconds for worker events.
The wait still returns as soon as an event arrives and remains bounded.
Production worker timeouts and test assertions are unchanged.
--- a/testing/test_remote.py
+++ b/testing/test_remote.py
@@ -17,7 +17,8 @@
from xdist.workermanage import WorkerController
-WAIT_TIMEOUT = 10.0
+# Allow worker events extra time on heavily loaded builders.
+WAIT_TIMEOUT = 60.0
def check_marshallable(d: object) -> None:
@@ -1,5 +0,0 @@
{ scipy }:
scipy.overridePythonAttrs (old: {
# Keep the STFT tests enabled with tolerances for x86-64-v3 rounding.
patches = (old.patches or [ ]) ++ [ ./stft-test-tolerances.patch ];
})
@@ -1,51 +0,0 @@
Subject: [PATCH] signal: allow floating-point rounding in STFT tests
Keep the STFT tests enabled for x86-64-v3 builds. Allow two float32
epsilons of relative error when comparing inverse-STFT implementations;
float64 and the existing i686 override remain unchanged. Allow one
float64 epsilon of absolute error in all three scaling round trips,
which otherwise require exact zeros (observed residual: 4e-17 for a
signal with amplitude 2).
Upstream issue: https://github.com/scipy/scipy/issues/25488
--- a/scipy/signal/tests/_scipy_spectral_test_shim.py
+++ b/scipy/signal/tests/_scipy_spectral_test_shim.py
@@ -294,7 +294,7 @@
# Adapted tolerances to account for resolution loss:
atol = np.finfo(x.dtype).resolution*2 # instead of default atol = 0
- rtol = 1e-7 # default for np.allclose()
+ rtol = max(1e-7, 2 * np.finfo(x.dtype).eps)
# Relax atol on 32-Bit platforms a bit to pass CI tests.
# - Not clear why there are discrepancies (in the FFT maybe?)
--- a/scipy/signal/tests/test_spectral.py
+++ b/scipy/signal/tests/test_spectral.py
@@ -2044,7 +2044,7 @@
# Test round trip:
x1 = istft(Zs, boundary=True, scaling='spectrum')[1]
- assert_allclose(x1, x)
+ assert_allclose(x1, x, atol=np.finfo(x.dtype).eps)
# For a Hann-windowed 256 sample length FFT, we expect a peak at
# frequency 64 (since it is 1/4 the length of X) with a height of 1
@@ -2074,7 +2074,7 @@
# Test round trip:
x1 = istft(Zp, input_onesided=False, boundary=True, scaling='psd')[1]
- assert_allclose(x1, x)
+ assert_allclose(x1, x, atol=np.finfo(x.dtype).eps)
# The power of the one-sided psd-scaled STFT can be determined
# analogously (note that the two sides are not of equal shape):
@@ -2094,7 +2094,7 @@
# Test round trip:
x1 = istft(Zp0, input_onesided=True, boundary=True, scaling='psd')[1]
- assert_allclose(x1, x)
+ assert_allclose(x1, x, atol=np.finfo(x.dtype).eps)
class TestSampledSpectralRepresentations:
@@ -1,21 +0,0 @@
{ trunk, jq }:
trunk.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ jq ];
# Patch the bundled libdeflate before Cargo validates its vendor checksums.
postPatch = (old.postPatch or "") + ''
libdeflateVendors=( "$cargoDepsCopy"/source-*/libdeflate-sys-1.23.1 )
if [ "''${#libdeflateVendors[@]}" -ne 1 ] || [ ! -d "''${libdeflateVendors[0]}" ]; then
echo "Expected exactly one vendored libdeflate-sys 1.23.1" >&2
exit 1
fi
libdeflateVendor="''${libdeflateVendors[0]}"
patch -d "$libdeflateVendor/libdeflate" -p1 < ${./libdeflate-gcc16.patch}
libdeflateHeader=libdeflate/lib/x86/cpu_features.h
libdeflateChecksum=$(sha256sum "$libdeflateVendor/$libdeflateHeader" | cut -d ' ' -f 1)
jq --arg path "$libdeflateHeader" --arg hash "$libdeflateChecksum" \
'.files[$path] = $hash' "$libdeflateVendor/.cargo-checksum.json" \
> "$libdeflateVendor/.cargo-checksum.json.new"
mv "$libdeflateVendor/.cargo-checksum.json.new" "$libdeflateVendor/.cargo-checksum.json"
'';
})
@@ -1,33 +0,0 @@
From 2a3762cf736aff9aa5dda329fdec3c08a816eb9b Mon Sep 17 00:00:00 2001
From: Sergei Trofimovich <slyich@gmail.com>
Date: Tue, 20 May 2025 21:18:28 +0100
Subject: [PATCH] lib/x86/cpu_features.h: drop evex512 on gcc-16
gcc-16 `master` branch dropped support for `evex512` as:
https://gcc.gnu.org/git/?p=gcc.git;a=commitdiff;h=c052a6f4a1c803cb92147ff98fb91cf3511e0856
As a result libdeflate build started failing as:
In file included from /build/source/lib/adler32.c:28:
/build/source/lib/x86/adler32_template.h:135:12: error: attribute 'target' argument 'no-evex512' is unknown
135 | ADD_SUFFIX(reduce_to_32bits)(vec_t v_s1, vec_t v_s2, u32 *s1_p, u32 *s2_p)
| ^~~~~~~~~~~~~~~~
The change adds upper build to `evex512` for `gcc`.
---
lib/x86/cpu_features.h | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/x86/cpu_features.h b/lib/x86/cpu_features.h
index fbfdb0eb..f9d69d72 100644
--- a/lib/x86/cpu_features.h
+++ b/lib/x86/cpu_features.h
@@ -165,7 +165,7 @@ static inline u32 get_x86_cpu_features(void) { return 0; }
# define HAVE_AVXVNNI(features) ((features) & X86_CPU_FEATURE_AVXVNNI)
#endif
-#if (GCC_PREREQ(14, 0) || CLANG_PREREQ(18, 0, 18000000)) \
+#if ((GCC_PREREQ(14, 0) && !(GCC_PREREQ(16, 0))) || CLANG_PREREQ(18, 0, 18000000)) \
&& !defined(__EVEX512__) /* avoid subtracting the evex512 feature */
# define EVEX512 ",evex512" /* needed to override potential -mno-evex512 */
# define NO_EVEX512 ",no-evex512"
@@ -1,4 +0,0 @@
{ zopfli }:
zopfli.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./unaligned-match-loads.patch ];
})
@@ -1,32 +0,0 @@
Subject: [PATCH] Read unaligned match buffers with memcpy
Byte buffers need not satisfy integer alignment or aliasing requirements.
GCC 16 with x86-64-v3 vectorizes the cast loads using aligned AVX reads,
causing a segmentation fault. Copy into local integers instead.
--- a/src/zopfli/lz77.c
+++ b/src/zopfli/lz77.c
@@ -302,13 +302,20 @@
if (sizeof(size_t) == 8) {
/* 8 checks at once per array bounds check (size_t is 64-bit). */
- while (scan < safe_end && *((size_t*)scan) == *((size_t*)match)) {
+ while (scan < safe_end) {
+ size_t scan_word, match_word;
+ memcpy(&scan_word, scan, sizeof(scan_word));
+ memcpy(&match_word, match, sizeof(match_word));
+ if (scan_word != match_word) break;
scan += 8;
match += 8;
}
} else if (sizeof(unsigned int) == 4) {
/* 4 checks at once per array bounds check (unsigned int is 32-bit). */
- while (scan < safe_end
- && *((unsigned int*)scan) == *((unsigned int*)match)) {
+ while (scan < safe_end) {
+ unsigned int scan_word, match_word;
+ memcpy(&scan_word, scan, sizeof(scan_word));
+ memcpy(&match_word, match, sizeof(match_word));
+ if (scan_word != match_word) break;
scan += 4;
match += 4;
}
+11
View File
@@ -0,0 +1,11 @@
{
services.zerotierone = {
enable = true;
joinNetworks = [ "e4da7455b2ae64ca" ];
};
nix.settings = {
trusted-substituters = [ "http://192.168.90.40:5000" ];
substituters = [ "http://192.168.90.40:5000/?priority=1&want-mass-query=true" ];
trusted-public-keys = [ "cache.tmmworkshop.com:jHffkpgbmEdstQPoihJPYW9TQe6jnQbWR2LqkNGV3iA=" ];
};
}
-26
View File
@@ -1,26 +0,0 @@
{
inputs,
lib,
pkgs,
...
}:
{
imports = [ ./snapshot.nix ];
boot.zfs = {
package = lib.mkDefault pkgs.zfs_2_4;
forceImportRoot = lib.mkDefault false;
};
services = {
snapshot_manager = {
enable = lib.mkDefault true;
PYTHONPATH = "${inputs.self}/";
};
zfs = {
trim.enable = lib.mkDefault true;
autoScrub.enable = lib.mkDefault true;
};
};
}
Generated
+33 -108
View File
@@ -1,41 +1,25 @@
{
"nodes": {
"disko": {
"firefox-addons": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1789770686,
"narHash": "sha256-uZkBR7yHdIKUFB5SZdfgh1qkGfI3XmYmI/lTiquxbck=",
"owner": "nix-community",
"repo": "disko",
"rev": "725ea35e410ad83be4931d1bff7e090eacaf3563",
"type": "github"
"dir": "pkgs/firefox-addons",
"lastModified": 1781928171,
"narHash": "sha256-2IIxdVe7afJ8HyTeR/MD9Qw5iIJ68o1iRYkiXn7LDag=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "1777920f3688105a47b95b229dcefc85ae1bb42e",
"type": "gitlab"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
"dir": "pkgs/firefox-addons",
"owner": "rycee",
"repo": "nur-expressions",
"type": "gitlab"
}
},
"home-manager": {
@@ -45,11 +29,11 @@
]
},
"locked": {
"lastModified": 1790989254,
"narHash": "sha256-ZN+riaeFuE+DZ5zfqJiXlM12jLwZzeIXiNkU1YeHMXI=",
"lastModified": 1781989573,
"narHash": "sha256-npfH7Zv7t1akX/ArqCNro4zU4ViPlghLaPnbEfHbCxk=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "acd21c5a3420a9d5fd0ed06299b10828267ef9ba",
"rev": "78e7d8b13ecd7f5256a5c11ce216876164099d9f",
"type": "github"
},
"original": {
@@ -58,38 +42,16 @@
"type": "github"
}
},
"jellyswarrm": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1790427786,
"narHash": "sha256-8+Ip0HFW2oZiRr0zCZmPPIHfTdxxHy4CmSF1uOkM3Bw=",
"owner": "LLukas22",
"repo": "Jellyswarrm",
"rev": "9e67c5ad12116add867ba6c1bf69f767879e9147",
"type": "github"
},
"original": {
"owner": "LLukas22",
"repo": "Jellyswarrm",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1790948122,
"narHash": "sha256-fDMYyzY1lI5b8RuCMrvoaIgTeRwNnTG4dNk1ucROlQE=",
"lastModified": 1781622756,
"narHash": "sha256-JrPh4M6S7aPsEE9tOENuZrxC6o2szSLlK+t4+nLke9s=",
"owner": "nixos",
"repo": "nixos-hardware",
"rev": "0953bb1a609df63013176e3463393f8df993d3ef",
"rev": "08018c72174a4df5657f8d94178ac69fb9c243e5",
"type": "github"
},
"original": {
@@ -101,11 +63,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1789546076,
"narHash": "sha256-vWkSk5bbfTqdtMoSgD9FshACO8JCvXTFi+3cqEp0mH0=",
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
"lastModified": 1767892417,
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1074753.b1b875982b17/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -114,11 +76,11 @@
},
"nixpkgs-master": {
"locked": {
"lastModified": 1791033472,
"narHash": "sha256-IRpPnIfZ0drkkyMtuuvXaada2RjH+G2WYv14Ap1W8q4=",
"lastModified": 1782009160,
"narHash": "sha256-BRto9JC5S8uaapULr/n+tLbZf896K4kbtVZ0PnFra0U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ad70d223e208533b7ee8cc8e336a34aad134ab19",
"rev": "a7ef34422d8e3cd1f9ffaec949735adb983d4ffc",
"type": "github"
},
"original": {
@@ -146,11 +108,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1790822859,
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
"lastModified": 1781577229,
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
"type": "github"
},
"original": {
@@ -162,36 +124,14 @@
},
"root": {
"inputs": {
"disko": "disko",
"firefox-addons": "firefox-addons",
"home-manager": "home-manager",
"jellyswarrm": "jellyswarrm",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs_2",
"nixpkgs-master": "nixpkgs-master",
"nixpkgs-stable": "nixpkgs-stable",
"sops-nix": "sops-nix",
"systems": "systems_2"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"jellyswarrm",
"nixpkgs"
]
},
"locked": {
"lastModified": 1779419951,
"narHash": "sha256-dMX0PUslUHPajP6o8FEoRdFv9afq/dec4POR0vVfjK4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "5b5c521d6cae9ef4aa32f888eb2c0ce595c9be52",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"systems": "systems"
}
},
"sops-nix": {
@@ -201,11 +141,11 @@
]
},
"locked": {
"lastModified": 1790498116,
"narHash": "sha256-rs9meAYxW3zzrh43yaW7htrqCD+X9+pupDPHN86fumI=",
"lastModified": 1781943681,
"narHash": "sha256-NFHmA7H47adqiyp+0iEOyZOQhmigDqA/NBAlf4imB6U=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "5efb5a6f4f5ab192817d28557dd4d650fa14d866",
"rev": "420f8d2e9882911f65cfac15cc706f639ba96cca",
"type": "github"
},
"original": {
@@ -215,21 +155,6 @@
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
+48 -22
View File
@@ -21,23 +21,18 @@
nixos-hardware.url = "github:nixos/nixos-hardware/master";
jellyswarrm = {
url = "github:LLukas22/Jellyswarrm";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
@@ -100,18 +95,49 @@
}
);
nixosConfigurations =
let
hosts = builtins.attrNames (
lib.filterAttrs (_: type: type == "directory") (builtins.readDir ./systems)
);
mkHost =
name:
lib.nixosSystem {
modules = [ ./systems/${name} ];
specialArgs = { inherit inputs outputs; };
};
in
lib.genAttrs hosts mkHost;
nixosConfigurations = {
bob = lib.nixosSystem {
modules = [
./systems/bob
];
specialArgs = { inherit inputs outputs; };
};
brain = lib.nixosSystem {
modules = [
./systems/brain
];
specialArgs = { inherit inputs outputs; };
};
jeeves = lib.nixosSystem {
modules = [
./systems/jeeves
];
specialArgs = { inherit inputs outputs; };
};
rhapsody-in-green = lib.nixosSystem {
modules = [
./systems/rhapsody-in-green
];
specialArgs = { inherit inputs outputs; };
};
leviathan = lib.nixosSystem {
modules = [
./systems/leviathan
];
specialArgs = { inherit inputs outputs; };
};
tortoise = lib.nixosSystem {
modules = [
./systems/tortoise
];
specialArgs = { inherit inputs outputs; };
};
iso = lib.nixosSystem {
modules = [
./systems/iso
];
specialArgs = { inherit inputs outputs; };
};
};
};
}
+61 -9
View File
@@ -1,13 +1,13 @@
{ inputs, ... }:
{
# Additional package sets are accessible through `pkgs.<name>`.
# When applied, the stable nixpkgs set (declared in the flake inputs) will be accessible through 'pkgs.stable'
stable = final: _prev: {
stable = import inputs.nixpkgs-stable {
system = final.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
};
# When applied, the master nixpkgs set (declared in the flake inputs) will be accessible through 'pkgs.master'
master = final: _prev: {
master = import inputs.nixpkgs-master {
system = final.stdenv.hostPlatform.system;
@@ -15,12 +15,64 @@
};
};
# Baseline x86-64 (v1) packages for prebuilt applications that should not
# inherit an x86-64-v3 host platform.
x86-v1 = final: _prev: {
x86-v1 = import inputs.nixpkgs {
system = final.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
python-env = final: _prev: {
my_python = final.python314.withPackages (
ps:
let
bm25s = ps.buildPythonPackage rec {
pname = "bm25s";
version = "0.3.9";
pyproject = true;
src = final.fetchPypi {
inherit pname version;
hash = "sha256-iVxnnZUrfeg1XttfPhpiCh4vKU0dQrkZvwghzOLi9Zc=";
};
build-system = [ ps.setuptools ];
dependencies = with ps; [
numpy
scipy
];
pythonImportsCheck = [ "bm25s" ];
};
in
with ps;
[
alembic
apprise
apscheduler
beautifulsoup4
ebooklib
fastapi
fastapi-cli
httpx
mypy
numpy
orjson
pgvector
polars
psycopg
pydantic
pydantic-settings
pyfakefs
pytest
pytest-cov
pytest-mock
pytest-xdist
python-multipart
ruff
scalene
sqlalchemy
bm25s
tenacity
textual
tiktoken
tinytuya
typer
websockets
]
);
};
}
+36 -3
View File
@@ -6,6 +6,41 @@ authors = [{ name = "Richie Cahill", email = "richie@tmmworkshop.com" }]
requires-python = "~=3.14.0"
readme = "README.md"
license = "MIT"
# these dependencies are a best effort and aren't guaranteed to work
# for up-to-date dependencies, see overlays/default.nix
dependencies = [
"alembic",
"apprise",
"apscheduler",
"fastapi",
"fastapi-cli",
"httpx",
"polars",
"psycopg[binary]",
"pydantic",
"pydantic-settings",
"python-multipart",
"sqlalchemy",
"tenacity",
"tinytuya",
"typer",
"websockets",
]
[project.scripts]
database = "python.database_cli:app"
whisper-transcribe = "python.tools.whisper.transcribe:main"
[dependency-groups]
dev = [
"mypy",
"pyfakefs",
"pytest-cov",
"pytest-mock",
"pytest-xdist",
"pytest",
"ruff",
]
[tool.ruff]
@@ -20,7 +55,6 @@ lint.ignore = [
"ISC001", # (TEMP) conflicts when used with the formatter
"S603", # (PERM) This is known to cause a false positive
"S607", # (PERM) This is becoming a consistent annoyance
"CPY001", # (PERM) I don't include the license in every file
]
[tool.ruff.lint.per-file-ignores]
@@ -75,7 +109,6 @@ exclude_lines = [
]
[tool.pytest.ini_options]
addopts = "-n auto -ra --ignore=tests/ebook_search"
asyncio_mode = "auto"
addopts = "-n auto -ra"
testpaths = ["tests"]
# --cov=system_tools --cov-report=term-missing --cov-report=xml --cov-report=html --cov-branch
@@ -1,55 +0,0 @@
"""remove spaCy-ner.
Revision ID: 751260fc3228
Revises: dddee09eddcc
Create Date: 2026-07-09 23:03:39.554083
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from python.orm import RichieBase
if TYPE_CHECKING:
from collections.abc import Sequence
# revision identifiers, used by Alembic.
revision: str = "751260fc3228"
down_revision: str | None = "dddee09eddcc"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = RichieBase.schema_name
def upgrade() -> None:
"""Upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("candidate_phrases", "source_spacy_noun_chunk", schema=schema)
op.drop_column("candidate_phrases", "source_spacy_ner", schema=schema)
op.drop_column("candidate_phrases", "spacy_label", schema=schema)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"candidate_phrases", sa.Column("spacy_label", sa.VARCHAR(), autoincrement=False, nullable=True), schema=schema
)
op.add_column(
"candidate_phrases",
sa.Column("source_spacy_ner", sa.BOOLEAN(), autoincrement=False, nullable=False),
schema=schema,
)
op.add_column(
"candidate_phrases",
sa.Column("source_spacy_noun_chunk", sa.BOOLEAN(), autoincrement=False, nullable=False),
schema=schema,
)
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
"""FastAPI applications."""
+56
View File
@@ -0,0 +1,56 @@
"""FastAPI interface for Contact database."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Annotated
import typer
import uvicorn
from fastapi import FastAPI
from python.api.routers import contact_router, views_router
from python.common import configure_logger
from python.fastapi_tools import ZstdMiddleware
from python.orm.common import get_postgres_engine
if TYPE_CHECKING:
from collections.abc import AsyncIterator
logger = logging.getLogger(__name__)
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Manage application lifespan."""
app.state.engine = get_postgres_engine()
yield
app.state.engine.dispose()
app = FastAPI(title="Contact Database API", lifespan=lifespan)
app.add_middleware(ZstdMiddleware)
app.include_router(contact_router)
app.include_router(views_router)
return app
def serve(
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")],
port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8000,
log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO",
) -> None:
"""Start the Contact API server."""
configure_logger(log_level)
app = create_app()
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
typer.run(serve)
+6
View File
@@ -0,0 +1,6 @@
"""API routers."""
from python.api.routers.contact import router as contact_router
from python.api.routers.views import router as views_router
__all__ = ["contact_router", "views_router"]
+481
View File
@@ -0,0 +1,481 @@
"""Contact API router."""
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from python.fastapi_tools.db import DbSession # noqa: TC001 this is a FastAPI needed at runtime
from python.orm.richie.contact import Contact, ContactRelationship, Need, RelationshipType
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=TEMPLATES_DIR)
def _is_htmx(request: Request) -> bool:
"""Check if the request is from HTMX."""
return request.headers.get("HX-Request") == "true"
class NeedBase(BaseModel):
"""Base schema for Need."""
name: str
description: str | None = None
class NeedCreate(NeedBase):
"""Schema for creating a Need."""
class NeedResponse(NeedBase):
"""Schema for Need response."""
id: int
model_config = {"from_attributes": True}
class ContactRelationshipCreate(BaseModel):
"""Schema for creating a contact relationship."""
related_contact_id: int
relationship_type: RelationshipType
closeness_weight: int | None = None
class ContactRelationshipUpdate(BaseModel):
"""Schema for updating a contact relationship."""
relationship_type: RelationshipType | None = None
closeness_weight: int | None = None
class ContactRelationshipResponse(BaseModel):
"""Schema for contact relationship response."""
contact_id: int
related_contact_id: int
relationship_type: str
closeness_weight: int
model_config = {"from_attributes": True}
class RelationshipTypeInfo(BaseModel):
"""Information about a relationship type."""
value: str
display_name: str
default_weight: int
class GraphNode(BaseModel):
"""Node in the relationship graph."""
id: int
name: str
current_job: str | None = None
class GraphEdge(BaseModel):
"""Edge in the relationship graph."""
source: int
target: int
relationship_type: str
closeness_weight: int
class GraphData(BaseModel):
"""Complete graph data for visualization."""
nodes: list[GraphNode]
edges: list[GraphEdge]
class ContactBase(BaseModel):
"""Base schema for Contact."""
name: str
age: int | None = None
bio: str | None = None
current_job: str | None = None
gender: str | None = None
goals: str | None = None
legal_name: str | None = None
profile_pic: str | None = None
safe_conversation_starters: str | None = None
self_sufficiency_score: int | None = None
social_structure_style: str | None = None
ssn: str | None = None
suffix: str | None = None
timezone: str | None = None
topics_to_avoid: str | None = None
class ContactCreate(ContactBase):
"""Schema for creating a Contact."""
need_ids: list[int] = []
class ContactUpdate(BaseModel):
"""Schema for updating a Contact."""
name: str | None = None
age: int | None = None
bio: str | None = None
current_job: str | None = None
gender: str | None = None
goals: str | None = None
legal_name: str | None = None
profile_pic: str | None = None
safe_conversation_starters: str | None = None
self_sufficiency_score: int | None = None
social_structure_style: str | None = None
ssn: str | None = None
suffix: str | None = None
timezone: str | None = None
topics_to_avoid: str | None = None
need_ids: list[int] | None = None
class ContactResponse(ContactBase):
"""Schema for Contact response with relationships."""
id: int
needs: list[NeedResponse] = []
related_to: list[ContactRelationshipResponse] = []
related_from: list[ContactRelationshipResponse] = []
model_config = {"from_attributes": True}
class ContactListResponse(ContactBase):
"""Schema for Contact list response."""
id: int
model_config = {"from_attributes": True}
router = APIRouter(prefix="/api", tags=["contacts"])
@router.post("/needs", response_model=NeedResponse)
def create_need(need: NeedCreate, db: DbSession) -> Need:
"""Create a new need."""
db_need = Need(name=need.name, description=need.description)
db.add(db_need)
db.commit()
db.refresh(db_need)
return db_need
@router.get("/needs", response_model=list[NeedResponse])
def list_needs(db: DbSession) -> list[Need]:
"""List all needs."""
return list(db.scalars(select(Need)).all())
@router.get("/needs/{need_id}", response_model=NeedResponse)
def get_need(need_id: int, db: DbSession) -> Need:
"""Get a need by ID."""
need = db.get(Need, need_id)
if not need:
raise HTTPException(status_code=404, detail="Need not found")
return need
@router.delete("/needs/{need_id}", response_model=None)
def delete_need(need_id: int, request: Request, db: DbSession) -> dict[str, bool] | HTMLResponse:
"""Delete a need by ID."""
need = db.get(Need, need_id)
if not need:
raise HTTPException(status_code=404, detail="Need not found")
db.delete(need)
db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"deleted": True}
@router.post("/contacts", response_model=ContactResponse)
def create_contact(contact: ContactCreate, db: DbSession) -> Contact:
"""Create a new contact."""
need_ids = contact.need_ids
contact_data = contact.model_dump(exclude={"need_ids"})
db_contact = Contact(**contact_data)
if need_ids:
needs = list(db.scalars(select(Need).where(Need.id.in_(need_ids))).all())
db_contact.needs = needs
db.add(db_contact)
db.commit()
db.refresh(db_contact)
return db_contact
@router.get("/contacts", response_model=list[ContactListResponse])
def list_contacts(
db: DbSession,
skip: int = 0,
limit: int = 100,
) -> list[Contact]:
"""List all contacts with pagination."""
return list(db.scalars(select(Contact).offset(skip).limit(limit)).all())
@router.get("/contacts/{contact_id}", response_model=ContactResponse)
def get_contact(contact_id: int, db: DbSession) -> Contact:
"""Get a contact by ID with all relationships."""
contact = db.scalar(
select(Contact)
.where(Contact.id == contact_id)
.options(
selectinload(Contact.needs),
selectinload(Contact.related_to),
selectinload(Contact.related_from),
)
)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
return contact
@router.patch("/contacts/{contact_id}", response_model=ContactResponse)
def update_contact(
contact_id: int,
contact: ContactUpdate,
db: DbSession,
) -> Contact:
"""Update a contact by ID."""
db_contact = db.get(Contact, contact_id)
if not db_contact:
raise HTTPException(status_code=404, detail="Contact not found")
update_data = contact.model_dump(exclude_unset=True)
need_ids = update_data.pop("need_ids", None)
for key, value in update_data.items():
setattr(db_contact, key, value)
if need_ids is not None:
needs = list(db.scalars(select(Need).where(Need.id.in_(need_ids))).all())
db_contact.needs = needs
db.commit()
db.refresh(db_contact)
return db_contact
@router.delete("/contacts/{contact_id}", response_model=None)
def delete_contact(contact_id: int, request: Request, db: DbSession) -> dict[str, bool] | HTMLResponse:
"""Delete a contact by ID."""
contact = db.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
db.delete(contact)
db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"deleted": True}
@router.post("/contacts/{contact_id}/needs/{need_id}")
def add_need_to_contact(
contact_id: int,
need_id: int,
db: DbSession,
) -> dict[str, bool]:
"""Add a need to a contact."""
contact = db.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
need = db.get(Need, need_id)
if not need:
raise HTTPException(status_code=404, detail="Need not found")
if need not in contact.needs:
contact.needs.append(need)
db.commit()
return {"added": True}
@router.delete("/contacts/{contact_id}/needs/{need_id}", response_model=None)
def remove_need_from_contact(
contact_id: int,
need_id: int,
request: Request,
db: DbSession,
) -> dict[str, bool] | HTMLResponse:
"""Remove a need from a contact."""
contact = db.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
need = db.get(Need, need_id)
if not need:
raise HTTPException(status_code=404, detail="Need not found")
if need in contact.needs:
contact.needs.remove(need)
db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"removed": True}
@router.post(
"/contacts/{contact_id}/relationships",
response_model=ContactRelationshipResponse,
)
def add_contact_relationship(
contact_id: int,
relationship: ContactRelationshipCreate,
db: DbSession,
) -> ContactRelationship:
"""Add a relationship between two contacts."""
contact = db.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
related_contact = db.get(Contact, relationship.related_contact_id)
if not related_contact:
raise HTTPException(status_code=404, detail="Related contact not found")
if contact_id == relationship.related_contact_id:
raise HTTPException(status_code=400, detail="Cannot relate contact to itself")
# Use provided weight or default from relationship type
weight = relationship.closeness_weight
if weight is None:
weight = relationship.relationship_type.default_weight
db_relationship = ContactRelationship(
contact_id=contact_id,
related_contact_id=relationship.related_contact_id,
relationship_type=relationship.relationship_type.value,
closeness_weight=weight,
)
db.add(db_relationship)
db.commit()
db.refresh(db_relationship)
return db_relationship
@router.get(
"/contacts/{contact_id}/relationships",
response_model=list[ContactRelationshipResponse],
)
def get_contact_relationships(
contact_id: int,
db: DbSession,
) -> list[ContactRelationship]:
"""Get all relationships for a contact."""
contact = db.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
outgoing = list(db.scalars(select(ContactRelationship).where(ContactRelationship.contact_id == contact_id)).all())
incoming = list(
db.scalars(select(ContactRelationship).where(ContactRelationship.related_contact_id == contact_id)).all()
)
return outgoing + incoming
@router.patch(
"/contacts/{contact_id}/relationships/{related_contact_id}",
response_model=ContactRelationshipResponse,
)
def update_contact_relationship(
contact_id: int,
related_contact_id: int,
update: ContactRelationshipUpdate,
db: DbSession,
) -> ContactRelationship:
"""Update a relationship between two contacts."""
relationship = db.scalar(
select(ContactRelationship).where(
ContactRelationship.contact_id == contact_id,
ContactRelationship.related_contact_id == related_contact_id,
)
)
if not relationship:
raise HTTPException(status_code=404, detail="Relationship not found")
if update.relationship_type is not None:
relationship.relationship_type = update.relationship_type.value
if update.closeness_weight is not None:
relationship.closeness_weight = update.closeness_weight
db.commit()
db.refresh(relationship)
return relationship
@router.delete("/contacts/{contact_id}/relationships/{related_contact_id}", response_model=None)
def remove_contact_relationship(
contact_id: int,
related_contact_id: int,
request: Request,
db: DbSession,
) -> dict[str, bool] | HTMLResponse:
"""Remove a relationship between two contacts."""
relationship = db.scalar(
select(ContactRelationship).where(
ContactRelationship.contact_id == contact_id,
ContactRelationship.related_contact_id == related_contact_id,
)
)
if not relationship:
raise HTTPException(status_code=404, detail="Relationship not found")
db.delete(relationship)
db.commit()
if _is_htmx(request):
return HTMLResponse("")
return {"deleted": True}
@router.get("/relationship-types")
def list_relationship_types() -> list[RelationshipTypeInfo]:
"""List all available relationship types with their default weights."""
return [
RelationshipTypeInfo(
value=rt.value,
display_name=rt.display_name,
default_weight=rt.default_weight,
)
for rt in RelationshipType
]
@router.get("/graph")
def get_relationship_graph(db: DbSession) -> GraphData:
"""Get all contacts and relationships as graph data for visualization."""
contacts = list(db.scalars(select(Contact)).all())
relationships = list(db.scalars(select(ContactRelationship)).all())
nodes = [GraphNode(id=c.id, name=c.name, current_job=c.current_job) for c in contacts]
edges = [
GraphEdge(
source=rel.contact_id,
target=rel.related_contact_id,
relationship_type=rel.relationship_type,
closeness_weight=rel.closeness_weight,
)
for rel in relationships
]
return GraphData(nodes=nodes, edges=edges)
+345
View File
@@ -0,0 +1,345 @@
"""HTMX server-rendered view router."""
from pathlib import Path
from typing import Annotated, Any
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from python.fastapi_tools.db import DbSession # noqa: TC001 this is a FastAPI needed at runtime
from python.orm.richie.contact import Contact, ContactRelationship, Need, RelationshipType
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=TEMPLATES_DIR)
router = APIRouter(tags=["views"])
FAMILIAL_TYPES = {
"parent",
"child",
"sibling",
"grandparent",
"grandchild",
"aunt_uncle",
"niece_nephew",
"cousin",
"in_law",
}
FRIEND_TYPES = {"best_friend", "close_friend", "friend", "acquaintance", "neighbor"}
PARTNER_TYPES = {"spouse", "partner"}
PROFESSIONAL_TYPES = {"mentor", "mentee", "business_partner", "colleague", "manager", "direct_report", "client"}
CONTACT_STRING_FIELDS = (
"name",
"legal_name",
"suffix",
"gender",
"current_job",
"timezone",
"profile_pic",
"bio",
"goals",
"social_structure_style",
"safe_conversation_starters",
"topics_to_avoid",
"ssn",
)
CONTACT_INT_FIELDS = ("age", "self_sufficiency_score")
def _group_relationships(relationships: list[ContactRelationship]) -> dict[str, list[ContactRelationship]]:
"""Group relationships by category."""
groups: dict[str, list[ContactRelationship]] = {
"familial": [],
"partners": [],
"friends": [],
"professional": [],
"other": [],
}
for rel in relationships:
if rel.relationship_type in FAMILIAL_TYPES:
groups["familial"].append(rel)
elif rel.relationship_type in PARTNER_TYPES:
groups["partners"].append(rel)
elif rel.relationship_type in FRIEND_TYPES:
groups["friends"].append(rel)
elif rel.relationship_type in PROFESSIONAL_TYPES:
groups["professional"].append(rel)
else:
groups["other"].append(rel)
return groups
def _build_contact_name_map(database: Session, contact: Contact) -> dict[int, str]:
"""Build a mapping of contact IDs to names for relationship display."""
related_ids = {rel.related_contact_id for rel in contact.related_to}
related_ids |= {rel.contact_id for rel in contact.related_from}
related_ids.discard(contact.id)
if not related_ids:
return {}
related_contacts = list(database.scalars(select(Contact).where(Contact.id.in_(related_ids))).all())
return {related.id: related.name for related in related_contacts}
def _get_relationship_type_display() -> dict[str, str]:
"""Build a mapping of relationship type values to display names."""
return {rel_type.value: rel_type.display_name for rel_type in RelationshipType}
async def _parse_contact_form(request: Request) -> dict[str, Any]:
"""Parse contact form data from a multipart/form request."""
form_data = await request.form()
result: dict[str, Any] = {}
for field in CONTACT_STRING_FIELDS:
value = form_data.get(field, "")
result[field] = str(value) if value else None
for field in CONTACT_INT_FIELDS:
value = form_data.get(field, "")
result[field] = int(value) if value else None
result["need_ids"] = [int(value) for value in form_data.getlist("need_ids")]
return result
def _save_contact_from_form(database: Session, contact: Contact, form_result: dict[str, Any]) -> None:
"""Apply parsed form data to a Contact and save associated needs."""
need_ids = form_result.pop("need_ids")
for key, value in form_result.items():
setattr(contact, key, value)
if need_ids:
contact.needs = list(database.scalars(select(Need).where(Need.id.in_(need_ids))).all())
else:
contact.needs = []
@router.get("/", response_class=HTMLResponse)
@router.get("/contacts", response_class=HTMLResponse)
def contact_list_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the contacts list page."""
contacts = list(database.scalars(select(Contact)).all())
return templates.TemplateResponse(request, "contact_list.html", {"contacts": contacts})
@router.get("/contacts/new", response_class=HTMLResponse)
def new_contact_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the new contact form page."""
all_needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "contact_form.html", {"contact": None, "all_needs": all_needs})
@router.post("/htmx/contacts/new")
async def create_contact_form(request: Request, database: DbSession) -> RedirectResponse:
"""Handle the create contact form submission."""
form_result = await _parse_contact_form(request)
contact = Contact()
_save_contact_from_form(database, contact, form_result)
database.add(contact)
database.commit()
database.refresh(contact)
return RedirectResponse(url=f"/contacts/{contact.id}", status_code=303)
@router.get("/contacts/{contact_id}", response_class=HTMLResponse)
def contact_detail_page(contact_id: int, request: Request, database: DbSession) -> HTMLResponse:
"""Render the contact detail page."""
contact = database.scalar(
select(Contact)
.where(Contact.id == contact_id)
.options(
selectinload(Contact.needs),
selectinload(Contact.related_to),
selectinload(Contact.related_from),
)
)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
contact_names = _build_contact_name_map(database, contact)
grouped_relationships = _group_relationships(contact.related_to)
all_contacts = list(database.scalars(select(Contact)).all())
all_needs = list(database.scalars(select(Need)).all())
available_needs = [need for need in all_needs if need not in contact.needs]
return templates.TemplateResponse(
request,
"contact_detail.html",
{
"contact": contact,
"contact_names": contact_names,
"grouped_relationships": grouped_relationships,
"all_contacts": all_contacts,
"available_needs": available_needs,
"relationship_types": list(RelationshipType),
},
)
@router.get("/contacts/{contact_id}/edit", response_class=HTMLResponse)
def edit_contact_page(contact_id: int, request: Request, database: DbSession) -> HTMLResponse:
"""Render the edit contact form page."""
contact = database.scalar(select(Contact).where(Contact.id == contact_id).options(selectinload(Contact.needs)))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
all_needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "contact_form.html", {"contact": contact, "all_needs": all_needs})
@router.post("/htmx/contacts/{contact_id}/edit")
async def update_contact_form(contact_id: int, request: Request, database: DbSession) -> RedirectResponse:
"""Handle the edit contact form submission."""
contact = database.get(Contact, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
form_result = await _parse_contact_form(request)
_save_contact_from_form(database, contact, form_result)
database.commit()
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
@router.post("/htmx/contacts/{contact_id}/add-need", response_class=HTMLResponse)
def add_need_to_contact_htmx(
contact_id: int,
request: Request,
database: DbSession,
need_id: Annotated[int, Form()],
) -> HTMLResponse:
"""Add a need to a contact and return updated manage-needs partial."""
contact = database.scalar(select(Contact).where(Contact.id == contact_id).options(selectinload(Contact.needs)))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
need = database.get(Need, need_id)
if not need:
raise HTTPException(status_code=404, detail="Need not found")
if need not in contact.needs:
contact.needs.append(need)
database.commit()
database.refresh(contact)
return templates.TemplateResponse(request, "partials/manage_needs.html", {"contact": contact})
@router.post("/htmx/contacts/{contact_id}/add-relationship", response_class=HTMLResponse)
def add_relationship_htmx(
contact_id: int,
request: Request,
database: DbSession,
related_contact_id: Annotated[int, Form()],
relationship_type: Annotated[str, Form()],
) -> HTMLResponse:
"""Add a relationship and return updated manage-relationships partial."""
contact = database.scalar(select(Contact).where(Contact.id == contact_id).options(selectinload(Contact.related_to)))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
related_contact = database.get(Contact, related_contact_id)
if not related_contact:
raise HTTPException(status_code=404, detail="Related contact not found")
rel_type = RelationshipType(relationship_type)
weight = rel_type.default_weight
relationship = ContactRelationship(
contact_id=contact_id,
related_contact_id=related_contact_id,
relationship_type=relationship_type,
closeness_weight=weight,
)
database.add(relationship)
database.commit()
database.refresh(contact)
contact_names = _build_contact_name_map(database, contact)
return templates.TemplateResponse(
request,
"partials/manage_relationships.html",
{"contact": contact, "contact_names": contact_names},
)
@router.post("/htmx/contacts/{contact_id}/relationships/{related_contact_id}/weight")
def update_relationship_weight_htmx(
contact_id: int,
related_contact_id: int,
database: DbSession,
closeness_weight: Annotated[int, Form()],
) -> HTMLResponse:
"""Update a relationship's closeness weight from HTMX range input."""
relationship = database.scalar(
select(ContactRelationship).where(
ContactRelationship.contact_id == contact_id,
ContactRelationship.related_contact_id == related_contact_id,
)
)
if not relationship:
raise HTTPException(status_code=404, detail="Relationship not found")
relationship.closeness_weight = closeness_weight
database.commit()
return HTMLResponse("")
@router.post("/htmx/needs", response_class=HTMLResponse)
def create_need_htmx(
request: Request,
database: DbSession,
name: Annotated[str, Form()],
description: Annotated[str, Form()] = "",
) -> HTMLResponse:
"""Create a need via form data and return updated needs list."""
need = Need(name=name, description=description or None)
database.add(need)
database.commit()
needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "partials/need_items.html", {"needs": needs})
@router.get("/needs", response_class=HTMLResponse)
def needs_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the needs list page."""
needs = list(database.scalars(select(Need)).all())
return templates.TemplateResponse(request, "need_list.html", {"needs": needs})
@router.get("/graph", response_class=HTMLResponse)
def graph_page(request: Request, database: DbSession) -> HTMLResponse:
"""Render the relationship graph page."""
contacts = list(database.scalars(select(Contact)).all())
relationships = list(database.scalars(select(ContactRelationship)).all())
graph_data = {
"nodes": [{"id": contact.id, "name": contact.name, "current_job": contact.current_job} for contact in contacts],
"edges": [
{
"source": rel.contact_id,
"target": rel.related_contact_id,
"relationship_type": rel.relationship_type,
"closeness_weight": rel.closeness_weight,
}
for rel in relationships
],
}
return templates.TemplateResponse(
request,
"graph.html",
{
"graph_data": graph_data,
"relationship_type_display": _get_relationship_type_display(),
},
)
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Contact Database{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
:root {
--color-bg: #f5f5f5;
--color-bg-card: #ffffff;
--color-bg-hover: #f0f0f0;
--color-bg-muted: #f9f9f9;
--color-bg-error: #ffe0e0;
--color-text: #333333;
--color-text-muted: #666666;
--color-text-error: #cc0000;
--color-border: #dddddd;
--color-border-light: #eeeeee;
--color-border-lighter: #f0f0f0;
--color-primary: #0066cc;
--color-primary-hover: #0055aa;
--color-danger: #cc3333;
--color-danger-hover: #aa2222;
--color-tag-bg: #e0e0e0;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
}
[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-bg-card: #2d2d2d;
--color-bg-hover: #3d3d3d;
--color-bg-muted: #252525;
--color-bg-error: #4a2020;
--color-text: #e0e0e0;
--color-text-muted: #a0a0a0;
--color-text-error: #ff6b6b;
--color-border: #404040;
--color-border-light: #353535;
--color-border-lighter: #303030;
--color-primary: #4da6ff;
--color-primary-hover: #7dbfff;
--color-danger: #ff6b6b;
--color-danger-hover: #ff8a8a;
--color-tag-bg: #404040;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--color-bg); color: var(--color-text); }
.app { max-width: 1000px; margin: 0 auto; padding: 20px; }
nav { display: flex; align-items: center; gap: 20px; padding: 15px 0; border-bottom: 1px solid var(--color-border); margin-bottom: 20px; }
nav a { color: var(--color-primary); text-decoration: none; font-weight: 500; }
nav a:hover { text-decoration: underline; }
.theme-toggle { margin-left: auto; }
main { background: var(--color-bg-card); padding: 20px; border-radius: 8px; box-shadow: var(--shadow); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.header h1 { margin: 0; }
a { color: var(--color-primary); }
a:hover { text-decoration: underline; }
.btn { display: inline-block; padding: 8px 16px; border: 1px solid var(--color-border); border-radius: 4px; background: var(--color-bg-card); color: var(--color-text); text-decoration: none; cursor: pointer; font-size: 14px; margin-left: 8px; }
.btn:hover { background: var(--color-bg-hover); }
.btn-primary { background: var(--color-primary); border-color: var(--color-primary); color: white; }
.btn-primary:hover { background: var(--color-primary-hover); }
.btn-danger { background: var(--color-danger); border-color: var(--color-danger); color: white; }
.btn-danger:hover { background: var(--color-danger-hover); }
.btn-small { padding: 4px 8px; font-size: 12px; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--color-border-light); }
th { font-weight: 600; background: var(--color-bg-muted); }
tr:hover { background: var(--color-bg-muted); }
.error { background: var(--color-bg-error); color: var(--color-text-error); padding: 10px; border-radius: 4px; margin-bottom: 20px; }
.tag { display: inline-block; background: var(--color-tag-bg); padding: 2px 8px; border-radius: 12px; font-size: 12px; color: var(--color-text-muted); }
.add-form { display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap; }
.add-form select, .add-form input { padding: 8px; border: 1px solid var(--color-border); border-radius: 4px; min-width: 200px; background: var(--color-bg-card); color: var(--color-text); }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; font-weight: 500; margin-bottom: 5px; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid var(--color-border); border-radius: 4px; font-size: 14px; background: var(--color-bg-card); color: var(--color-text); }
.form-group textarea { resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.checkbox-group { display: flex; flex-wrap: wrap; gap: 15px; }
.checkbox-label { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.form-actions { display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid var(--color-border-light); }
.need-form { background: var(--color-bg-muted); padding: 20px; border-radius: 4px; margin-bottom: 20px; }
.need-items { list-style: none; padding: 0; }
.need-items li { display: flex; justify-content: space-between; align-items: flex-start; padding: 15px; border: 1px solid var(--color-border-light); border-radius: 4px; margin-bottom: 10px; }
.need-info p { margin: 5px 0 0; color: var(--color-text-muted); font-size: 14px; }
.graph-container { width: 100%; }
.graph-hint { color: var(--color-text-muted); font-size: 14px; margin-bottom: 15px; }
.selected-info { margin-top: 15px; padding: 15px; background: var(--color-bg-muted); border-radius: 8px; }
.selected-info h3 { margin: 0 0 10px; }
.selected-info p { margin: 5px 0; color: var(--color-text-muted); }
.legend { margin-top: 20px; padding: 15px; background: var(--color-bg-muted); border-radius: 8px; }
.legend h4 { margin: 0 0 10px; font-size: 14px; }
.legend-items { display: flex; flex-wrap: wrap; gap: 15px; }
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); }
.legend-line { width: 30px; border-radius: 2px; }
.id-card { width: 100%; }
.id-card-inner { background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%); background-image: radial-gradient(white 1px, transparent 1px), linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%); background-size: 50px 50px, 100% 100%; color: #fff; border-radius: 12px; padding: 25px; min-height: 500px; position: relative; overflow: hidden; }
.id-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }
.id-card-header-left { flex: 1; }
.id-card-header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.id-card-title { font-size: 2.5rem; font-weight: 700; margin: 0; color: #fff; text-shadow: 2px 2px 4px rgba(0,0,0,0.5); }
.id-profile-pic { width: 80px; height: 80px; border-radius: 8px; object-fit: cover; border: 2px solid rgba(255,255,255,0.3); }
.id-profile-placeholder { width: 80px; height: 80px; border-radius: 8px; background: linear-gradient(135deg, #4ecdc4 0%, #44a8a0 100%); display: flex; align-items: center; justify-content: center; border: 2px solid rgba(255,255,255,0.3); }
.id-profile-placeholder span { font-size: 2rem; font-weight: 700; color: #fff; text-shadow: 1px 1px 2px rgba(0,0,0,0.3); }
.id-card-actions { display: flex; gap: 8px; }
.id-card-actions .btn { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.3); color: #fff; }
.id-card-actions .btn:hover { background: rgba(255,255,255,0.2); }
.id-card-body { display: grid; grid-template-columns: 1fr 1.5fr; gap: 30px; }
.id-card-left { display: flex; flex-direction: column; gap: 8px; }
.id-field { font-size: 1rem; line-height: 1.4; }
.id-field-block { margin-top: 15px; font-size: 0.95rem; line-height: 1.5; }
.id-label { color: #4ecdc4; font-weight: 500; }
.id-card-right { display: flex; flex-direction: column; gap: 20px; }
.id-bio { font-size: 0.9rem; line-height: 1.6; color: #e0e0e0; }
.id-relationships { margin-top: 10px; }
.id-section-title { font-size: 1.5rem; margin: 0 0 15px; color: #fff; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 8px; }
.id-rel-group { margin-bottom: 12px; font-size: 0.9rem; line-height: 1.6; }
.id-rel-label { color: #a0a0a0; }
.id-rel-group a { color: #4ecdc4; text-decoration: none; }
.id-rel-group a:hover { text-decoration: underline; }
.id-rel-type { color: #888; font-size: 0.85em; }
.id-card-warnings { margin-top: 30px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,0.2); display: flex; flex-wrap: wrap; gap: 20px; }
.id-warning { display: flex; align-items: center; gap: 8px; font-size: 0.9rem; color: #ff6b6b; }
.warning-dot { width: 8px; height: 8px; background: #ff6b6b; border-radius: 50%; flex-shrink: 0; }
.warning-desc { color: #ccc; }
.id-card-manage { margin-top: 20px; background: var(--color-bg-muted); border-radius: 8px; padding: 15px; }
.id-card-manage summary { cursor: pointer; font-weight: 600; font-size: 1.1rem; padding: 5px 0; }
.id-card-manage[open] summary { margin-bottom: 15px; border-bottom: 1px solid var(--color-border-light); padding-bottom: 10px; }
.manage-section { margin-bottom: 25px; }
.manage-section h3 { margin: 0 0 15px; font-size: 1rem; }
.manage-relationships { display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px; }
.manage-rel-item { display: flex; align-items: center; gap: 12px; padding: 10px; background: var(--color-bg-card); border-radius: 6px; flex-wrap: wrap; }
.manage-rel-item a { font-weight: 500; min-width: 120px; }
.weight-control { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--color-text-muted); }
.weight-control input[type="range"] { width: 80px; cursor: pointer; }
.weight-value { min-width: 20px; text-align: center; font-weight: 600; }
.manage-needs-list { list-style: none; padding: 0; margin: 0 0 15px; }
.manage-needs-list li { display: flex; align-items: center; gap: 12px; padding: 10px; background: var(--color-bg-card); border-radius: 6px; margin-bottom: 8px; }
.manage-needs-list li .btn { margin-left: auto; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
@media (max-width: 768px) {
.id-card-body { grid-template-columns: 1fr; }
.id-card-title { font-size: 1.8rem; }
.id-card-header { flex-direction: column; gap: 15px; }
}
</style>
</head>
<body>
<div class="app">
<nav>
<a href="/contacts">Contacts</a>
<a href="/graph">Graph</a>
<a href="/needs">Needs</a>
<button class="btn btn-small theme-toggle" onclick="toggleTheme()">
<span id="theme-label">Dark</span>
</button>
</nav>
<main id="main-content">
{% block content %}{% endblock %}
</main>
</div>
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
document.getElementById('theme-label').textContent = next === 'light' ? 'Dark' : 'Light';
}
(function() {
const saved = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
document.getElementById('theme-label').textContent = saved === 'light' ? 'Dark' : 'Light';
})();
</script>
</body>
</html>
+204
View File
@@ -0,0 +1,204 @@
{% extends "base.html" %}
{% block title %}{{ contact.name }}{% endblock %}
{% block content %}
<div class="id-card">
<div class="id-card-inner">
<div class="id-card-header">
<div class="id-card-header-left">
<h1 class="id-card-title">I.D.: {{ contact.name }}</h1>
</div>
<div class="id-card-header-right">
{% if contact.profile_pic %}
<img src="{{ contact.profile_pic }}" alt="{{ contact.name }}'s profile" class="id-profile-pic">
{% else %}
<div class="id-profile-placeholder">
<span>{{ contact.name[0]|upper }}</span>
</div>
{% endif %}
<div class="id-card-actions">
<a href="/contacts/{{ contact.id }}/edit" class="btn btn-small">Edit</a>
<a href="/contacts" class="btn btn-small">Back</a>
</div>
</div>
</div>
<div class="id-card-body">
<div class="id-card-left">
{% if contact.legal_name %}
<div class="id-field">Legal name: {{ contact.legal_name }}</div>
{% endif %}
{% if contact.suffix %}
<div class="id-field">Suffix: {{ contact.suffix }}</div>
{% endif %}
{% if contact.gender %}
<div class="id-field">Gender: {{ contact.gender }}</div>
{% endif %}
{% if contact.age %}
<div class="id-field">Age: {{ contact.age }}</div>
{% endif %}
{% if contact.current_job %}
<div class="id-field">Job: {{ contact.current_job }}</div>
{% endif %}
{% if contact.social_structure_style %}
<div class="id-field">Social style: {{ contact.social_structure_style }}</div>
{% endif %}
{% if contact.self_sufficiency_score is not none %}
<div class="id-field">Self-Sufficiency: {{ contact.self_sufficiency_score }}</div>
{% endif %}
{% if contact.timezone %}
<div class="id-field">Timezone: {{ contact.timezone }}</div>
{% endif %}
{% if contact.safe_conversation_starters %}
<div class="id-field-block">
<span class="id-label">Safe con starters:</span> {{ contact.safe_conversation_starters }}
</div>
{% endif %}
{% if contact.topics_to_avoid %}
<div class="id-field-block">
<span class="id-label">Topics to avoid:</span> {{ contact.topics_to_avoid }}
</div>
{% endif %}
{% if contact.goals %}
<div class="id-field-block">
<span class="id-label">Goals:</span> {{ contact.goals }}
</div>
{% endif %}
</div>
<div class="id-card-right">
{% if contact.bio %}
<div class="id-bio">
<span class="id-label">Bio:</span> {{ contact.bio }}
</div>
{% endif %}
<div class="id-relationships">
<h2 class="id-section-title">Relationships</h2>
{% if grouped_relationships.familial %}
<div class="id-rel-group">
<span class="id-rel-label">Familial:</span>
{% for rel in grouped_relationships.familial %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a><span class="id-rel-type">({{ rel.relationship_type|replace("_", " ")|title }})</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.partners %}
<div class="id-rel-group">
<span class="id-rel-label">Partners:</span>
{% for rel in grouped_relationships.partners %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.friends %}
<div class="id-rel-group">
<span class="id-rel-label">Friends:</span>
{% for rel in grouped_relationships.friends %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.professional %}
<div class="id-rel-group">
<span class="id-rel-label">Professional:</span>
{% for rel in grouped_relationships.professional %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a><span class="id-rel-type">({{ rel.relationship_type|replace("_", " ")|title }})</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if grouped_relationships.other %}
<div class="id-rel-group">
<span class="id-rel-label">Other:</span>
{% for rel in grouped_relationships.other %}
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a><span class="id-rel-type">({{ rel.relationship_type|replace("_", " ")|title }})</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if contact.related_from %}
<div class="id-rel-group">
<span class="id-rel-label">Known by:</span>
{% for rel in contact.related_from %}
<a href="/contacts/{{ rel.contact_id }}">{{ contact_names[rel.contact_id] }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% if contact.needs %}
<div class="id-card-warnings">
{% for need in contact.needs %}
<div class="id-warning">
<span class="warning-dot"></span>
Warning: {{ need.name }}
{% if need.description %}<span class="warning-desc"> - {{ need.description }}</span>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<details class="id-card-manage">
<summary>Manage Contact</summary>
<div class="manage-section">
<h3>Manage Relationships</h3>
<div id="manage-relationships" class="manage-relationships">
{% include "partials/manage_relationships.html" %}
</div>
{% if all_contacts %}
<form hx-post="/htmx/contacts/{{ contact.id }}/add-relationship"
hx-target="#manage-relationships"
hx-swap="innerHTML"
class="add-form">
<select name="related_contact_id" required>
<option value="">Select contact...</option>
{% for other in all_contacts %}
{% if other.id != contact.id %}
<option value="{{ other.id }}">{{ other.name }}</option>
{% endif %}
{% endfor %}
</select>
<select name="relationship_type" required>
<option value="">Select relationship type...</option>
{% for rel_type in relationship_types %}
<option value="{{ rel_type.value }}">{{ rel_type.display_name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Add Relationship</button>
</form>
{% endif %}
</div>
<div class="manage-section">
<h3>Manage Needs/Warnings</h3>
<div id="manage-needs">
{% include "partials/manage_needs.html" %}
</div>
{% if available_needs %}
<form hx-post="/htmx/contacts/{{ contact.id }}/add-need"
hx-target="#manage-needs"
hx-swap="innerHTML"
class="add-form">
<select name="need_id" required>
<option value="">Select a need...</option>
{% for need in available_needs %}
<option value="{{ need.id }}">{{ need.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Add Need</button>
</form>
{% endif %}
</div>
</details>
</div>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}{{ "Edit " + contact.name if contact else "New Contact" }}{% endblock %}
{% block content %}
<div class="contact-form">
<h1>{{ "Edit Contact" if contact else "New Contact" }}</h1>
{% if contact %}
<form method="post" action="/htmx/contacts/{{ contact.id }}/edit">
{% else %}
<form method="post" action="/htmx/contacts/new">
{% endif %}
<div class="form-group">
<label for="name">Name *</label>
<input id="name" name="name" type="text" value="{{ contact.name if contact else '' }}" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="legal_name">Legal Name</label>
<input id="legal_name" name="legal_name" type="text" value="{{ contact.legal_name or '' }}">
</div>
<div class="form-group">
<label for="suffix">Suffix</label>
<input id="suffix" name="suffix" type="text" value="{{ contact.suffix or '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="age">Age</label>
<input id="age" name="age" type="number" value="{{ contact.age if contact and contact.age is not none else '' }}">
</div>
<div class="form-group">
<label for="gender">Gender</label>
<input id="gender" name="gender" type="text" value="{{ contact.gender or '' }}">
</div>
</div>
<div class="form-group">
<label for="current_job">Current Job</label>
<input id="current_job" name="current_job" type="text" value="{{ contact.current_job or '' }}">
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<input id="timezone" name="timezone" type="text" value="{{ contact.timezone or '' }}">
</div>
<div class="form-group">
<label for="profile_pic">Profile Picture URL</label>
<input id="profile_pic" name="profile_pic" type="url" placeholder="https://example.com/photo.jpg" value="{{ contact.profile_pic or '' }}">
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea id="bio" name="bio" rows="3">{{ contact.bio or '' }}</textarea>
</div>
<div class="form-group">
<label for="goals">Goals</label>
<textarea id="goals" name="goals" rows="3">{{ contact.goals or '' }}</textarea>
</div>
<div class="form-group">
<label for="social_structure_style">Social Structure Style</label>
<input id="social_structure_style" name="social_structure_style" type="text" value="{{ contact.social_structure_style or '' }}">
</div>
<div class="form-group">
<label for="self_sufficiency_score">Self-Sufficiency Score (1-10)</label>
<input id="self_sufficiency_score" name="self_sufficiency_score" type="number" min="1" max="10" value="{{ contact.self_sufficiency_score if contact and contact.self_sufficiency_score is not none else '' }}">
</div>
<div class="form-group">
<label for="safe_conversation_starters">Safe Conversation Starters</label>
<textarea id="safe_conversation_starters" name="safe_conversation_starters" rows="2">{{ contact.safe_conversation_starters or '' }}</textarea>
</div>
<div class="form-group">
<label for="topics_to_avoid">Topics to Avoid</label>
<textarea id="topics_to_avoid" name="topics_to_avoid" rows="2">{{ contact.topics_to_avoid or '' }}</textarea>
</div>
<div class="form-group">
<label for="ssn">SSN</label>
<input id="ssn" name="ssn" type="text" value="{{ contact.ssn or '' }}">
</div>
{% if all_needs %}
<div class="form-group">
<label>Needs/Accommodations</label>
<div class="checkbox-group">
{% for need in all_needs %}
<label class="checkbox-label">
<input type="checkbox" name="need_ids" value="{{ need.id }}"
{% if contact and need in contact.needs %}checked{% endif %}>
{{ need.name }}
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save</button>
{% if contact %}
<a href="/contacts/{{ contact.id }}" class="btn">Cancel</a>
{% else %}
<a href="/contacts" class="btn">Cancel</a>
{% endif %}
</div>
</form>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Contacts{% endblock %}
{% block content %}
<div class="contact-list">
<div class="header">
<h1>Contacts</h1>
<a href="/contacts/new" class="btn btn-primary">Add Contact</a>
</div>
<div id="contact-table">
{% include "partials/contact_table.html" %}
</div>
</div>
{% endblock %}
+198
View File
@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% block title %}Relationship Graph{% endblock %}
{% block content %}
<div class="graph-container">
<div class="header">
<h1>Relationship Graph</h1>
</div>
<p class="graph-hint">Drag nodes to reposition. Closer relationships have shorter, darker edges.</p>
<canvas id="graph-canvas" width="900" height="600"
style="border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-bg); cursor: grab;">
</canvas>
<div id="selected-info"></div>
<div class="legend">
<h4>Relationship Closeness (1-10)</h4>
<div class="legend-items">
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 40%); height: 4px; display: inline-block;"></span>
<span>10 - Very Close (Spouse, Partner)</span>
</div>
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 52%); height: 3px; display: inline-block;"></span>
<span>7 - Close (Family, Best Friend)</span>
</div>
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 64%); height: 2px; display: inline-block;"></span>
<span>4 - Moderate (Friend, Colleague)</span>
</div>
<div class="legend-item">
<span class="legend-line" style="background: hsl(220, 70%, 72%); height: 1px; display: inline-block;"></span>
<span>2 - Distant (Acquaintance)</span>
</div>
</div>
</div>
</div>
<script>
(function() {
const RELATIONSHIP_DISPLAY = {{ relationship_type_display|tojson }};
const graphData = {{ graph_data|tojson }};
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
const nodes = graphData.nodes.map(function(node) {
return Object.assign({}, node, {
x: centerX + (Math.random() - 0.5) * 300,
y: centerY + (Math.random() - 0.5) * 300,
vx: 0,
vy: 0
});
});
const nodeMap = new Map(nodes.map(function(node) { return [node.id, node]; }));
const edges = graphData.edges.map(function(edge) {
const sourceNode = nodeMap.get(edge.source);
const targetNode = nodeMap.get(edge.target);
if (!sourceNode || !targetNode) return null;
return Object.assign({}, edge, { sourceNode: sourceNode, targetNode: targetNode });
}).filter(function(edge) { return edge !== null; });
let dragNode = null;
let selectedNode = null;
const repulsion = 5000;
const springStrength = 0.05;
const baseSpringLength = 150;
const damping = 0.9;
const centerPull = 0.01;
function simulate() {
for (const node of nodes) { node.vx = 0; node.vy = 0; }
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsion / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
nodes[i].vx -= fx; nodes[i].vy -= fy;
nodes[j].vx += fx; nodes[j].vy += fy;
}
}
for (const edge of edges) {
const dx = edge.targetNode.x - edge.sourceNode.x;
const dy = edge.targetNode.y - edge.sourceNode.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const normalizedWeight = edge.closeness_weight / 10;
const idealLength = baseSpringLength * (1.5 - normalizedWeight);
const displacement = dist - idealLength;
const force = springStrength * displacement;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
edge.sourceNode.vx += fx; edge.sourceNode.vy += fy;
edge.targetNode.vx -= fx; edge.targetNode.vy -= fy;
}
for (const node of nodes) {
node.vx += (centerX - node.x) * centerPull;
node.vy += (centerY - node.y) * centerPull;
}
for (const node of nodes) {
if (node === dragNode) continue;
node.x += node.vx * damping;
node.y += node.vy * damping;
node.x = Math.max(30, Math.min(width - 30, node.x));
node.y = Math.max(30, Math.min(height - 30, node.y));
}
}
function getEdgeColor(weight) {
const normalized = weight / 10;
return 'hsl(220, 70%, ' + (80 - normalized * 40) + '%)';
}
function draw() {
ctx.clearRect(0, 0, width, height);
for (const edge of edges) {
const lineWidth = 1 + (edge.closeness_weight / 10) * 3;
ctx.strokeStyle = getEdgeColor(edge.closeness_weight);
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y);
ctx.lineTo(edge.targetNode.x, edge.targetNode.y);
ctx.stroke();
const midX = (edge.sourceNode.x + edge.targetNode.x) / 2;
const midY = (edge.sourceNode.y + edge.targetNode.y) / 2;
ctx.fillStyle = '#666';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
const label = RELATIONSHIP_DISPLAY[edge.relationship_type] || edge.relationship_type;
ctx.fillText(label, midX, midY - 5);
}
for (const node of nodes) {
const isSelected = node === selectedNode;
const radius = isSelected ? 25 : 20;
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? '#0066cc' : '#fff';
ctx.fill();
ctx.strokeStyle = '#0066cc';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = isSelected ? '#fff' : '#333';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const name = node.name.length > 10 ? node.name.slice(0, 9) + '\u2026' : node.name;
ctx.fillText(name, node.x, node.y);
}
}
function animate() {
simulate();
draw();
requestAnimationFrame(animate);
}
animate();
function getNodeAt(x, y) {
for (const node of nodes) {
const dx = x - node.x;
const dy = y - node.y;
if (dx * dx + dy * dy < 400) return node;
}
return null;
}
canvas.addEventListener('mousedown', function(event) {
const rect = canvas.getBoundingClientRect();
const node = getNodeAt(event.clientX - rect.left, event.clientY - rect.top);
if (node) {
dragNode = node;
selectedNode = node;
const infoDiv = document.getElementById('selected-info');
let html = '<div class="selected-info"><h3>' + node.name + '</h3>';
if (node.current_job) html += '<p>Job: ' + node.current_job + '</p>';
html += '<a href="/contacts/' + node.id + '">View details</a></div>';
infoDiv.innerHTML = html;
}
});
canvas.addEventListener('mousemove', function(event) {
if (!dragNode) return;
const rect = canvas.getBoundingClientRect();
dragNode.x = event.clientX - rect.left;
dragNode.y = event.clientY - rect.top;
});
canvas.addEventListener('mouseup', function() { dragNode = null; });
canvas.addEventListener('mouseleave', function() { dragNode = null; });
})();
</script>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Needs{% endblock %}
{% block content %}
<div class="need-list">
<div class="header">
<h1>Needs / Accommodations</h1>
<button class="btn btn-primary" onclick="document.getElementById('need-form').toggleAttribute('hidden')">Add Need</button>
</div>
<form id="need-form" hidden
hx-post="/htmx/needs"
hx-target="#need-items"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()"
class="need-form">
<div class="form-group">
<label for="name">Name *</label>
<input id="name" name="name" type="text" placeholder="e.g., Light Sensitive, ADHD" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Optional description..." rows="2"></textarea>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
<div id="need-items">
{% include "partials/need_items.html" %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% if contacts %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Job</th>
<th>Timezone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr id="contact-row-{{ contact.id }}">
<td><a href="/contacts/{{ contact.id }}">{{ contact.name }}</a></td>
<td>{{ contact.current_job or "-" }}</td>
<td>{{ contact.timezone or "-" }}</td>
<td>
<a href="/contacts/{{ contact.id }}/edit" class="btn">Edit</a>
<button class="btn btn-danger"
hx-delete="/api/contacts/{{ contact.id }}"
hx-target="#contact-row-{{ contact.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this contact?">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No contacts yet.</p>
{% endif %}
@@ -0,0 +1,14 @@
<ul class="manage-needs-list">
{% for need in contact.needs %}
<li id="contact-need-{{ need.id }}">
<strong>{{ need.name }}</strong>
{% if need.description %}<span> - {{ need.description }}</span>{% endif %}
<button class="btn btn-small btn-danger"
hx-delete="/api/contacts/{{ contact.id }}/needs/{{ need.id }}"
hx-target="#contact-need-{{ need.id }}"
hx-swap="outerHTML">
Remove
</button>
</li>
{% endfor %}
</ul>
@@ -0,0 +1,23 @@
{% for rel in contact.related_to %}
<div class="manage-rel-item" id="rel-{{ contact.id }}-{{ rel.related_contact_id }}">
<a href="/contacts/{{ rel.related_contact_id }}">{{ contact_names[rel.related_contact_id] }}</a>
<span class="tag">{{ rel.relationship_type|replace("_", " ")|title }}</span>
<label class="weight-control">
<span>Closeness:</span>
<input type="range" min="1" max="10" value="{{ rel.closeness_weight }}"
hx-post="/htmx/contacts/{{ contact.id }}/relationships/{{ rel.related_contact_id }}/weight"
hx-trigger="change"
hx-include="this"
name="closeness_weight"
hx-swap="none"
oninput="this.nextElementSibling.textContent = this.value">
<span class="weight-value">{{ rel.closeness_weight }}</span>
</label>
<button class="btn btn-small btn-danger"
hx-delete="/api/contacts/{{ contact.id }}/relationships/{{ rel.related_contact_id }}"
hx-target="#rel-{{ contact.id }}-{{ rel.related_contact_id }}"
hx-swap="outerHTML">
Remove
</button>
</div>
{% endfor %}
@@ -0,0 +1,21 @@
{% if needs %}
<ul class="need-items">
{% for need in needs %}
<li id="need-item-{{ need.id }}">
<div class="need-info">
<strong>{{ need.name }}</strong>
{% if need.description %}<p>{{ need.description }}</p>{% endif %}
</div>
<button class="btn btn-danger"
hx-delete="/api/needs/{{ need.id }}"
hx-target="#need-item-{{ need.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this need?">
Delete
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p>No needs defined yet.</p>
{% endif %}
+23 -6
View File
@@ -4,19 +4,16 @@ from __future__ import annotations
import logging
from datetime import UTC, datetime
from pathlib import Path
from os import getenv
from subprocess import PIPE, Popen
from apprise import Apprise
from python.logging_config import configure_logger as _configure_logger
logger = logging.getLogger(__name__)
def get_repo_dir() -> Path:
"""Return the repository root directory."""
return Path(__file__).resolve().parents[1]
def configure_logger(level: str = "INFO") -> None:
"""Configure the logger."""
_configure_logger(level)
@@ -42,6 +39,26 @@ def bash_wrapper(command: str) -> tuple[str, int]:
return output.decode(), process.returncode
def signal_alert(body: str, title: str = "") -> None:
"""Send a signal alert.
Args:
body (str): The body of the alert.
title (str, optional): The title of the alert. Defaults to "".
"""
apprise_client = Apprise()
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
if not from_phone or not to_phone:
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
return
apprise_client.add(f"signal://localhost:8989/{from_phone}/{to_phone}")
apprise_client.notify(title=title, body=body)
def utcnow() -> datetime:
"""Get the current UTC time."""
return datetime.now(tz=UTC)
+22 -15
View File
@@ -6,23 +6,15 @@ import logging
from typing import TYPE_CHECKING
from python.ebook_search.llm_interface import request_chat_completion
from python.ebook_search.prompts import load_prompt
if TYPE_CHECKING:
import httpx
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.search import SearchResult
logger = logging.getLogger(__name__)
async def answer_query(
client: httpx.AsyncClient,
query: str,
results: list[SearchResult],
config: EbookSearchConfig,
) -> str:
def answer_query(query: str, results: list[SearchResult], config: EbookSearchConfig) -> str:
"""Answer a question using only retrieved chunks."""
if not config.answer_enabled:
logger.info("ebook_answer_skipped_disabled")
@@ -33,18 +25,33 @@ async def answer_query(
return "No relevant sources were found."
logger.info(
f"ebook_answer_request_start {config.vllm_base_url=} {config.chat_model=} sources={len(results)} "
f"query_length={len(query)}"
"ebook_answer_request_start base_url=%s model=%s sources=%s query_length=%s",
config.vllm_base_url,
config.chat_model,
len(results),
len(query),
)
context = "\n\n".join(
f"[{index}] {result.source_title}{' - ' + result.chapter_title if result.chapter_title else ''}\n{result.text}"
for index, result in enumerate(results, start=1)
)
content = await request_chat_completion(
client,
content = request_chat_completion(
config,
load_prompt("answer").messages(query=query, context=context),
[
{
"role": "system",
"content": (
"Answer only from the provided context. Cite sources with bracketed numbers like [1]. "
"If the context is insufficient, say so."
),
},
{"role": "user", "content": f"Question:\n{query}\n\nContext:\n{context}"},
],
)
logger.info(f"ebook_answer_request_complete {config.chat_model=} answer_length={len(content)}")
logger.info(
"ebook_answer_request_complete model=%s answer_length=%s",
config.chat_model,
len(content),
)
return content or "The model returned an empty answer."
+23 -33
View File
@@ -1,22 +1,18 @@
"""Background BM25 refresh tasks for the web app.
The refresh is scheduled on the event loop instead of a thread because the async psycopg
driver only works from the loop; a bare thread cannot open a session on the async engine.
"""
"""Background BM25 refresh tasks for the web app."""
from __future__ import annotations
import asyncio
import logging
from threading import Timer
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.bm25_corpus import load_bm25_corpus, refresh_bm25_corpus
if TYPE_CHECKING:
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Engine
from python.ebook_search.config import EbookSearchConfig
@@ -24,47 +20,41 @@ logger = logging.getLogger(__name__)
def schedule_bm25_refresh(app: FastAPI) -> None:
"""Schedule a delayed BM25 corpus refresh, replacing any pending refresh.
"""Schedule a delayed BM25 corpus refresh, replacing any pending refresh."""
existing_timer = getattr(app.state, "bm25_refresh_timer", None)
if existing_timer is not None:
existing_timer.cancel()
Only called from route handlers, so a running event loop is guaranteed.
"""
cancel_bm25_refresh(app)
loop = asyncio.get_running_loop()
def start_refresh() -> None:
app.state.bm25_refresh_task = loop.create_task(refresh_bm25_for_app(app))
app.state.bm25_refresh_timer = loop.call_later(app.state.config.bm25_refresh_delay_seconds, start_refresh)
logger.info(f"ebook_bm25_refresh_scheduled {app.state.config.bm25_refresh_delay_seconds=}")
timer = Timer(app.state.config.bm25_refresh_delay_seconds, refresh_bm25_for_app, args=(app,))
timer.daemon = True
timer.start()
app.state.bm25_refresh_timer = timer
logger.info(
"ebook_bm25_refresh_scheduled delay_seconds=%s",
app.state.config.bm25_refresh_delay_seconds,
)
def cancel_bm25_refresh(app: FastAPI) -> None:
"""Cancel any pending BM25 corpus refresh timer and in-flight refresh task."""
"""Cancel any pending BM25 corpus refresh."""
existing_timer = getattr(app.state, "bm25_refresh_timer", None)
if existing_timer is not None:
existing_timer.cancel()
app.state.bm25_refresh_timer = None
logger.info("ebook_bm25_refresh_cancelled")
existing_task = getattr(app.state, "bm25_refresh_task", None)
if existing_task is not None:
if not existing_task.done():
existing_task.cancel()
app.state.bm25_refresh_task = None
async def refresh_bm25_for_app(app: FastAPI) -> None:
def refresh_bm25_for_app(app: FastAPI) -> None:
"""Refresh the BM25 corpus using the app engine and config."""
try:
await refresh_bm25_for_engine(app.state.engine, app.state.config)
refresh_bm25_for_engine(app.state.engine, app.state.config)
except Exception:
logger.exception("ebook_bm25_refresh_failed")
async def refresh_bm25_for_engine(engine: AsyncEngine, config: EbookSearchConfig) -> None:
"""Refresh the BM25 corpus using an async SQLAlchemy engine."""
async with AsyncSession(engine) as session:
await refresh_bm25_corpus(session, config)
def refresh_bm25_for_engine(engine: Engine, config: EbookSearchConfig) -> None:
"""Refresh the BM25 corpus using a SQLAlchemy engine."""
with Session(engine) as session:
refresh_bm25_corpus(session, config)
load_bm25_corpus.cache_clear()
logger.info("ebook_bm25_corpus_cache_cleared_after_refresh")
+5 -5
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from typing import Annotated
import httpx
from fastapi import Depends, Request
from sqlalchemy.engine import Engine
from python.ebook_search.config import EbookSearchConfig
@@ -15,10 +15,10 @@ def get_config(request: Request) -> EbookSearchConfig:
return request.app.state.config
def get_http_client(request: Request) -> httpx.AsyncClient:
"""Get the shared LLM HTTP client from app state."""
return request.app.state.http_client
def get_engine(request: Request) -> Engine:
"""Get the database engine from app state."""
return request.app.state.engine
AppConfig = Annotated[EbookSearchConfig, Depends(get_config)]
AppHttpClient = Annotated[httpx.AsyncClient, Depends(get_http_client)]
AppEngine = Annotated[Engine, Depends(get_engine)]
-127
View File
@@ -1,127 +0,0 @@
"""Background phrase-judging tasks for the web app.
Judging a book sends one LLM request per candidate phrase, which can take minutes, so it must
not run inside the request where it would block the UI. Judgments run as async FastAPI
background tasks, awaited on the event loop after the response is sent, and are tracked per
book in app state so a second judge request for a book that is already being judged is
rejected instead of doubling the work.
State is loop-confined: every read and mutation happens on the event loop (async route
handlers and async background tasks) and no critical section contains an ``await``, so each
mutation is atomic per loop iteration and no locking is needed.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from python.ebook_search.protected_phrases.judge_ngrams import judge_candidate_phrases_for_books
if TYPE_CHECKING:
from fastapi import BackgroundTasks, FastAPI
logger = logging.getLogger(__name__)
@dataclass
class JudgeTaskState:
"""Running book judgments and last outcome messages, keyed by book id."""
running_book_ids: set[int] = field(default_factory=set)
outcome_messages: dict[int, str] = field(default_factory=dict)
def get_judge_task_state(app: FastAPI) -> JudgeTaskState:
"""Return the app's judge task state, creating it on first use.
Args:
app (FastAPI): App whose state holds the judge task registry.
Returns:
JudgeTaskState: The shared judge task state for this app.
"""
state = getattr(app.state, "judge_tasks", None)
if state is None:
state = JudgeTaskState()
app.state.judge_tasks = state
return state
def start_book_phrase_judgment(app: FastAPI, background_tasks: BackgroundTasks, source_id: int) -> bool:
"""Queue judging of one book's candidate phrases as a FastAPI background task.
The book is claimed before the response returns, so a repeated judge request cannot queue
a second run while one is pending or running.
Args:
app (FastAPI): App supplying the engine, config, and judge task state.
background_tasks (BackgroundTasks): Request's background tasks to queue the judgment on.
source_id (int): Book to judge candidates for.
Returns:
bool: True when a judgment was queued, False when one is already running for this book.
"""
state = get_judge_task_state(app)
if source_id in state.running_book_ids:
logger.info(f"ebook_book_phrase_judgment_already_running {source_id=}")
return False
state.running_book_ids.add(source_id)
state.outcome_messages.pop(source_id, None)
background_tasks.add_task(judge_book_phrases_for_app, app, source_id)
logger.info(f"ebook_book_phrase_judgment_queued {source_id=}")
return True
async def judge_book_phrases_for_app(app: FastAPI, source_id: int) -> None:
"""Judge one book using the app engine and config, recording the outcome message.
Args:
app (FastAPI): App supplying the engine, config, and judge task state.
source_id (int): Book to judge candidates for.
"""
state = get_judge_task_state(app)
try:
result = await judge_candidate_phrases_for_books(app.state.engine, app.state.config, source_ids=[source_id])
logger.info(
f"ebook_book_phrase_judgment_complete {source_id=} {result.candidates_judged=} {result.protected_phrases=} "
f"{result.phrase_mentions=} {result.books_failed=}"
)
if result.books_failed:
message = "Judging failed; see server logs for details"
else:
message = (
f"Judged {result.candidates_judged} candidates; {result.protected_phrases} protected phrases promoted"
)
except Exception:
logger.exception(f"ebook_book_phrase_judgment_task_failed {source_id=}")
message = "Judging failed; see server logs for details"
state.running_book_ids.discard(source_id)
state.outcome_messages[source_id] = message
def is_judging_book(app: FastAPI, source_id: int) -> bool:
"""Report whether a judgment is currently queued or running for one book.
Args:
app (FastAPI): App supplying the judge task state.
source_id (int): Book to check.
Returns:
bool: True while the book's judgment is pending or running.
"""
return source_id in get_judge_task_state(app).running_book_ids
def pop_book_judgment_outcome(app: FastAPI, source_id: int) -> str | None:
"""Return and clear the outcome message from one book's last finished judgment.
Args:
app (FastAPI): App supplying the judge task state.
source_id (int): Book to fetch the outcome for.
Returns:
str | None: The outcome message, or None when there is nothing new to report.
"""
return get_judge_task_state(app).outcome_messages.pop(source_id, None)
+15 -20
View File
@@ -6,12 +6,11 @@ import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Annotated
import httpx
import typer
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.common import configure_logger
from python.ebook_search.api.bm25_tasks import cancel_bm25_refresh
@@ -19,9 +18,8 @@ from python.ebook_search.api.routes import admin_router, health_router, page_rou
from python.ebook_search.api.web import STATIC_DIR
from python.ebook_search.bm25_corpus import ensure_bm25_corpus
from python.ebook_search.config import load_config
from python.ebook_search.protected_phrases.pool import shutdown_extraction_pool
from python.fastapi_tools import ZstdMiddleware
from python.orm.common import get_async_postgres_engine
from python.orm.common import get_postgres_engine
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -37,30 +35,27 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
config = load_config()
app.state.config = config
logger.info(
f"ebook_search_config_loaded {config.top_k=} {config.embedding_model=} {config.embedding_base_url=} "
f"{config.vllm_base_url=} {config.rerank.enabled=} {config.phrase_matching_enabled=} {config.answer_enabled=} "
f"library_paths={len(config.library_paths)}"
"ebook_search_config_loaded top_k=%s embedding_model=%s embedding_base_url=%s vllm_base_url=%s "
"rerank_enabled=%s answer_enabled=%s library_paths=%s",
config.top_k,
config.embedding_model,
config.embedding_base_url,
config.vllm_base_url,
config.rerank.enabled,
config.answer_enabled,
len(config.library_paths),
)
if not config.library_paths:
logger.warning("ebook_search_no_library_paths_configured")
# Concurrent phrase judging opens one session per book worker on this engine, so size the pool
# to cover those plus headroom for ordinary web requests.
app.state.engine = get_async_postgres_engine(
name="RICHIE",
vector_engine=True,
pool_size=config.phrase_judge_book_workers + 10,
)
app.state.http_client = httpx.AsyncClient()
async with AsyncSession(app.state.engine, expire_on_commit=False) as session:
await ensure_bm25_corpus(session, config)
app.state.engine = get_postgres_engine(name="RICHIE", vector_engine=True)
with Session(app.state.engine) as session:
ensure_bm25_corpus(session, config)
try:
yield
finally:
logger.info("ebook_search_shutdown")
cancel_bm25_refresh(app)
shutdown_extraction_pool()
await app.state.http_client.aclose()
await app.state.engine.dispose()
app.state.engine.dispose()
def create_app() -> FastAPI:
+39 -140
View File
@@ -8,20 +8,13 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from python.ebook_search.api.bm25_tasks import schedule_bm25_refresh
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppConfig,
AppHttpClient,
from python.ebook_search.api.dependencies import (
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
)
from python.ebook_search.api.web import error_response, templates
from python.ebook_search.api.web import templates
from python.ebook_search.embeddings import embed_missing_chunks, embedding_model_stats
from python.ebook_search.ingest import ingest_configured_paths
from python.ebook_search.protected_phrases.generate_ngrams import generate_candidate_phrases_for_books
from python.ebook_search.protected_phrases.judge_ngrams import judge_candidate_phrases_for_books
from python.ebook_search.protected_phrases.store import book_ids_pending_first_judgment, corpus_phrase_stats
from python.fastapi_tools import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppAsyncEngine,
AsyncDbSession,
)
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
logger = logging.getLogger(__name__)
@@ -29,143 +22,40 @@ router = APIRouter(prefix="/admin")
@router.get("", response_class=HTMLResponse)
async def admin(request: Request, config: AppConfig, session: AsyncDbSession) -> HTMLResponse:
def admin(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Render the admin page."""
stats = await embedding_model_stats(session)
phrase_stats = await corpus_phrase_stats(session)
logger.info(
f"ebook_admin_page_loaded models={len(stats)} {phrase_stats.candidate_phrases=} "
f"{phrase_stats.protected_phrases=}"
)
return templates.TemplateResponse(
request,
"admin.html",
{"config": config, "stats": stats, "phrase_stats": phrase_stats},
)
stats = embedding_model_stats(session)
logger.info("ebook_admin_page_loaded models=%s", len(stats))
return templates.TemplateResponse(request, "admin.html", {"config": config, "stats": stats})
@router.post("/scan", response_class=HTMLResponse)
async def scan_library(request: Request, config: AppConfig, session: AsyncDbSession) -> HTMLResponse:
def scan_library(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Scan configured library paths for EPUB changes."""
try:
count = await ingest_configured_paths(session, config)
await session.commit()
count = ingest_configured_paths(session, config)
session.commit()
except Exception as error:
logger.exception("ebook_admin_scan_failed")
return error_response(request, error)
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
logger.info(f"ebook_admin_scan_complete {count=}")
logger.info("ebook_admin_scan_complete changed_files=%s", count)
if count > 0:
schedule_bm25_refresh(request.app)
return templates.TemplateResponse(request, "partials/admin_status.html", {"message": f"Indexed {count} EPUBs"})
@router.post("/phrases/generate-all", response_class=HTMLResponse)
async def generate_all_phrases(request: Request, config: AppConfig, engine: AppAsyncEngine) -> HTMLResponse:
"""Regenerate candidate phrases for every indexed book without LLM judging."""
try:
result = await generate_candidate_phrases_for_books(engine, config)
except Exception as error:
logger.exception("ebook_admin_generate_phrases_failed")
return error_response(request, error)
logger.info(
f"ebook_admin_generate_phrases_complete {result.books_seen=} {result.books_built=} {result.candidate_phrases=}"
)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
{
"message": (
f"Generated phrases for {result.books_built} of {result.books_seen} books; "
f"{result.candidate_phrases} candidates stored"
)
},
)
@router.post("/phrases/judge-all", response_class=HTMLResponse)
async def judge_all_phrases(request: Request, engine: AppAsyncEngine, config: AppConfig) -> HTMLResponse:
"""Judge unjudged candidate phrases across every indexed book."""
return await run_phrase_judgment(request, engine, config, source_ids=None)
@router.post("/phrases/judge-missing", response_class=HTMLResponse)
async def judge_missing_phrases(
request: Request,
engine: AppAsyncEngine,
config: AppConfig,
session: AsyncDbSession,
) -> HTMLResponse:
"""Judge candidate phrases only for books where judging has never run."""
source_ids = await book_ids_pending_first_judgment(session)
if not source_ids:
return templates.TemplateResponse(
request,
"partials/admin_status.html",
{"message": "All books with candidate phrases have been judged"},
)
return await run_phrase_judgment(request, engine, config, source_ids=source_ids)
async def run_phrase_judgment(
request: Request,
engine: AppAsyncEngine,
config: AppConfig,
*,
source_ids: list[int] | None,
) -> HTMLResponse:
"""Run LLM judging for candidate phrases and render the outcome as an admin status partial.
Args:
request (Request): Current request, for template rendering.
engine (AppAsyncEngine): Engine used to open per-book judging sessions.
config (AppConfig): Runtime phrase-tuning settings.
source_ids (list[int] | None): Books to judge; ``None`` judges every indexed book.
Returns:
HTMLResponse: Status partial describing the judging outcome.
"""
try:
result = await judge_candidate_phrases_for_books(engine, config, source_ids=source_ids)
except Exception as error:
logger.exception("ebook_admin_judge_phrases_failed")
return error_response(request, error)
logger.info(
f"ebook_admin_judge_phrases_complete {result.books_seen=} {result.books_judged=} {result.books_failed=} "
f"{result.candidates_judged=} {result.protected_phrases=} {result.phrase_mentions=}"
)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
{
"message": (
f"Judged {result.candidates_judged} candidates across {result.books_judged} of "
f"{result.books_seen} books; {result.protected_phrases} protected phrases, "
f"{result.phrase_mentions} mentions"
+ (f"; {result.books_failed} books failed" if result.books_failed else "")
)
},
)
@router.post("/embed-missing", response_class=HTMLResponse)
async def embed_missing(
request: Request,
config: AppConfig,
session: AsyncDbSession,
client: AppHttpClient,
) -> HTMLResponse:
def embed_missing(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Embed chunks missing vectors for the configured model."""
try:
count = await embed_missing_chunks(session, client, config)
await session.commit()
count = embed_missing_chunks(session, config)
session.commit()
except Exception as error:
logger.exception("ebook_admin_embed_missing_failed")
return error_response(request, error)
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
logger.info(f"ebook_admin_embed_missing_complete {count=}")
logger.info("ebook_admin_embed_missing_complete chunks=%s", count)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
@@ -174,29 +64,38 @@ async def embed_missing(
@router.post("/embed-all", response_class=HTMLResponse)
async def embed_all(
request: Request,
config: AppConfig,
session: AsyncDbSession,
client: AppHttpClient,
) -> HTMLResponse:
def embed_all(request: Request, config: AppConfig, session: DbSession) -> HTMLResponse:
"""Embed all chunks missing vectors in fixed-size batches."""
total = 0
batches = 0
try:
while True:
count = await embed_missing_chunks(session, client, config)
count = embed_missing_chunks(session, config)
if count == 0:
break
await session.commit()
session.commit()
total += count
batches += 1
logger.info(f"ebook_admin_embed_all_batch_complete {batches=} {count=} {total=}")
logger.info(
"ebook_admin_embed_all_batch_complete batch=%s chunks=%s total_chunks=%s",
batches,
count,
total,
)
except Exception as error:
logger.exception(f"ebook_admin_embed_all_failed {batches=} {total=}")
return error_response(request, f"Embed all failed after {total} chunks in {batches} batches: {error}")
logger.exception(
"ebook_admin_embed_all_failed batches=%s chunks=%s",
batches,
total,
)
return templates.TemplateResponse(
request,
"partials/error.html",
{"message": f"Embed all failed after {total} chunks in {batches} batches: {error}"},
status_code=500,
)
logger.info(f"ebook_admin_embed_all_complete {batches=} {total=}")
logger.info("ebook_admin_embed_all_complete batches=%s chunks=%s", batches, total)
return templates.TemplateResponse(
request,
"partials/admin_status.html",
+22 -17
View File
@@ -11,17 +11,15 @@ from fastapi.responses import JSONResponse
from sqlalchemy import literal, select
from sqlalchemy.exc import SQLAlchemyError
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppConfig,
AppHttpClient,
from python.ebook_search.api.dependencies import (
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
)
from python.ebook_search.bm25_corpus import bm25_index_exists, bm25_index_path, read_bm25_manifest
from python.ebook_search.llm_interface import check_chat_endpoint, check_embedding_endpoint
from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
if TYPE_CHECKING:
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
@@ -31,17 +29,17 @@ router = APIRouter()
@router.get("/health")
async def health() -> dict[str, str]:
def health() -> dict[str, str]:
"""Liveness probe that returns ok without touching dependencies."""
return {"status": "ok"}
@router.get("/ready")
async def ready(config: AppConfig, session: AsyncDbSession, client: AppHttpClient) -> JSONResponse:
def ready(config: AppConfig, session: DbSession) -> JSONResponse:
"""Readiness probe reporting database, embedding endpoint, and BM25 index status."""
database_ok = await check_database(session)
embedding_ok = await check_embedding_endpoint(client, config)
chat_status = await chat_endpoint_status(client, config)
database_ok = check_database(session)
embedding_ok = check_embedding_endpoint(config)
chat_status = chat_endpoint_status(config)
bm25_status = check_bm25_status(config)
checks = {
@@ -60,23 +58,30 @@ async def ready(config: AppConfig, session: AsyncDbSession, client: AppHttpClien
status = "ready"
status_code = HTTPStatus.OK
logger.info(f"ebook_ready_check {status=} {database_ok=} {embedding_ok=} {chat_status=} {bm25_status=}")
logger.info(
"ebook_ready_check status=%s database=%s embedding=%s chat=%s bm25=%s",
status,
database_ok,
embedding_ok,
chat_status,
bm25_status,
)
return JSONResponse(content={"status": status, "checks": checks}, status_code=status_code)
async def chat_endpoint_status(client: httpx.AsyncClient, config: EbookSearchConfig) -> str:
def chat_endpoint_status(config: EbookSearchConfig) -> str:
"""Return the answering chat endpoint status, or disabled when answers are off."""
if not config.answer_enabled:
return "disabled"
return "ok" if await check_chat_endpoint(client, config) else "fail"
return "ok" if check_chat_endpoint(config) else "fail"
async def check_database(session: AsyncSession) -> bool:
def check_database(session: Session) -> bool:
"""Return whether the database answers a trivial query."""
try:
await session.execute(select(literal(1)))
session.execute(select(literal(1)))
except SQLAlchemyError as error:
logger.warning(f"ebook_ready_database_unavailable {error=}")
logger.warning("ebook_ready_database_unavailable error=%s", error)
return False
return True
+19 -148
View File
@@ -3,24 +3,17 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func, select
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from python.ebook_search.api.dependencies import (
AppConfig, # noqa: TC001 FastAPI resolves this annotated dependency at runtime
)
from python.ebook_search.api.judge_tasks import is_judging_book, pop_book_judgment_outcome, start_book_phrase_judgment
from python.ebook_search.api.web import templates
from python.ebook_search.protected_phrases.generate_ngrams import recalculate_candidate_phrases_for_book
from python.ebook_search.protected_phrases.store import count_protected_phrases
from python.fastapi_tools import AsyncDbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.orm.richie import EbookCandidatePhrase, EbookChapter, EbookChunk, EbookProtectedPhrase, EbookSource
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from python.fastapi_tools import DbSession # noqa: TC001 FastAPI resolves this annotated dependency at runtime
from python.orm.richie import EbookSource
logger = logging.getLogger(__name__)
@@ -28,160 +21,38 @@ router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def index(request: Request, config: AppConfig) -> HTMLResponse:
def index(request: Request, config: AppConfig) -> HTMLResponse:
"""Render the search page."""
return templates.TemplateResponse(request, "search.html", {"config": config})
@router.get("/books", response_class=HTMLResponse)
async def books(request: Request, session: AsyncDbSession) -> HTMLResponse:
def books(request: Request, session: DbSession) -> HTMLResponse:
"""Render the indexed books page."""
sources = list((await session.scalars(select(EbookSource).order_by(EbookSource.title))).all())
logger.info(f"ebook_books_page_loaded count={len(sources)}")
sources = list(session.scalars(select(EbookSource).order_by(EbookSource.title)).all())
logger.info("ebook_books_page_loaded count=%s", len(sources))
return templates.TemplateResponse(request, "books.html", {"sources": sources})
async def get_chapter_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of indexed chapters for one book."""
return await session.scalar(select(func.count(EbookChapter.id)).where(EbookChapter.source_id == book_id)) or 0
async def get_chunk_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of indexed chunks for one book."""
return await session.scalar(select(func.count(EbookChunk.id)).where(EbookChunk.source_id == book_id)) or 0
async def get_candidate_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of indexed candidates for one book."""
return (
await session.scalar(select(func.count(EbookCandidatePhrase.id)).where(EbookCandidatePhrase.book_id == book_id))
or 0
)
async def get_judged_candidate_count(session: AsyncSession, book_id: int) -> int:
"""Return the number of judged candidates for one book."""
return (
await session.scalar(
select(func.count(EbookCandidatePhrase.id)).where(
EbookCandidatePhrase.book_id == book_id,
EbookCandidatePhrase.llm_judged.is_(True),
)
)
or 0
)
async def get_candidates(session: AsyncSession, book_id: int) -> list[EbookCandidatePhrase]:
"""Return the indexed candidates for one book."""
return list(
await session.scalars(
select(EbookCandidatePhrase)
.where(EbookCandidatePhrase.book_id == book_id)
.order_by(EbookCandidatePhrase.candidate_score.desc())
.limit(100)
)
)
async def get_protected_phrases(session: AsyncSession, book_id: int) -> list[EbookProtectedPhrase]:
"""Return the protected phrases for one book."""
return list(
await session.scalars(
select(EbookProtectedPhrase)
.where(EbookProtectedPhrase.book_id == book_id)
.order_by(EbookProtectedPhrase.importance.desc())
.limit(100)
)
)
@router.get("/books/{source_id}", response_class=HTMLResponse)
async def book_detail(source_id: int, request: Request, session: AsyncDbSession) -> HTMLResponse:
def book_detail(source_id: int, request: Request, session: DbSession) -> HTMLResponse:
"""Render details for one indexed book."""
source = await session.get(EbookSource, source_id)
phrase_status_message = None
recalculated = request.query_params.get("phrases_recalculated")
if recalculated is not None:
phrase_status_message = f"Recalculated phrases; {recalculated} candidates generated"
judgment_outcome = pop_book_judgment_outcome(request.app, source_id)
if judgment_outcome is not None:
phrase_status_message = judgment_outcome
judging_in_progress = is_judging_book(request.app, source_id)
if judging_in_progress:
phrase_status_message = "Judging candidate phrases in the background; refresh to see progress"
source = session.get(EbookSource, source_id)
if source is not None:
chapter_count = await get_chapter_count(session, source.id)
chunk_count = await get_chunk_count(session, source.id)
candidate_count = await get_candidate_count(session, source.id)
judged_candidate_count = await get_judged_candidate_count(session, source.id)
protected_count = await count_protected_phrases(session, source.id)
candidates = await get_candidates(session, source.id)
protected_phrases = await get_protected_phrases(session, source.id)
chapter_count = len(source.chapters)
chunk_count = len(source.chunks)
else:
chapter_count = 0
chunk_count = 0
candidate_count = 0
judged_candidate_count = 0
protected_count = 0
candidates = []
protected_phrases = []
logger.info(
f"ebook_book_detail_loaded {source_id=} found={source is not None} {chapter_count=} {chunk_count=} "
f"{candidate_count=} {judged_candidate_count=} {protected_count=}"
"ebook_book_detail_loaded source_id=%s found=%s chapters=%s chunks=%s",
source_id,
source is not None,
chapter_count,
chunk_count,
)
return templates.TemplateResponse(
request,
"book_detail.html",
{
"candidate_count": candidate_count,
"candidates": candidates,
"chapter_count": chapter_count,
"chunk_count": chunk_count,
"judged_candidate_count": judged_candidate_count,
"judging_in_progress": judging_in_progress,
"protected_count": protected_count,
"protected_phrases": protected_phrases,
"phrase_status_message": phrase_status_message,
"source": source,
},
{"chapter_count": chapter_count, "chunk_count": chunk_count, "source": source},
)
@router.post("/books/{source_id}/recalculate-phrases")
async def recalculate_book_phrases(source_id: int, config: AppConfig, session: AsyncDbSession) -> RedirectResponse:
"""Clear and regenerate candidate phrases for one indexed book."""
source = await session.get(EbookSource, source_id)
if source is None:
raise HTTPException(status_code=404, detail="Book not found")
try:
result = await recalculate_candidate_phrases_for_book(session, source, config)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
logger.info(
f"ebook_book_phrase_recalculation_complete {source_id=} {result.candidate_phrases=} "
f"{result.deleted_candidates=} {result.deleted_protected_phrases=} {result.deleted_aliases=} "
f"{result.deleted_mentions=}"
)
return RedirectResponse(
url=f"/books/{source_id}?phrases_recalculated={result.candidate_phrases}",
status_code=303,
)
@router.post("/books/{source_id}/judge-phrases")
async def judge_book_phrases(
source_id: int,
request: Request,
background_tasks: BackgroundTasks,
session: AsyncDbSession,
) -> RedirectResponse:
"""Queue background judging of one book's candidate phrases and return immediately."""
source = await session.get(EbookSource, source_id)
if source is None:
raise HTTPException(status_code=404, detail="Book not found")
started = start_book_phrase_judgment(request.app, background_tasks, source.id)
logger.info(f"ebook_book_phrase_judgment_requested {source_id=} {started=}")
return RedirectResponse(url=f"/books/{source_id}", status_code=303)
+24 -31
View File
@@ -13,9 +13,9 @@ from fastapi.responses import HTMLResponse
from python.ebook_search.answer import answer_query
from python.ebook_search.api.dependencies import ( # noqa: TC001 FastAPI resolves these annotated dependencies at runtime
AppConfig,
AppHttpClient,
AppEngine,
)
from python.ebook_search.api.web import error_response, templates
from python.ebook_search.api.web import templates
from python.ebook_search.guardrails import (
CitationReport,
is_confident,
@@ -24,11 +24,8 @@ from python.ebook_search.guardrails import (
)
from python.ebook_search.search import SearchResponse, search_ebooks
from python.ebook_search.timing import runtime_step_from_start
from python.fastapi_tools import AppAsyncEngine # noqa: TC001 FastAPI resolves this annotated dependency at runtime
if TYPE_CHECKING:
import httpx
from python.ebook_search.config import EbookSearchConfig
logger = logging.getLogger(__name__)
@@ -36,8 +33,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
async def build_answer(
client: httpx.AsyncClient,
def build_answer(
query: str,
response: SearchResponse,
config: EbookSearchConfig,
@@ -49,8 +45,9 @@ async def build_answer(
if not is_confident(response.results, config):
logger.info(
f"ebook_answer_low_confidence confidence={retrieval_confidence(response.results):.4f} "
f"{config.min_retrieval_confidence=:.4f}"
"ebook_answer_low_confidence confidence=%.4f threshold=%.4f",
retrieval_confidence(response.results),
config.min_retrieval_confidence,
)
answer = (
"Retrieval confidence is low for this query, so answer generation was skipped. "
@@ -59,46 +56,40 @@ async def build_answer(
return answer, True, None
try:
answer = await answer_query(client, query, response.results, config)
answer = answer_query(query, response.results, config)
except RuntimeError as error:
logger.warning(f"ebook_answer_request_failed_falling_back {error=}")
logger.warning("ebook_answer_request_failed_falling_back error=%s", error)
return "Answer generation failed. Source chunks are still shown below.", False, None
citation_report = None
if config.validate_citations_enabled and response.results:
citation_report = validate_citations(answer, len(response.results))
if citation_report.invalid or not citation_report.grounded:
logger.warning(f"ebook_answer_citation_issue {citation_report.invalid=} {citation_report.grounded=}")
logger.warning(
"ebook_answer_citation_issue invalid=%s grounded=%s",
citation_report.invalid,
citation_report.grounded,
)
return answer, False, citation_report
@router.post("/search", response_class=HTMLResponse)
async def search(
def search(
request: Request,
config: AppConfig,
engine: AppAsyncEngine,
client: AppHttpClient,
engine: AppEngine,
query: Annotated[str, Form()],
*,
rerank: Annotated[bool, Form()] = False,
phrase_matching: Annotated[bool, Form()] = False,
rerank: Annotated[str | None, Form()] = None,
) -> HTMLResponse:
"""Run a search and render HTMX results."""
try:
response = await search_ebooks(
engine,
client,
query,
config,
rerank=rerank,
phrase_matching=phrase_matching,
)
response = search_ebooks(engine, query, config, rerank=rerank == "true")
except Exception as error:
logger.exception("ebook_search_request_failed")
return error_response(request, error)
return templates.TemplateResponse(request, "partials/error.html", {"message": str(error)}, status_code=500)
answer_start = perf_counter()
answer, low_confidence, citation_report = await build_answer(client, query, response, config)
answer, low_confidence, citation_report = build_answer(query, response, config)
answer_step_name = "Answer generation" if config.answer_enabled else "Answer skipped"
response = replace(
response,
@@ -106,10 +97,12 @@ async def search(
)
for step in response.timings:
logger.info(f"ebook_search_timing {step.name=} {step.duration_ms=:.1f}")
logger.info("ebook_search_timing step=%r runtime_ms=%.1f", step.name, step.duration_ms)
logger.info(
f"ebook_search_request_complete results={len(response.results)} {response.rank_label=} "
f"{response.total_runtime_ms=:.1f}"
"ebook_search_request_complete results=%s rank_label=%s runtime_ms=%.1f",
len(response.results),
response.rank_label,
response.total_runtime_ms,
)
return templates.TemplateResponse(
request,
+50 -348
View File
@@ -1,214 +1,25 @@
:root {
--bg: #f4f5f7;
--surface: #ffffff;
--border: #e3e5ea;
--text: #1c1f24;
--muted: #6b7280;
--accent: #4f46e5;
--accent-soft: #eef0fe;
--danger: #b42318;
--warn-bg: #fff8eb;
--warn-border: #e0a92e;
--warn-text: #7a5008;
--radius: 12px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.08);
}
html.theme-dark {
--bg: #0f1117;
--surface: #1a1d25;
--border: #2b303b;
--text: #e6e8ec;
--muted: #9aa1ad;
--accent: #818cf8;
--accent-soft: #262b45;
--danger: #f97066;
--warn-bg: #2a2410;
--warn-border: #b9881f;
--warn-text: #e8c97a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
background: #f7f7f4;
color: #202124;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.55;
}
main {
max-width: 820px;
max-width: 960px;
margin: 0 auto;
padding: 32px 20px 64px;
padding: 24px;
}
/* Header / nav */
.site-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.site-nav {
max-width: 820px;
margin: 0 auto;
padding: 12px 20px;
nav {
display: flex;
align-items: center;
gap: 20px;
}
.brand {
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 6px;
margin-right: auto;
}
.nav-links a {
padding: 6px 12px;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.94rem;
transition: background 0.15s, color 0.15s;
}
.nav-links a:hover {
background: var(--accent-soft);
color: var(--accent);
}
.dev-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.theme-toggle:hover {
border-color: var(--accent);
filter: none;
}
h1 {
font-size: 1.6rem;
margin: 0 0 20px;
}
h2 {
font-size: 1.15rem;
margin: 0 0 8px;
}
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
/* Search form */
form {
margin: 0;
}
label {
font-weight: 600;
font-size: 0.92rem;
}
textarea {
display: block;
width: 100%;
margin: 8px 0 16px;
padding: 12px 14px;
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
resize: vertical;
transition: border-color 0.15s, box-shadow 0.15s;
}
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.search-toggles {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
button {
padding: 10px 20px;
font: inherit;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 10px;
cursor: pointer;
transition: filter 0.15s;
}
button:hover {
filter: brightness(1.08);
}
.check {
display: inline-flex;
gap: 8px;
align-items: center;
font-weight: 500;
color: var(--muted);
margin-bottom: 20px;
}
nav form {
margin: 0;
}
.actions {
@@ -218,230 +29,121 @@ button:hover {
margin-bottom: 24px;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(2, max-content);
textarea {
display: block;
width: 100%;
margin: 8px 0 12px;
}
/* Answer + results */
#results {
display: block;
margin-top: 28px;
button {
padding: 8px 14px;
}
.check {
display: inline-flex;
gap: 8px;
align-items: center;
margin-right: 12px;
}
.rank-label {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
margin-bottom: 16px;
}
.answer {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 24px;
}
.answer p:last-child {
margin-bottom: 0;
margin-top: 24px;
font-weight: 700;
}
.results {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 16px;
padding-left: 24px;
}
.results > li {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 20px;
}
.results h2 {
font-size: 1.05rem;
}
.results h2 a {
color: var(--text);
text-decoration: none;
}
.results h2 a:hover {
color: var(--accent);
}
.meta {
color: var(--muted);
font-size: 0.88rem;
margin: 0 0 10px;
.meta,
.scores,
.status {
color: #626a73;
}
.scores {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0 0;
margin: 12px 0;
}
.scores div {
display: inline-flex;
gap: 6px;
gap: 4px;
align-items: baseline;
padding: 3px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.78rem;
}
.scores dt {
font-weight: 600;
color: var(--muted);
font-weight: 700;
}
.scores dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.phrase-matches {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
margin: 10px 0 0;
font-size: 0.78rem;
}
.phrase-matches-label {
color: var(--muted);
font-weight: 600;
}
.phrase-match {
padding: 3px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--accent);
}
/* Runtime — developer diagnostics, hidden unless dev mode is on */
.runtime {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 20px;
margin-bottom: 24px;
}
html.dev .runtime {
display: block;
margin-top: 16px;
}
.timing-chart {
display: grid;
gap: 8px;
padding: 0;
margin: 12px 0 0;
list-style: none;
}
.timing-chart li {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(160px, 2fr) auto auto;
gap: 10px;
gap: 8px;
align-items: center;
font-size: 0.85rem;
}
.timing-bar {
height: 8px;
height: 10px;
overflow: hidden;
background: var(--bg);
border-radius: 999px;
background: #e5e5df;
}
.timing-bar span {
display: block;
height: 100%;
background: var(--accent);
border-radius: 999px;
background: #3767c8;
}
.timing-value,
.timing-remaining {
color: var(--muted);
color: #626a73;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
th,
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
padding: 8px;
border-bottom: 1px solid #d8d8d2;
text-align: left;
font-size: 0.9rem;
}
th {
font-weight: 600;
color: var(--muted);
background: var(--bg);
font-weight: 700;
}
tbody tr:last-child td {
border-bottom: none;
}
dl dt {
font-weight: 600;
color: var(--muted);
font-size: 0.85rem;
}
dl dd {
margin: 0 0 12px;
}
/* States */
.error {
color: var(--danger);
font-weight: 600;
color: #9f1d20;
font-weight: 700;
}
.notice {
margin: 12px 0;
padding: 10px 14px;
border-left: 3px solid var(--warn-border);
border-radius: 6px;
background: var(--warn-bg);
color: var(--warn-text);
font-weight: 500;
}
.status {
color: var(--muted);
margin: 8px 0;
padding: 8px 12px;
border-left: 4px solid #c8881d;
background: #fcf3e2;
color: #6b4a06;
font-weight: 600;
}
+57 -103
View File
@@ -1,103 +1,57 @@
{% extends "base.html" %} {% block title %}EPUB Admin{% endblock %} {% block
head %}
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% endblock %} {% block content %}
<h1>Admin</h1>
<section id="admin-status"></section>
<section class="actions">
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Scan</button>
</form>
</section>
<section>
<h2>Embeddings</h2>
<section class="actions">
<form
hx-post="/admin/embed-missing"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Embed</button>
</form>
<form
hx-post="/admin/embed-all"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Embed all</button>
</form>
</section>
<table>
<thead>
<tr>
<th>Model</th>
<th>Dimensions</th>
<th>Embedded</th>
<th>Missing</th>
<th>Total chunks</th>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section>
<h2>Protected phrases</h2>
<section class="actions actions-grid">
<form
hx-post="/admin/phrases/generate-all"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Regenerate all phrases</button>
</form>
<form
hx-post="/admin/phrases/judge-all"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Judge all phrases</button>
</form>
<form
hx-post="/admin/phrases/judge-missing"
hx-target="#admin-status"
hx-swap="innerHTML"
>
<button type="submit">Judge missing phrases</button>
</form>
</section>
<table>
<thead>
<tr>
<th>Candidates</th>
<th>Judged</th>
<th>Unjudged</th>
<th>Protected</th>
<th>Books indexed</th>
<th>Books generated</th>
<th>Books fully judged</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ phrase_stats.candidate_phrases }}</td>
<td>{{ phrase_stats.judged_candidates }}</td>
<td>{{ phrase_stats.unjudged_candidates }}</td>
<td>{{ phrase_stats.protected_phrases }}</td>
<td>{{ phrase_stats.total_books }}</td>
<td>{{ phrase_stats.books_with_candidates }}</td>
<td>{{ phrase_stats.books_fully_judged }}</td>
</tr>
</tbody>
</table>
</section>
{% endblock %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB Admin</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>Admin</h1>
<section id="admin-status"></section>
<section class="actions">
<form hx-post="/admin/scan" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Scan</button>
</form>
<form hx-post="/admin/embed-missing" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed</button>
</form>
<form hx-post="/admin/embed-all" hx-target="#admin-status" hx-swap="innerHTML">
<button type="submit">Embed all</button>
</form>
</section>
<section>
<h2>Embeddings</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>Dimensions</th>
<th>Embedded</th>
<th>Missing</th>
<th>Total chunks</th>
</tr>
</thead>
<tbody>
{% for item in stats %}
<tr>
<td>{{ item.model_name }}</td>
<td>{{ item.dimension }}</td>
<td>{{ item.embedded_chunks }}</td>
<td>{{ item.missing_chunks }}</td>
<td>{{ item.total_chunks }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>
@@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EPUB Search{% endblock %}</title>
{% block head %}{% endblock %}
<link rel="stylesheet" href="/static/style.css?v={{ static_version('style.css') }}">
<script>
// Apply theme and dev mode before paint to avoid a flash of unstyled/wrong content.
(function () {
var stored = localStorage.getItem("ebook-theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme = stored || (prefersDark ? "dark" : "light");
document.documentElement.classList.add("theme-" + theme);
if (localStorage.getItem("ebook-dev-mode") === "on") {
document.documentElement.classList.add("dev");
}
})();
</script>
</head>
<body>
<header class="site-header">
<nav class="site-nav">
<a class="brand" href="/">EPUB Search</a>
<div class="nav-links">
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</div>
<button type="button" id="theme-toggle" class="theme-toggle" title="Toggle light / dark theme" aria-label="Toggle theme"></button>
<label class="dev-toggle" title="Show developer diagnostics">
<input type="checkbox" id="dev-mode-toggle">
<span>Dev</span>
</label>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script>
(function () {
var toggle = document.getElementById("dev-mode-toggle");
if (toggle) {
toggle.checked = document.documentElement.classList.contains("dev");
toggle.addEventListener("change", function () {
document.documentElement.classList.toggle("dev", toggle.checked);
localStorage.setItem("ebook-dev-mode", toggle.checked ? "on" : "off");
});
}
var themeButton = document.getElementById("theme-toggle");
if (themeButton) {
var root = document.documentElement;
var sync = function () {
var isDark = root.classList.contains("theme-dark");
themeButton.textContent = isDark ? "☀️" : "🌙";
};
sync();
themeButton.addEventListener("click", function () {
var next = root.classList.contains("theme-dark") ? "light" : "dark";
root.classList.remove("theme-dark", "theme-light");
root.classList.add("theme-" + next);
localStorage.setItem("ebook-theme", next);
sync();
});
}
})();
</script>
</body>
</html>
@@ -1,109 +1,32 @@
{% extends "base.html" %}
{% block title %}{% if source %}{{ source.title }}{% else %}Book not found{% endif %}{% endblock %}
{% block content %}
{% if source %}
<h1>{{ source.title }}</h1>
<p class="meta">{{ source.author or "Unknown author" }}</p>
{% if phrase_status_message %}
<p class="status">{{ phrase_status_message }}</p>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if source %}{{ source.title }}{% else %}Book not found{% endif %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
{% if source %}
<h1>{{ source.title }}</h1>
<p class="meta">{{ source.author or "Unknown author" }}</p>
<dl>
<dt>File</dt>
<dd>{{ source.file_path }}</dd>
<dt>Chapters</dt>
<dd>{{ chapter_count }}</dd>
<dt>Chunks</dt>
<dd>{{ chunk_count }}</dd>
</dl>
{% else %}
<h1>Book not found</h1>
{% endif %}
<dl class="card">
<dt>File</dt>
<dd>{{ source.file_path }}</dd>
<dt>Chapters</dt>
<dd>{{ chapter_count }}</dd>
<dt>Chunks</dt>
<dd>{{ chunk_count }}</dd>
<dt>Candidates</dt>
<dd>{{ candidate_count }}</dd>
<dt>Judged</dt>
<dd>{{ judged_candidate_count }}</dd>
<dt>Protected</dt>
<dd>{{ protected_count }}</dd>
</dl>
<form
method="post"
action="/books/{{ source.id }}/recalculate-phrases"
onsubmit="return confirm('Remove old phrases for this book and generate new candidates?');"
>
<button type="submit">Recalculate phrases</button>
</form>
<form
method="post"
action="/books/{{ source.id }}/judge-phrases"
onsubmit="return confirm('Judge candidate phrases for this book with the LLM?');"
>
<button type="submit"{% if judging_in_progress %} disabled{% endif %}>
{% if judging_in_progress %}Judging&hellip;{% else %}Judge phrases{% endif %}
</button>
</form>
<section>
<h2>Candidate n-grams</h2>
{% if candidates %}
<table>
<thead>
<tr>
<th>Phrase</th>
<th>Status</th>
<th>Score</th>
<th>Count</th>
<th>Chapters</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>{{ candidate.phrase_text }}</td>
<td>
{% if candidate.llm_judged %}
{% if candidate.llm_keep %}Kept{% else %}Rejected{% endif %}
{% else %}
Candidate
{% endif %}
</td>
<td>{{ "%.2f"|format(candidate.candidate_score) }}</td>
<td>{{ candidate.raw_count }}</td>
<td>{{ candidate.chapter_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No candidate n-grams.</p>
{% endif %}
</section>
<section>
<h2>Protected phrases</h2>
{% if protected_phrases %}
<table>
<thead>
<tr>
<th>Phrase</th>
<th>Type</th>
<th>Confidence</th>
<th>Importance</th>
</tr>
</thead>
<tbody>
{% for phrase in protected_phrases %}
<tr>
<td>{{ phrase.phrase_text }}</td>
<td>{{ phrase.phrase_type or "phrase" }}</td>
<td>{{ "%.2f"|format(phrase.confidence) }}</td>
<td>{{ "%.2f"|format(phrase.importance) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No protected phrases.</p>
{% endif %}
</section>
{% else %}
<h1>Book not found</h1>
{% endif %}
{% endblock %}
</main>
</body>
</html>
+31 -19
View File
@@ -1,19 +1,31 @@
{% extends "base.html" %}
{% block title %}EPUB Books{% endblock %}
{% block content %}
<h1>Books</h1>
{% if sources %}
<ol class="results">
{% for source in sources %}
<li>
<h2><a href="/books/{{ source.id }}">{{ source.title }}</a></h2>
<p class="meta">{{ source.author or "Unknown author" }}</p>
</li>
{% endfor %}
</ol>
{% else %}
<p>No EPUBs indexed.</p>
{% endif %}
{% endblock %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB Books</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>Books</h1>
{% if sources %}
<ol class="results">
{% for source in sources %}
<li>
<h2><a href="/books/{{ source.id }}">{{ source.title }}</a></h2>
<p class="meta">{{ source.author or "Unknown author" }}</p>
</li>
{% endfor %}
</ol>
{% else %}
<p>No EPUBs indexed.</p>
{% endif %}
</main>
</body>
</html>
@@ -39,13 +39,7 @@
<ol class="results">
{% for result in response.results %}
<li>
<h2>
{% if result.source_id %}
<a href="/books/{{ result.source_id }}">{{ result.source_title }}</a>
{% else %}
{{ result.source_title }}
{% endif %}
</h2>
<h2>{{ result.source_title }}</h2>
<p class="meta">
{% if result.source_author %}{{ result.source_author }}{% endif %}
{% if result.chapter_title %} · {{ result.chapter_title }}{% endif %}
@@ -82,14 +76,6 @@
</div>
{% endif %}
</dl>
{% if result.matched_phrases %}
<p class="phrase-matches">
<span class="phrase-matches-label">boosted by</span>
{% for phrase in result.matched_phrases %}
<span class="phrase-match">{{ phrase }}</span>
{% endfor %}
</p>
{% endif %}
</li>
{% endfor %}
</ol>
+29 -31
View File
@@ -1,32 +1,30 @@
{% extends "base.html" %}
{% block title %}EPUB Search{% endblock %}
{% block head %}<script src="https://unpkg.com/htmx.org@2.0.4"></script>{% endblock %}
{% block content %}
<h1>Search</h1>
<form class="card" hx-post="/search" hx-target="#results" hx-swap="innerHTML">
<label for="query">What are you looking for?</label>
<textarea id="query" name="query" rows="4" placeholder="Ask a question or paste a passage…" required
onkeydown="if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); this.form.requestSubmit(); }"></textarea>
<div class="form-row">
<div class="search-toggles">
<label class="check">
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
Rerank
</label>
<label class="check">
<input
type="checkbox"
name="phrase_matching"
value="true"
{% if config.phrase_matching_enabled %}checked{% endif %}
>
Phrase matching
</label>
</div>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB Search</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main>
<nav>
<a href="/">Search</a>
<a href="/books">Books</a>
<a href="/admin">Admin</a>
</nav>
<h1>EPUB Search</h1>
<form hx-post="/search" hx-target="#results" hx-swap="innerHTML">
<label for="query">Search</label>
<textarea id="query" name="query" rows="4" required></textarea>
<label class="check">
<input type="checkbox" name="rerank" value="true" {% if config.rerank.enabled %}checked{% endif %}>
Rerank
</label>
<button type="submit">Search</button>
</div>
</form>
<section id="results"></section>
{% endblock %}
</form>
<section id="results"></section>
</main>
</body>
</html>
-20
View File
@@ -3,31 +3,11 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi.templating import Jinja2Templates
if TYPE_CHECKING:
from fastapi import Request
from fastapi.responses import HTMLResponse
PACKAGE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = PACKAGE_DIR / "templates"
STATIC_DIR = PACKAGE_DIR / "static"
def static_version(filename: str) -> int:
"""Return a cache-busting token for a static file based on its modification time."""
try:
return int((STATIC_DIR / filename).stat().st_mtime)
except OSError:
return 0
templates = Jinja2Templates(directory=TEMPLATE_DIR)
templates.env.globals["static_version"] = static_version
def error_response(request: Request, message: object) -> HTMLResponse:
"""Render the shared error partial for a failed UI request."""
return templates.TemplateResponse(request, "partials/error.html", {"message": str(message)}, status_code=500)
+35 -28
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import json
import logging
import shutil
@@ -15,11 +14,10 @@ from typing import TYPE_CHECKING
import bm25s
from sqlalchemy import func, select, union_all
from python.ebook_search.chunk_records import CHUNK_RECORD_COLUMNS
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
@@ -75,48 +73,52 @@ def get_current_bm25_index(index_path: Path) -> Path:
return index_path
async def ensure_bm25_corpus(session: AsyncSession, config: EbookSearchConfig) -> None:
def ensure_bm25_corpus(session: Session, config: EbookSearchConfig) -> None:
"""Create or refresh the persisted BM25 corpus when it is missing or stale."""
index_path = bm25_index_path(config)
manifest = read_bm25_manifest(index_path)
db_updated_at = await corpus_last_updated_at(session)
db_updated_at = corpus_last_updated_at(session)
if not bm25_index_exists(index_path, manifest):
logger.info(f"ebook_bm25_index_missing {index_path=}")
await refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
logger.info("ebook_bm25_index_missing path=%s", index_path)
refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
return
if db_updated_at is not None and manifest is not None and manifest.created_at < db_updated_at:
logger.info(
f"ebook_bm25_index_stale {index_path=} created_at={manifest.created_at.isoformat()} "
f"db_updated_at={db_updated_at.isoformat()}"
"ebook_bm25_index_stale path=%s created_at=%s db_updated_at=%s",
index_path,
manifest.created_at.isoformat(),
db_updated_at.isoformat(),
)
await refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
refresh_bm25_corpus(session, config, db_updated_at=db_updated_at)
return
logger.info(
f"ebook_bm25_index_current {index_path=} chunks={manifest.chunk_count if manifest else 0} "
f"created_at={manifest.created_at.isoformat() if manifest else None}"
"ebook_bm25_index_current path=%s chunks=%s created_at=%s",
index_path,
manifest.chunk_count if manifest else 0,
manifest.created_at.isoformat() if manifest else None,
)
async def refresh_bm25_corpus(
session: AsyncSession,
def refresh_bm25_corpus(
session: Session,
config: EbookSearchConfig,
*,
db_updated_at: datetime | None = None,
) -> BM25Manifest:
"""Rebuild and persist the BM25 corpus from the current database chunks.
The index build is CPU and disk work, so it runs in a worker thread.
"""
"""Rebuild and persist the BM25 corpus from the current database chunks."""
index_path = bm25_index_path(config)
records, texts = await fetch_bm25_corpus_records(session)
records, texts = fetch_bm25_corpus_records(session)
manifest = BM25Manifest(
created_at=datetime.now(tz=UTC),
db_updated_at=db_updated_at if db_updated_at is not None else await corpus_last_updated_at(session),
db_updated_at=db_updated_at if db_updated_at is not None else corpus_last_updated_at(session),
chunk_count=len(records),
)
await asyncio.to_thread(write_bm25_corpus, index_path, records, texts, manifest)
write_bm25_corpus(index_path, records, texts, manifest)
logger.info(
f"ebook_bm25_index_refreshed {index_path=} {manifest.chunk_count=} created_at={manifest.created_at.isoformat()}"
"ebook_bm25_index_refreshed path=%s chunks=%s created_at=%s",
index_path,
manifest.chunk_count,
manifest.created_at.isoformat(),
)
return manifest
@@ -129,7 +131,7 @@ def load_bm25_corpus(config: EbookSearchConfig) -> BM25Corpus:
"""
index_path = bm25_index_path(config)
active_index_path = get_current_bm25_index(index_path)
logger.info(f"ebook_bm25_corpus_cache_load {index_path=} {active_index_path=}")
logger.info("ebook_bm25_corpus_cache_load path=%s active_path=%s", index_path, active_index_path)
manifest = read_bm25_manifest(index_path)
if manifest is None or not bm25_index_exists(index_path, manifest):
msg = f"BM25 corpus is not available: {index_path}"
@@ -162,7 +164,7 @@ def score_bm25_corpus(query: str, corpus: BM25Corpus, *, limit: int) -> list[tup
return results
async def fetch_bm25_corpus_records(session: AsyncSession) -> tuple[list[dict[str, object]], list[str]]:
def fetch_bm25_corpus_records(session: Session) -> tuple[list[dict[str, object]], list[str]]:
"""Fetch persistable BM25 corpus records and their matching index texts from the database.
search_text is only needed to build the index, so it is returned separately instead of
@@ -170,7 +172,12 @@ async def fetch_bm25_corpus_records(session: AsyncSession) -> tuple[list[dict[st
"""
statement = (
select(
*CHUNK_RECORD_COLUMNS,
EbookChunk.id.label("chunk_id"),
EbookChunk.text.label("text"),
EbookSource.title.label("source_title"),
EbookSource.author.label("source_author"),
EbookChapter.title.label("chapter_title"),
EbookChunk.page_label.label("page_label"),
EbookChunk.search_text.label("bm25_text"),
)
.select_from(EbookChunk)
@@ -180,21 +187,21 @@ async def fetch_bm25_corpus_records(session: AsyncSession) -> tuple[list[dict[st
)
records: list[dict[str, object]] = []
texts: list[str] = []
for row in (await session.execute(statement)).mappings():
for row in session.execute(statement).mappings():
record = dict(row)
texts.append(str(record.pop("bm25_text")))
records.append(record)
return records, texts
async def corpus_last_updated_at(session: AsyncSession) -> datetime | None:
def corpus_last_updated_at(session: Session) -> datetime | None:
"""Return the latest source/chapter/chunk update timestamp relevant to BM25 text."""
update_times = union_all(
select(func.max(EbookSource.updated).label("updated")),
select(func.max(EbookChapter.updated).label("updated")),
select(func.max(EbookChunk.updated).label("updated")),
).subquery()
return await session.scalar(select(func.max(update_times.c.updated)))
return session.scalar(select(func.max(update_times.c.updated)))
def write_bm25_corpus(
-13
View File
@@ -1,13 +0,0 @@
"""Shared database columns used to build search-result records."""
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
CHUNK_RECORD_COLUMNS = (
EbookChunk.id.label("chunk_id"),
EbookChunk.text.label("text"),
EbookSource.id.label("source_id"),
EbookSource.title.label("source_title"),
EbookSource.author.label("source_author"),
EbookChapter.title.label("chapter_title"),
EbookChunk.page_label.label("page_label"),
)
+8 -27
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from os import getenv
from typing import Annotated, Self
from pydantic import AliasChoices, Field, field_validator, model_validator
@@ -31,13 +32,18 @@ def normalize_embedding_alias(model: str) -> str:
return standard_model
def normalize_embedding_model(default: str = "qwen3-embedding-0.6b") -> str:
"""Normalize the configured embedding alias to its provider model name."""
return normalize_embedding_alias(getenv("EBOOK_SEARCH_EMBEDDING_MODEL", default))
class RerankConfig(BaseSettings):
"""vLLM reranker settings."""
model_config = SettingsConfigDict(env_prefix="EBOOK_SEARCH_RERANK_", frozen=True, protected_namespaces=())
enabled: bool = True
base_url: str = "http://bob:8001"
base_url: str = "http://192.168.90.25:8001"
model: str = "qwen3-reranker-06b"
candidates: int = 24
timeout_seconds: float = 30.0
@@ -67,7 +73,7 @@ class EbookSearchConfig(BaseSettings):
)
chat_model: str = "deepseek-v4-flash"
answer_enabled: bool = True
embedding_base_url: str = "http://bob:8000/v1"
embedding_base_url: str = "http://192.168.90.25:8000/v1"
embedding_api_key: str = "not-needed"
embedding_model: str = "qwen3-embedding-0.6b"
embedding_batch_size: int = 32
@@ -80,31 +86,6 @@ class EbookSearchConfig(BaseSettings):
validate_citations_enabled: bool = True
bm25_index_dir: str = ".ebook_search_bm25"
bm25_refresh_delay_seconds: int = 60
protected_phrase_max_candidates_per_book: int = 5000
protected_phrase_llm_candidates_per_book: int = 500
protected_phrase_extraction_workers: int = 16
phrase_judge_book_workers: int = 20
phrase_judge_phrase_workers: int = 100
protected_phrase_confidence_threshold: float = 0.80
phrase_matching_enabled: bool = True
phrase_hit_boost: float = 0.25
phrase_min_tokens: int = 2
phrase_max_tokens: int = 5
phrase_max_entity_tokens: int = 8
phrase_yake_top_k: int = 1000
phrase_yake_dedup_limit: float = 0.85
phrase_raw_ngram_min_count: int = 2
phrase_raw_count_score_threshold: int = 3
phrase_raw_count_high_score_threshold: int = 10
phrase_chapter_count_score_threshold: int = 2
phrase_chapter_count_high_score_threshold: int = 5
phrase_target_protected_per_book: int = 100
phrase_default_allow_nested: bool = False
phrase_default_suppress_children: bool = True
phrase_bad_start_score_penalty: float = 10.0
phrase_bad_end_score_penalty: float = 10.0
phrase_multi_source_score_bonus: float = 2.0
phrase_multi_source_min_sources: int = 2
@field_validator("library_paths", mode="before")
@classmethod
-54
View File
@@ -1,54 +0,0 @@
FROM python:3.14-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /uvx /bin/
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_DIR=/home/richie/dotfiles \
UV_PROJECT_ENVIRONMENT=/opt/venv \
UV_PYTHON_DOWNLOADS=never \
UV_NO_CACHE=1
# Separate ENV instruction so ${APP_DIR} and ${PATH} from above resolve.
ENV PYTHONPATH=${APP_DIR} \
PATH=/opt/venv/bin:${PATH}
WORKDIR ${APP_DIR}
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY python/ebook_search/docker/pyproject.toml python/ebook_search/docker/uv.lock ./
RUN uv sync --locked --no-dev
FROM base AS test
RUN uv sync --locked
COPY python ./python
COPY tests/__init__.py ./tests/__init__.py
COPY tests/ebook_search ./tests/ebook_search
CMD ["pytest"]
FROM base AS runtime
ENV EBOOK_SEARCH_HOST=0.0.0.0 \
EBOOK_SEARCH_PORT=8070 \
EBOOK_SEARCH_BM25_INDEX_DIR=/data/bm25
COPY python ./python
RUN useradd --create-home --uid 10001 app \
&& mkdir -p /data \
&& chown -R app:app /home/richie /data
USER app
EXPOSE 8070
CMD ["sh", "-c", "exec python -m python.ebook_search.api.main --host \"${EBOOK_SEARCH_HOST}\" --port \"${EBOOK_SEARCH_PORT}\" --log-level \"${EBOOK_SEARCH_LOG_LEVEL:-INFO}\""]
-1
View File
@@ -1 +0,0 @@
"""Docker packaging and lifecycle tooling for ebook search."""
-259
View File
@@ -1,259 +0,0 @@
"""Docker container lifecycle management for ebook search."""
from __future__ import annotations
import logging
import os
import subprocess
from pathlib import Path
from typing import Annotated
import typer
from python.common import configure_logger, get_repo_dir
logger = logging.getLogger(__name__)
def get_compose_file() -> Path:
"""Return the path to the docker-compose.yml file."""
return Path(__file__).resolve().with_name("docker-compose.yml")
def compose_base_args() -> list[str]:
"""Return the common docker compose arguments for the ebook search stack."""
return ["compose", "-f", str(get_compose_file())]
def docker_run(
arguments: list[str],
*,
env: dict[str, str] | None = None,
capture_output: bool = False,
) -> subprocess.CompletedProcess[str]:
"""Run docker with repo-root cwd and consistent error handling."""
logger.info(f"docker {' '.join(arguments)}")
return subprocess.run(
["docker", *arguments],
cwd=get_repo_dir(),
env=env,
text=True,
check=False,
capture_output=capture_output,
)
def compose_env(*, library_path: Path | None = None, port: int | None = None) -> dict[str, str]:
"""Return environment variables passed to docker compose."""
env = os.environ.copy()
if library_path is not None:
resolved_library = library_path.expanduser().resolve()
if not resolved_library.exists():
msg = f"EPUB library path does not exist: {resolved_library}"
raise FileNotFoundError(msg)
env["EBOOK_LIBRARY_HOST_PATH"] = str(resolved_library)
if port is not None:
env["EBOOK_SEARCH_PORT"] = str(port)
return env
def ensure_compose_file() -> None:
"""Raise if the ebook search compose file is missing."""
if not get_compose_file().is_file():
msg = f"Compose file not found: {get_compose_file()}"
raise FileNotFoundError(msg)
def build_image() -> None:
"""Build the ebook search app image."""
ensure_compose_file()
result = docker_run([*compose_base_args(), "build"])
if result.returncode != 0:
msg = "Failed to build ebook search image"
raise RuntimeError(msg)
def build_test_image() -> None:
"""Build the ebook search test Docker image."""
dockerfile = Path(__file__).resolve().with_name("Dockerfile")
result = docker_run(["build", "--file", str(dockerfile), "--target", "test", "--tag", "ebook-search:test", "."])
if result.returncode != 0:
msg = "Failed to build ebook search test image"
raise RuntimeError(msg)
def run_test_image() -> None:
"""Run the ebook search test suite inside Docker."""
result = docker_run(["run", "--rm", "ebook-search:test"])
if result.returncode != 0:
msg = f"Ebook search tests failed with code {result.returncode}"
raise RuntimeError(msg)
def start_stack(
*,
library_path: Path | None = None,
port: int | None = None,
build: bool = False,
) -> None:
"""Start the ebook search Docker compose stack."""
ensure_compose_file()
env = compose_env(library_path=library_path, port=port)
if build:
build_image()
result = docker_run(
[*compose_base_args(), "up", "-d"],
env=env,
)
if result.returncode != 0:
msg = f"Ebook search stack failed to start with code {result.returncode}"
raise RuntimeError(msg)
logger.info("Ebook search started.")
def stop_stack(
*,
volumes: bool = False,
) -> None:
"""Stop and remove ebook search containers."""
ensure_compose_file()
command = [*compose_base_args(), "down"]
if volumes:
command.append("-v")
result = docker_run(command)
if result.returncode != 0:
msg = f"Ebook search stack failed to stop with code {result.returncode}"
raise RuntimeError(msg)
def logs_stack(
*,
service: str | None = None,
tail: int = 100,
follow: bool = False,
) -> str | None:
"""Return recent logs from the ebook search stack."""
ensure_compose_file()
command = [*compose_base_args(), "logs", "--tail", str(tail)]
if follow:
command.append("--follow")
if service:
command.append(service)
result = docker_run(command, capture_output=not follow)
if result.returncode != 0:
return None
if follow:
return ""
return result.stdout + result.stderr
def ps_stack() -> str | None:
"""Return docker compose ps output for the ebook search stack."""
ensure_compose_file()
result = docker_run([*compose_base_args(), "ps"], capture_output=True)
if result.returncode != 0:
return None
return result.stdout + result.stderr
app = typer.Typer(help="Ebook search Docker container management.", no_args_is_help=True)
@app.command()
def build() -> None:
"""Build the ebook search Docker image."""
build_image()
@app.command()
def start(
library_path: Annotated[Path | None, typer.Option(help="Override host path containing EPUB files.")] = None,
port: Annotated[int | None, typer.Option(help="Override host port for the web UI.")] = None,
*,
build: Annotated[bool, typer.Option("--build", help="Build the image before starting.")] = False,
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
) -> None:
"""Start the ebook search container."""
configure_logger(log_level)
start_stack(
library_path=library_path,
port=port,
build=build,
)
@app.command()
def stop(
*,
volumes: Annotated[bool, typer.Option("--volumes", help="Also remove ebook search data volumes.")] = False,
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
) -> None:
"""Stop and remove ebook search containers."""
configure_logger(log_level)
stop_stack(volumes=volumes)
@app.command()
def restart(
library_path: Annotated[Path | None, typer.Option(help="Override host path containing EPUB files.")] = None,
port: Annotated[int | None, typer.Option(help="Override host port for the web UI.")] = None,
*,
build: Annotated[bool, typer.Option("--build", help="Build the image before starting.")] = False,
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
) -> None:
"""Restart the ebook search stack."""
configure_logger(log_level)
stop_stack()
start_stack(
library_path=library_path,
port=port,
build=build,
)
@app.command()
def logs(
service: Annotated[str | None, typer.Option(help="Service name, or omit for all services.")] = None,
tail: Annotated[int, typer.Option(help="Number of recent log lines.")] = 100,
*,
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs.")] = False,
) -> None:
"""Show recent ebook search container logs."""
output = logs_stack(service=service, tail=tail, follow=follow)
if output is None:
typer.echo("No ebook search containers found.")
raise typer.Exit(code=1)
if output:
typer.echo(output)
@app.command("test")
def run_tests(
*,
build: Annotated[bool, typer.Option("--build/--no-build", help="Build the test image before running.")] = True,
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
) -> None:
"""Run ebook search tests inside the Docker test image."""
configure_logger(log_level)
if build:
build_test_image()
run_test_image()
@app.command("ps")
def ps() -> None:
"""Show ebook search container status."""
output = ps_stack()
if output is None:
typer.echo("No ebook search containers found.")
raise typer.Exit(code=1)
typer.echo(output)
def cli() -> None:
"""Typer entry point."""
app()
if __name__ == "__main__":
cli()
@@ -1,34 +0,0 @@
name: ebook-search
services:
ebook-search:
build:
context: ../../..
dockerfile: python/ebook_search/docker/Dockerfile
image: ebook-search:latest
restart: unless-stopped
ports:
- "${EBOOK_SEARCH_PORT:-8070}:8070"
env_file:
- ../../../.env
environment:
EBOOK_SEARCH_HOST: "0.0.0.0"
EBOOK_SEARCH_PORT: "8070"
EBOOK_SEARCH_LIBRARY_PATHS: "/library"
EBOOK_SEARCH_BM25_INDEX_DIR: "/data/bm25"
volumes:
- "${EBOOK_LIBRARY_HOST_PATH:-/home/richie/ebooks}:/library:ro"
- ebook-search-data:/data
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS http://127.0.0.1:8070/health >/dev/null || exit 1",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
volumes:
ebook-search-data:
-41
View File
@@ -1,41 +0,0 @@
[project]
name = "ebook-search"
version = "0.1.0"
description = "Locked runtime environment for the ebook search container."
requires-python = "~=3.14.0"
dependencies = [
"alembic",
"beautifulsoup4",
"bm25s",
"ebooklib",
"fastapi",
"httpx",
"jinja2",
"pgvector",
"psycopg[binary]",
"pydantic",
"pydantic-settings",
"python-multipart",
"sqlalchemy[asyncio]",
"tiktoken",
"typer",
"uvicorn[standard]",
"yake",
]
[dependency-groups]
dev = [
"aiosqlite",
"pytest",
"pytest-asyncio",
"pytest-mock",
"pytest-xdist",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
addopts = "-n auto -ra"
asyncio_mode = "auto"
testpaths = ["tests/ebook_search"]
-1143
View File
File diff suppressed because it is too large. Load diff
+35 -30
View File
@@ -23,8 +23,7 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import Sequence
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
@@ -66,45 +65,51 @@ class EmbeddingModelStats:
return max(self.total_chunks - self.embedded_chunks, 0)
async def embed_texts(
client: httpx.AsyncClient,
texts: Sequence[str],
config: EbookSearchConfig,
) -> list[list[float]]:
def embed_texts(texts: Sequence[str], config: EbookSearchConfig) -> list[list[float]]:
"""Embed text with the configured vLLM embedding model."""
logger.info(f"ebook_embed_request_start {config.embedding_base_url=} {config.embedding_model=} count={len(texts)}")
vectors = await request_embeddings(client, texts, config)
logger.info(
"ebook_embed_request_start base_url=%s model=%s count=%s",
config.embedding_base_url,
config.embedding_model,
len(texts),
)
vectors = request_embeddings(texts, config)
expected_dimension = MODEL_DIMENSIONS[config.embedding_model]
for vector in vectors:
if len(vector) != expected_dimension:
msg = f"Expected {expected_dimension} dimensions, got {len(vector)}"
raise ValueError(msg)
logger.info(f"ebook_embed_request_complete {config.embedding_model=} count={len(vectors)} {expected_dimension=}")
logger.info(
"ebook_embed_request_complete model=%s count=%s dimension=%s",
config.embedding_model,
len(vectors),
expected_dimension,
)
return vectors
async def embed_query(client: httpx.AsyncClient, query: str, config: EbookSearchConfig) -> list[float]:
def embed_query(query: str, config: EbookSearchConfig) -> list[float]:
"""Embed a search query with the Qwen retrieval instruction."""
instructed_query = f"Instruct: Retrieve relevant passages for the query.\nQuery: {query}"
return (await embed_texts(client, [instructed_query], config))[0]
return embed_texts([instructed_query], config)[0]
async def ensure_embedding_models(session: AsyncSession) -> None:
def ensure_embedding_models(session: Session) -> None:
"""Ensure supported embedding model rows exist."""
for name, dimension in MODEL_DIMENSIONS.items():
existing = await session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == name))
existing = session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == name))
if existing is None:
session.add(EbookEmbeddingModel(name=name, dimension=dimension, is_default=name == "qwen3-embedding-0.6b"))
logger.info(f"ebook_embedding_model_created {name=} {dimension=}")
await session.flush()
logger.info("ebook_embedding_model_created model=%s dimension=%s", name, dimension)
session.flush()
async def embedding_model_stats(session: AsyncSession) -> list[EmbeddingModelStats]:
def embedding_model_stats(session: Session) -> list[EmbeddingModelStats]:
"""Return embedding coverage counts for every supported model."""
total_chunks = await session.scalar(select(func.count(EbookChunk.id))) or 0
total_chunks = session.scalar(select(func.count(EbookChunk.id))) or 0
models = {
model.name: model
for model in await session.scalars(
for model in session.scalars(
select(EbookEmbeddingModel)
.where(EbookEmbeddingModel.name.in_(MODEL_DIMENSIONS))
.order_by(EbookEmbeddingModel.name)
@@ -117,7 +122,7 @@ async def embedding_model_stats(session: AsyncSession) -> list[EmbeddingModelSta
embedded_chunks = 0
if model is not None:
table = get_embedding_table(dimension)
embedded_chunks = await session.scalar(select(func.count(table.id)).where(table.model_id == model.id)) or 0
embedded_chunks = session.scalar(select(func.count(table.id)).where(table.model_id == model.id)) or 0
stats.append(
EmbeddingModelStats(
model_name=model_name,
@@ -129,10 +134,10 @@ async def embedding_model_stats(session: AsyncSession) -> list[EmbeddingModelSta
return stats
async def embed_missing_chunks(session: AsyncSession, client: httpx.AsyncClient, config: EbookSearchConfig) -> int:
def embed_missing_chunks(session: Session, config: EbookSearchConfig) -> int:
"""Embed chunks missing embeddings for the configured model."""
await ensure_embedding_models(session)
model = await session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == config.embedding_model))
ensure_embedding_models(session)
model = session.scalar(select(EbookEmbeddingModel).where(EbookEmbeddingModel.name == config.embedding_model))
if model is None:
supported_models = ", ".join(MODEL_DIMENSIONS)
msg = f"Unknown embedding model: {config.embedding_model}. Supported models: {supported_models}"
@@ -140,7 +145,7 @@ async def embed_missing_chunks(session: AsyncSession, client: httpx.AsyncClient,
table = get_embedding_table(model.dimension)
chunks = list(
await session.scalars(
session.scalars(
select(EbookChunk)
.outerjoin(table, (table.chunk_id == EbookChunk.id) & (table.model_id == model.id))
.where(table.id.is_(None))
@@ -149,17 +154,17 @@ async def embed_missing_chunks(session: AsyncSession, client: httpx.AsyncClient,
)
)
if not chunks:
logger.info(f"ebook_embed_missing_none {config.embedding_model=}")
logger.info("ebook_embed_missing_none model=%s", config.embedding_model)
return 0
logger.info(f"ebook_embed_missing_batch_start {config.embedding_model=} count={len(chunks)}")
vectors = await embed_texts(client, [chunk.text for chunk in chunks], config)
logger.info("ebook_embed_missing_batch_start model=%s count=%s", config.embedding_model, len(chunks))
vectors = embed_texts([chunk.text for chunk in chunks], config)
rows = [
{"chunk_id": chunk.id, "model_id": model.id, "embedding": vector}
for chunk, vector in zip(chunks, vectors, strict=True)
]
statement = insert(table).values(rows).on_conflict_do_nothing(index_elements=["chunk_id", "model_id"])
await session.execute(statement)
await session.flush()
logger.info(f"ebook_embed_missing_batch_complete {config.embedding_model=} count={len(rows)}")
session.execute(statement)
session.flush()
logger.info("ebook_embed_missing_batch_complete model=%s count=%s", config.embedding_model, len(rows))
return len(rows)
+41 -62
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
@@ -11,11 +10,9 @@ from pathlib import Path
from typing import TYPE_CHECKING
import tiktoken
from anyio import Path as AsyncPath
from sqlalchemy import or_, select
from python.ebook_search.epub_parse import parse_epub
from python.ebook_search.protected_phrases.matching import index_chunk_phrase_mentions_for_book
from python.orm.richie import EbookChapter, EbookChunk, EbookSource
logger = logging.getLogger(__name__)
@@ -23,7 +20,7 @@ DEFAULT_CHUNK_TOKENS = 700
DEFAULT_CHUNK_OVERLAP = 100
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
from python.ebook_search.epub_parse import ParsedChapter
@@ -75,64 +72,45 @@ def chunk_text(
return [chunk for chunk in chunks if chunk.text]
async def find_library_epubs(library_path: str) -> tuple[AsyncPath, list[AsyncPath] | None]:
"""Resolve one configured library path and collect its EPUB files asynchronously.
Returns:
tuple[Path, list[Path] | None]: The expanded path and its EPUB files, or ``None`` when
the path is neither an EPUB file nor a directory.
"""
path = await AsyncPath(library_path).expanduser()
if await path.is_file() and path.suffix.lower() == ".epub":
return path, [path]
if await path.is_dir():
return path, sorted([epub_path async for epub_path in path.rglob("*.epub")])
return path, None
async def ingest_configured_paths(session: AsyncSession, config: EbookSearchConfig) -> int:
def ingest_configured_paths(session: Session, config: EbookSearchConfig) -> int:
"""Ingest every EPUB found under configured library paths."""
count = 0
for library_path in config.library_paths:
path, epub_paths = await find_library_epubs(library_path)
logger.info(f"ebook_ingest_path_start {path=}")
if epub_paths is None:
logger.warning(f"ebook_ingest_path_missing {path=}")
continue
for epub_path in epub_paths:
count += int(await ingest_file(session, epub_path, config))
logger.info(f"ebook_ingest_paths_complete {count=} configured_paths={len(config.library_paths)}")
path = Path(library_path).expanduser()
logger.info("ebook_ingest_path_start path=%s", path)
if path.is_file() and path.suffix.lower() == ".epub":
count += int(ingest_file(session, path, config))
elif path.is_dir():
for epub_path in sorted(path.rglob("*.epub")):
count += int(ingest_file(session, epub_path, config))
else:
logger.warning("ebook_ingest_path_missing path=%s", path)
logger.info("ebook_ingest_paths_complete changed_files=%s configured_paths=%s", count, len(config.library_paths))
return count
async def resolve_ingest_path(path: Path | AsyncPath) -> AsyncPath:
"""Expand and resolve an ingest path without blocking the event loop."""
expanded_path = await AsyncPath(path).expanduser()
return await expanded_path.resolve()
async def ingest_file(session: AsyncSession, path: Path | AsyncPath, config: EbookSearchConfig) -> bool:
def ingest_file(session: Session, path: Path, config: EbookSearchConfig) -> bool:
"""Ingest one EPUB file. Return True when the database changed."""
try:
resolved_path = await resolve_ingest_path(path)
logger.info(f"ebook_ingest_file_start {resolved_path=}")
file_hash = await sha256_file(resolved_path)
existing = await find_existing_source(session, resolved_path, file_hash)
resolved_path = path.expanduser().resolve()
logger.info("ebook_ingest_file_start path=%s", resolved_path)
file_hash = sha256_file(resolved_path)
existing = find_existing_source(session, resolved_path, file_hash)
if existing is not None and existing.file_sha256 == file_hash:
stat = await resolved_path.stat()
stat = resolved_path.stat()
existing.file_path = str(resolved_path)
existing.file_mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
existing.file_size = stat.st_size
await session.flush()
logger.info(f"ebook_ingest_file_unchanged {existing.id=} {resolved_path=}")
session.flush()
logger.info("ebook_ingest_file_unchanged source_id=%s path=%s", existing.id, resolved_path)
return False
if existing is not None:
logger.info(f"ebook_ingest_file_replacing {existing.id=} {resolved_path=}")
await session.delete(existing)
await session.flush()
logger.info("ebook_ingest_file_replacing source_id=%s path=%s", existing.id, resolved_path)
session.delete(existing)
session.flush()
stat = await resolved_path.stat()
parsed = await asyncio.to_thread(parse_epub, Path(resolved_path))
stat = resolved_path.stat()
parsed = parse_epub(resolved_path)
source = EbookSource(
title=parsed.title,
author=parsed.author,
@@ -145,7 +123,7 @@ async def ingest_file(session: AsyncSession, path: Path | AsyncPath, config: Ebo
file_size=stat.st_size,
)
session.add(source)
await session.flush()
session.flush()
chunk_index = 0
for spine_index, parsed_chapter in enumerate(parsed.chapters):
@@ -156,32 +134,33 @@ async def ingest_file(session: AsyncSession, path: Path | AsyncPath, config: Ebo
href=parsed_chapter.href,
)
session.add(chapter)
await session.flush()
session.flush()
chunk_index = add_chapter_chunks(session, source, chapter, parsed_chapter, chunk_index, config)
mention_count = await index_chunk_phrase_mentions_for_book(session, source.id, config)
await session.commit()
session.commit()
logger.info(
f"ebook_ingest_file_complete {source.id=} {resolved_path=} chapters={len(parsed.chapters)} {chunk_index=} "
f"{mention_count=}"
"ebook_ingest_file_complete source_id=%s path=%s chapters=%s chunks=%s",
source.id,
resolved_path,
len(parsed.chapters),
chunk_index,
)
except Exception:
await session.rollback()
logger.exception(f"ebook_ingest_file_error {path=}")
logger.exception(f"ebook_ingest_file_error path={path}")
return False
else:
return True
async def find_existing_source(session: AsyncSession, path: Path | AsyncPath, file_hash: str) -> EbookSource | None:
def find_existing_source(session: Session, path: Path, file_hash: str) -> EbookSource | None:
"""Find an existing source by canonical path or file hash."""
return await session.scalar(
return session.scalar(
select(EbookSource).where(or_(EbookSource.file_path == str(path), EbookSource.file_sha256 == file_hash))
)
def add_chapter_chunks(
session: AsyncSession,
session: Session,
source: EbookSource,
chapter: EbookChapter,
parsed_chapter: ParsedChapter,
@@ -212,10 +191,10 @@ def add_chapter_chunks(
return chunk_index
async def sha256_file(path: AsyncPath) -> str:
"""Calculate the SHA-256 digest for a file without blocking the event loop."""
def sha256_file(path: Path) -> str:
"""Calculate the SHA-256 digest for a file."""
digest = hashlib.sha256()
async with await path.open("rb") as file:
while block := await file.read(1024 * 1024):
with path.open("rb") as file:
for block in iter(lambda: file.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
+38 -99
View File
@@ -22,26 +22,10 @@ def auth_headers(api_key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {api_key}"}
async def request_embeddings(
client: httpx.AsyncClient,
texts: Sequence[str],
config: EbookSearchConfig,
) -> list[list[float]]:
"""Request embeddings from the configured OpenAI-compatible endpoint.
Args:
client (httpx.AsyncClient): Shared async client for LLM calls.
texts (Sequence[str]): Texts to embed.
config (EbookSearchConfig): Runtime settings supplying the endpoint, model, and auth.
Returns:
list[list[float]]: One embedding vector per input text.
Raises:
RuntimeError: If the request fails or the response cannot be parsed.
"""
def request_embeddings(texts: Sequence[str], config: EbookSearchConfig) -> list[list[float]]:
"""Request embeddings from the configured OpenAI-compatible endpoint."""
try:
response = await client.post(
response = httpx.post(
f"{config.embedding_base_url.rstrip('/')}/embeddings",
headers=auth_headers(config.embedding_api_key),
json={"model": config.embedding_model, "input": list(texts)},
@@ -51,62 +35,41 @@ async def request_embeddings(
return embedding_vectors_from_response(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
logger.exception(
f"ebook_embed_request_failed {config.embedding_base_url=} {config.embedding_model=} count={len(texts)}"
"ebook_embed_request_failed base_url=%s model=%s count=%s",
config.embedding_base_url,
config.embedding_model,
len(texts),
)
msg = f"Embedding request failed. base_url={config.embedding_base_url} model={config.embedding_model}"
raise RuntimeError(msg) from error
async def check_embedding_endpoint(
client: httpx.AsyncClient,
config: EbookSearchConfig,
*,
timeout_seconds: float = 5.0,
) -> bool:
def check_embedding_endpoint(config: EbookSearchConfig, *, timeout_seconds: float = 5.0) -> bool:
"""Return whether the configured embedding endpoint answers a model listing."""
return await _check_endpoint(
client,
base_url=config.embedding_base_url,
api_key=config.embedding_api_key,
timeout_seconds=timeout_seconds,
unavailable_log=f"ebook_embedding_endpoint_unreachable {config.embedding_base_url=}",
)
async def check_chat_endpoint(
client: httpx.AsyncClient,
config: EbookSearchConfig,
*,
timeout_seconds: float = 5.0,
) -> bool:
"""Return whether the configured chat (answering) endpoint answers a model listing."""
return await _check_endpoint(
client,
base_url=config.vllm_base_url,
api_key=config.vllm_api_key,
timeout_seconds=timeout_seconds,
unavailable_log=f"ebook_chat_endpoint_unreachable {config.vllm_base_url=}",
)
async def _check_endpoint(
client: httpx.AsyncClient,
*,
base_url: str,
api_key: str,
timeout_seconds: float,
unavailable_log: str,
) -> bool:
"""Return whether an OpenAI-compatible endpoint answers a model listing."""
try:
response = await client.get(
f"{base_url.rstrip('/')}/models",
headers=auth_headers(api_key),
response = httpx.get(
f"{config.embedding_base_url.rstrip('/')}/models",
headers=auth_headers(config.embedding_api_key),
timeout=timeout_seconds,
)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning(f"{unavailable_log} {error=}")
logger.warning("ebook_embedding_endpoint_unreachable base_url=%s error=%s", config.embedding_base_url, error)
return False
return True
def check_chat_endpoint(config: EbookSearchConfig, *, timeout_seconds: float = 5.0) -> bool:
"""Return whether the configured chat (answering) endpoint answers a model listing."""
try:
response = httpx.get(
f"{config.vllm_base_url.rstrip('/')}/models",
headers=auth_headers(config.vllm_api_key),
timeout=timeout_seconds,
)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning("ebook_chat_endpoint_unreachable base_url=%s error=%s", config.vllm_base_url, error)
return False
return True
@@ -135,29 +98,18 @@ def embedding_vectors_from_response(body: object) -> list[list[float]]:
return vectors
async def request_rerank(
client: httpx.AsyncClient,
def request_rerank(
query: str,
documents: Sequence[str],
config: RerankConfig,
) -> object | None:
"""Request rerank scores from the configured vLLM endpoint.
Args:
client (httpx.AsyncClient): Shared async client for LLM calls.
query (str): Query the documents are scored against.
documents (Sequence[str]): Candidate documents to score.
config (RerankConfig): Rerank endpoint settings.
Returns:
object | None: The decoded response body, or ``None`` when it is not valid JSON.
"""
"""Request rerank scores from the configured vLLM endpoint."""
payload = {
"model": config.model,
"query": query,
"documents": list(documents),
}
response = await client.post(
response = httpx.post(
f"{config.base_url.rstrip('/')}/rerank",
json=payload,
timeout=config.timeout_seconds,
@@ -170,33 +122,20 @@ async def request_rerank(
return None
async def request_chat_completion(
client: httpx.AsyncClient,
def request_chat_completion(
config: EbookSearchConfig,
messages: Sequence[dict[str, str]],
*,
response_format: dict[str, object] | None = None,
) -> str:
"""Request a chat completion over a shared async client.
Args:
client (httpx.AsyncClient): Shared async client whose connection pool bounds concurrency.
config (EbookSearchConfig): Runtime settings supplying the endpoint, model, and auth.
messages (Sequence[dict[str, str]]): OpenAI-style chat messages.
response_format (dict[str, object] | None): Optional OpenAI-compatible structured output constraint.
Returns:
str: The assistant message text.
Raises:
RuntimeError: If the request fails or the response cannot be parsed.
"""
"""Request a chat completion from the configured OpenAI-compatible endpoint."""
try:
response = await client.post(
response = httpx.post(
f"{config.vllm_base_url.rstrip('/')}/chat/completions",
headers=auth_headers(config.vllm_api_key),
json={"model": config.chat_model, "messages": list(messages), "temperature": 0}
| ({"response_format": response_format} if response_format is not None else {}),
json={
"model": config.chat_model,
"messages": list(messages),
"temperature": 0,
},
timeout=config.chat_timeout_seconds,
)
response.raise_for_status()
+9 -2
View File
@@ -112,7 +112,7 @@ async def send_search(client: httpx.AsyncClient, query: str, *, rerank: bool) ->
try:
response = await client.post("/search", data=data)
except httpx.HTTPError as error:
logger.warning(f"ebook_loadtest_request_failed {error=}")
logger.warning("ebook_loadtest_request_failed error=%s", error)
return RequestResult(status_code=0, latency_ms=(time.perf_counter() - start) * 1000, ok=False)
return RequestResult(
status_code=response.status_code,
@@ -192,7 +192,14 @@ def main(
"""Load test the search endpoint and report latency and throughput."""
configure_logger(log_level)
queries = load_queries(queries_file)
logger.info(f"ebook_loadtest_start {base_url=} {request_count=} {concurrency=} {rerank=} queries={len(queries)}")
logger.info(
"ebook_loadtest_start base_url=%s requests=%s concurrency=%s rerank=%s queries=%s",
base_url,
request_count,
concurrency,
rerank,
len(queries),
)
summary = asyncio.run(
run_load(
base_url=base_url,
-8
View File
@@ -1,8 +0,0 @@
"""LLM prompt templates for EPUB search."""
from python.ebook_search.prompts.lib import (
Prompt,
load_prompt,
)
__all__ = ["Prompt", "load_prompt"]
-9
View File
@@ -1,9 +0,0 @@
system = """\
Answer only from the provided context. Cite sources with bracketed numbers like [1]. \
If the context is insufficient, say so."""
user = """\
Question:
{query}
Context:
{context}"""
-44
View File
@@ -1,44 +0,0 @@
"""Load and render TOML-backed LLM prompt templates."""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from functools import cache
from pathlib import Path
@dataclass(frozen=True)
class Prompt:
"""A system and user prompt pair loaded from TOML."""
system: str
user: str
def messages(self, **values: str) -> list[dict[str, str]]:
"""Render this prompt as OpenAI-style chat messages."""
return [
{"role": "system", "content": self.system.format(**values)},
{"role": "user", "content": self.user.format(**values)},
]
@cache
def _get_prompt_dir() -> Path:
"""Return the directory containing prompt template files."""
return Path(__file__).resolve().parent
@cache
def load_prompt(name: str) -> Prompt:
"""Load and validate a named system and user prompt pair from TOML."""
path = _get_prompt_dir() / f"{name}.toml"
with path.open("rb") as file:
body = tomllib.load(file)
system = body.get("system")
user = body.get("user")
if not isinstance(system, str) or not isinstance(user, str):
msg = f"{path} must define string system and user prompts"
raise TypeError(msg)
return Prompt(system=system, user=user)
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff. Show more