Compare commits
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"PQL_VAULT": "/mnt/media/Projects/core-api"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pql)",
|
||||||
|
"Bash(pql *)",
|
||||||
|
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(.venv/bin/python -m pytest:*)",
|
||||||
|
"Bash(.venv/bin/pytest:*)",
|
||||||
|
"Bash(pytest:*)",
|
||||||
|
"Bash(alembic *)",
|
||||||
|
"Bash(docker logs core-api:*)",
|
||||||
|
"Bash(curl -s http://localhost:8083/*)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
|
||||||
|
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
|
||||||
|
"Bash(alembic downgrade base*)",
|
||||||
|
"Bash(chmod -R 777 *)",
|
||||||
|
"Bash(chmod 777 *)",
|
||||||
|
"Bash(dd if=*)",
|
||||||
|
"Bash(find * -delete*)",
|
||||||
|
"Bash(find * -exec*)",
|
||||||
|
"Bash(git * add --all*)",
|
||||||
|
"Bash(git * add -A*)",
|
||||||
|
"Bash(git * add .)",
|
||||||
|
"Bash(git * branch -D *)",
|
||||||
|
"Bash(git * checkout -- *)",
|
||||||
|
"Bash(git * clean -fd*)",
|
||||||
|
"Bash(git * clean -fdx*)",
|
||||||
|
"Bash(git * commit --no-verify*)",
|
||||||
|
"Bash(git * merge --no-ff*)",
|
||||||
|
"Bash(git * push --force*)",
|
||||||
|
"Bash(git * push -f*)",
|
||||||
|
"Bash(git * reset --hard*)",
|
||||||
|
"Bash(git * restore .*)",
|
||||||
|
"Bash(git add --all*)",
|
||||||
|
"Bash(git add -A*)",
|
||||||
|
"Bash(git add .)",
|
||||||
|
"Bash(git branch -D *)",
|
||||||
|
"Bash(git checkout -- *)",
|
||||||
|
"Bash(git clean -fd*)",
|
||||||
|
"Bash(git clean -fdx*)",
|
||||||
|
"Bash(git commit --no-verify*)",
|
||||||
|
"Bash(git merge --no-ff*)",
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git push -f*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(git restore .*)",
|
||||||
|
"Bash(mkfs*)",
|
||||||
|
"Bash(psql * -c DROP*)",
|
||||||
|
"Bash(psql * DROP DATABASE*)",
|
||||||
|
"Bash(psql * TRUNCATE*)",
|
||||||
|
"Bash(rm -rf $HOME)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(su *)",
|
||||||
|
"Bash(sudo *)",
|
||||||
|
"Bash(toj)",
|
||||||
|
"Bash(toj:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-4
@@ -39,12 +39,9 @@ HOMEASSISTANT_URL=http://localhost:8123
|
|||||||
HOMEASSISTANT_TOKEN=your-long-lived-access-token
|
HOMEASSISTANT_TOKEN=your-long-lived-access-token
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AI Services
|
# Search
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Ollama API
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
|
|
||||||
# SearXNG (self-hosted search)
|
# SearXNG (self-hosted search)
|
||||||
SEARXNG_URL=http://localhost:8080
|
SEARXNG_URL=http://localhost:8080
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.pql/changelog/*.sql merge=union
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
name: Build and Push
|
name: Build and Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
push:
|
||||||
types: [published]
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create Gitea Release
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.schweitz.internal
|
registry: git.schweitz.net
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
@@ -23,8 +36,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.schweitz.internal/jpmschweitzer/core-api:latest
|
git.schweitz.net/jpmschweitzer/core-api:latest
|
||||||
git.schweitz.internal/jpmschweitzer/core-api:${{ github.ref_name }}
|
git.schweitz.net/jpmschweitzer/core-api:${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Trigger Watchtower update
|
- name: Trigger Watchtower update
|
||||||
if: success()
|
if: success()
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Trigger only. The checks live in the Makefile, where they can be read, run by
|
||||||
|
# hand (`make pre-push`), and changed under review.
|
||||||
|
#
|
||||||
|
# This file is identical in every repo in this workspace, deliberately: the call
|
||||||
|
# surface is the same everywhere even though what each gate runs is not, so
|
||||||
|
# nobody has to read a repo to find out how to check it (D-27).
|
||||||
|
#
|
||||||
|
# Enable per clone with: git config core.hooksPath .githooks
|
||||||
|
# Never bypass with --no-verify. Suppress a specific finding deliberately
|
||||||
|
# instead, with a reason — see `make pre-push`.
|
||||||
|
set -euo pipefail
|
||||||
|
exec make -C "$(git rev-parse --show-toplevel)" pre-push
|
||||||
+17
@@ -136,3 +136,20 @@ Thumbs.db
|
|||||||
|
|
||||||
# profiling data
|
# profiling data
|
||||||
.prof
|
.prof
|
||||||
|
|
||||||
|
# Claude Code local settings (machine-specific, may contain credentials)
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# pql — ignore everything except the changelog, which is the replication log of
|
||||||
|
# record and must be committed for tickets to travel with the repo.
|
||||||
|
.pql/*
|
||||||
|
!.pql/changelog/
|
||||||
|
|
||||||
|
|
||||||
|
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||||
|
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||||
|
# Only .githooks/pre-push is shared.
|
||||||
|
.githooks/pre-commit
|
||||||
|
.githooks/post-merge
|
||||||
|
.githooks/post-checkout
|
||||||
|
.githooks/post-rewrite
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Changelog format marker, written by pql. Comments only: this file
|
||||||
|
-- is never executed — Import descends into the per-table directories
|
||||||
|
-- and does not read the changelog root.
|
||||||
|
--
|
||||||
|
-- A changelog carrying no marker is format 1, the shape that existed
|
||||||
|
-- before formats were versioned. An older format is migrated forward
|
||||||
|
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||||
|
-- a newer one is refused rather than replayed under rules this binary
|
||||||
|
-- does not know. See D-28 and docs/versions.md.
|
||||||
|
-- pql:changelog_format: 2.0.0
|
||||||
|
-- pql:written_by: 2.2.0
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Auto-generated by pql init. CREATE TABLE statements
|
||||||
|
-- for the planning schema; per-table dir keeps the changelog
|
||||||
|
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||||
|
-- idempotent so running schema files from each directory in
|
||||||
|
-- replay order is harmless.
|
||||||
|
--
|
||||||
|
-- Importer parses the markers below to detect schema drift
|
||||||
|
-- between the producing pql version and the local one — a
|
||||||
|
-- bumped canonical_version means projection rules changed
|
||||||
|
-- and replay must refuse rather than silently corrupt state.
|
||||||
|
-- pql:created_by: 2.2.0
|
||||||
|
-- pql:canonical_version: 2
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
|
CHECK(status IN ('active','superseded','resolved','open')),
|
||||||
|
date TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||||
|
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||||
|
ref_type TEXT NOT NULL
|
||||||
|
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (source_id, target_id, ref_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||||
|
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||||
|
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||||
|
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||||
|
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
record_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||||
|
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||||
|
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||||
|
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||||
|
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||||
|
-- always inserts the configured default explicitly.
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
priority TEXT DEFAULT 'medium'
|
||||||
|
CHECK(priority IN ('critical','high','medium','low')),
|
||||||
|
assigned_to TEXT,
|
||||||
|
team TEXT,
|
||||||
|
decision_ref TEXT REFERENCES decisions(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||||
|
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||||
|
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||||
|
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||||
|
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||||
|
ticket_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||||
|
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by TEXT,
|
||||||
|
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT UNIQUE,
|
||||||
|
canonical_version INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||||
|
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
deleted_at TEXT,
|
||||||
|
hash TEXT,
|
||||||
|
canonical_version INTEGER,
|
||||||
|
PRIMARY KEY (ticket_record_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
|
|
||||||
# AGENTS.md
|
|
||||||
|
|
||||||
> **Start every session by reading this file.**
|
|
||||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
|
||||||
|
|
||||||
## 1. Agent Operational Protocols
|
|
||||||
|
|
||||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
|
||||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
|
||||||
* **Act:** Execute the changes in small, atomic steps.
|
|
||||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
|
||||||
|
|
||||||
### 🛡️ Git Discipline
|
|
||||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
|
||||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
|
||||||
* `feat: add user login endpoint`
|
|
||||||
* `fix: resolve database connection timeout`
|
|
||||||
* `refactor: split monolith dependency file`
|
|
||||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
|
||||||
|
|
||||||
### 📝 Changelog Maintenance
|
|
||||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
|
||||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
|
||||||
|
|
||||||
### 🚀 Release Flow
|
|
||||||
When changes are ready for deployment:
|
|
||||||
|
|
||||||
1. **Ask user if deploy cycle is desired **
|
|
||||||
|
|
||||||
2. **Update version** in `pyproject.toml`:
|
|
||||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
|
||||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
|
||||||
|
|
||||||
3. **Update CHANGELOG.md**:
|
|
||||||
- Move items from `[Unreleased]` to new version section
|
|
||||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
|
||||||
|
|
||||||
4. **Commit and tag**:
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "fix: description of changes"
|
|
||||||
git tag v1.8.4
|
|
||||||
git push origin main --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **CI/CD triggers automatically**:
|
|
||||||
- Gitea CI builds Docker image on new tag
|
|
||||||
- Watchtower pulls and deploys to production
|
|
||||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. FastAPI Architecture & Best Practices
|
|
||||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
|
||||||
|
|
||||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
|
||||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
|
||||||
|
|
||||||
**Correct Structure:**
|
|
||||||
```text
|
|
||||||
src/
|
|
||||||
├── auth/
|
|
||||||
│ ├── router.py # Endpoints
|
|
||||||
│ ├── schemas.py # Pydantic models
|
|
||||||
│ ├── service.py # Business logic (CRUD, etc.)
|
|
||||||
│ ├── dependencies.py# Module-specific dependencies
|
|
||||||
│ └── config.py # Module-specific settings
|
|
||||||
├── posts/
|
|
||||||
│ ├── router.py
|
|
||||||
│ └── ...
|
|
||||||
└── main.py # App entry point
|
|
||||||
+201
@@ -5,6 +5,207 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **News Headlines API** - New endpoint for news ticker integration
|
||||||
|
- `GET /tools/news` - Fetch news headlines from user's volatile collection
|
||||||
|
- Returns headlines with title, description, source, and URL
|
||||||
|
- Data sourced from `volatile_{user}` Qdrant collection (news namespace)
|
||||||
|
- Uses `preferred_username` from OIDC, falls back to `default`
|
||||||
|
- News subdomain under tools (`src/domains/tools/news/`)
|
||||||
|
- `NewsHeadline` and `NewsResponse` Pydantic schemas
|
||||||
|
- `NewsService` for parsing news data from Qdrant
|
||||||
|
|
||||||
|
## [1.10.12] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Forecast data retrieval** - Pass raw_data to service instead of extracting wrong field
|
||||||
|
- Qdrant client was extracting `days` (integer 7) instead of `daily` (list)
|
||||||
|
- Now passes full raw_data for service to parse correctly
|
||||||
|
|
||||||
|
## [1.10.11] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Weather wind direction** - Convert integer degrees to cardinal direction string
|
||||||
|
- Scheduler stores wind_direction as degrees (e.g., 135)
|
||||||
|
- Schema expects string (e.g., "SE")
|
||||||
|
- Added `_degrees_to_cardinal()` conversion
|
||||||
|
|
||||||
|
## [1.10.10] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Environment data parsing** - Fix parsing of scheduler-generated Qdrant data
|
||||||
|
- Forecast: Check `daily` key first (scheduler stores day count in `days`, list in `daily`)
|
||||||
|
- Sun times: Use `sunrise_iso`/`sunset_iso` fields, handle time-only format fallback
|
||||||
|
- Sun times: Calculate daylight from `daylight_duration_seconds` or `daylight_hours`
|
||||||
|
- Air quality: Support `aqi_us`/`aqi_european` and `nitrogen_dioxide` field names
|
||||||
|
|
||||||
|
## [1.10.9] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Environment user ID cleanup** - Strip email domain from user identifier
|
||||||
|
- If `preferred_username` is an email, extract just the username part
|
||||||
|
- Ensures Qdrant collection name matches (e.g., `volatile_jpmschweitzer` not `volatile_jpmschweitzer@gmail.com`)
|
||||||
|
|
||||||
|
## [1.10.8] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC audience validation** - python-jose requires string audience, not list
|
||||||
|
- Extract and validate audience from unverified claims first
|
||||||
|
- Use token's actual audience for JWT decode (after validating it's allowed)
|
||||||
|
- Fixes "audience must be a string or None" error
|
||||||
|
|
||||||
|
## [1.10.7] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-issuer OIDC support** - Accept tokens from multiple OAuth providers
|
||||||
|
- Changed `oidc_issuer` (string) to `oidc_issuers` (list)
|
||||||
|
- Each issuer has its own JWKS endpoint, now cached per-issuer
|
||||||
|
- Validates token issuer against allowed list before fetching JWKS
|
||||||
|
- Supports tokens from: `core-api`, `tatlock-ui`, `tatlock` OAuth applications
|
||||||
|
- Completes fix for environment endpoint user resolution
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Deprecated `src/config.py` - consolidated to `src/shared/config.py`
|
||||||
|
- Deprecated `src/security.py` - consolidated to `src/shared/security.py`
|
||||||
|
|
||||||
|
## [1.10.6] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC audience mismatch** - Accept tokens from multiple clients
|
||||||
|
- Changed `oidc_audience` (string) to `oidc_audiences` (list)
|
||||||
|
- Now accepts tokens with audience: `core-api`, `tatlock-ui`, or `tatlock`
|
||||||
|
- Fixes environment endpoint returning "default" user instead of authenticated username
|
||||||
|
- Fixed main.py to use `oidc_audiences[0]` for Swagger UI OAuth client
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Documentation cleanup in README
|
||||||
|
|
||||||
|
## [1.10.4] - 2026-01-07
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Ollama integration removed** - AI inference is no longer handled by this API
|
||||||
|
- Removed `src/models/ollama_client.py` and all Ollama-related configuration
|
||||||
|
- Removed `src/models/embeddings.py` and `src/models/embeddings_ollama.py`
|
||||||
|
- Removed model aliases and AI configuration from settings
|
||||||
|
- Health endpoints no longer check Ollama status
|
||||||
|
- Tests updated to reflect database-only health checks
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Health check `/health/full` now only checks database connectivity
|
||||||
|
- Diagnostics endpoint simplified (removed Ollama component info)
|
||||||
|
|
||||||
|
## [1.10.3] - 2026-01-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **OIDC config not applied to domains module** - Both `src.auth.oidc` and `src.domains.auth.oidc` configs are now initialized
|
||||||
|
- Previously only `src.auth.oidc` was configured, leaving domains tools using hardcoded "local" user
|
||||||
|
- Environment endpoint now correctly uses authenticated user from OIDC token
|
||||||
|
|
||||||
|
## [1.10.2] - 2026-01-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Enhanced environment endpoint logging** - Added detailed user claim logging for debugging
|
||||||
|
- Logs both `preferred_username` and `sub` claims when resolving user
|
||||||
|
- Distinguishes between authenticated and unauthenticated requests
|
||||||
|
|
||||||
|
## [1.10.1] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix OIDC import path in tools controller (`src.oidc.dependencies` → `src.auth.oidc`)
|
||||||
|
- Environment endpoint was returning `user: "local"` instead of authenticated username
|
||||||
|
- Caused queries to wrong Qdrant collection (`volatile_local` vs `volatile_{username}`)
|
||||||
|
|
||||||
|
## [1.10.0] - 2026-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Environment Data API** - Qdrant-backed endpoint for weather, forecast, and sun position data
|
||||||
|
- `GET /tools/environment` - Fetch environment data from user's volatile collection
|
||||||
|
- Weather: current temperature, conditions, humidity, wind speed
|
||||||
|
- Forecast: multi-day outlook with high/low temperatures
|
||||||
|
- Sun times: sunrise, sunset, daylight duration
|
||||||
|
- Air quality: AQI and quality level (when available)
|
||||||
|
- Data sourced from `volatile_{user}` Qdrant collection
|
||||||
|
- Uses `preferred_username` from OIDC, falls back to `default`
|
||||||
|
- `qdrant-client` dependency for vector database access
|
||||||
|
- `QdrantReadClient` wrapper for read-only collection queries
|
||||||
|
- Comprehensive test suite for environment service parsing
|
||||||
|
|
||||||
|
## [1.9.4] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix SQLAlchemy async lazy loading error for new users in `/auth/sync`
|
||||||
|
- Initialize `user.roles = []` and `user.preferences` to avoid greenlet error
|
||||||
|
- Was causing "MissingGreenlet: greenlet_spawn has not been called" on new user creation
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handle non-UUID `sub` claim in `/auth/sync` - Authentik JWT may return non-UUID subject identifiers
|
||||||
|
- Now derives deterministic UUID from sub string if direct parsing fails
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `/auth/users/me` endpoint now supports both NPM forward auth headers AND JWT Bearer tokens
|
||||||
|
- Added `get_current_user_or_forward_auth()` combined auth dependency
|
||||||
|
- Fixes web authentication where NPM passes `X-authentik-*` headers instead of JWT
|
||||||
|
- Mobile/native clients continue to use JWT Bearer tokens as before
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- CORS configuration now uses explicit origins instead of `"*"`
|
||||||
|
- When `allow_credentials=True`, wildcard origins are rejected by browsers
|
||||||
|
- Added `home.schweitz.net`, `tatlock.schweitz.net`, and localhost origins
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **NPM Forward Auth Support** - Web authentication via Nginx Proxy Manager forward auth
|
||||||
|
- `GET /auth/me` - Get current user from NPM forward auth headers (X-authentik-uid, X-authentik-email, etc.)
|
||||||
|
- Auto-creates user on first web login if not in database
|
||||||
|
- Syncs roles from NPM forward auth groups header
|
||||||
|
- `get_user_by_email` and `get_user_by_authentik_id` methods in AuthService
|
||||||
|
- Comprehensive tests for `/auth/me` endpoint
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Group-Role Mapping & Permissions** (Phase 3)
|
||||||
|
- Decoupled group-role architecture (groups from Authentik, roles admin-managed)
|
||||||
|
- Permission format: `domain.category:action` with action hierarchy
|
||||||
|
- `require_permission` and `require_any_permission` dependency factories
|
||||||
|
- Global admin override (`admin.general:admin`)
|
||||||
|
- **User Profile & API Keys** (Phase 4)
|
||||||
|
- `GET /auth/users/me` - Full user profile with roles and preferences
|
||||||
|
- `GET/PATCH /auth/users/me/preferences` - User preferences management
|
||||||
|
- `GET/POST/DELETE /auth/users/me/api-keys` - API key lifecycle
|
||||||
|
- API keys with `tak_` prefix, SHA-256 hashing, shown only once on creation
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-03
|
## [1.7.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# CLAUDE.md — core-api
|
||||||
|
|
||||||
|
FastAPI service providing infrastructure management, home automation, and utility
|
||||||
|
endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant,
|
||||||
|
Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on
|
||||||
|
tower-of-joy at **:8083**.
|
||||||
|
|
||||||
|
## Ports — these differ, deliberately
|
||||||
|
|
||||||
|
| | Port | How |
|
||||||
|
|---|---|---|
|
||||||
|
| Local dev | **8788** | `./wakeup.sh`, uvicorn `--reload`, logs to `logs/server.log` |
|
||||||
|
| Production | **8083** | container; health at `http://192.168.86.149:8083/health` |
|
||||||
|
|
||||||
|
Testing `localhost:8083` on the dev box hits the *container*, not your reload server.
|
||||||
|
|
||||||
|
## Live contract
|
||||||
|
|
||||||
|
The live contract is always `http://localhost:8083/openapi.json` (62 paths, verified
|
||||||
|
2026-08-09) and human docs at `http://localhost:8083/docs` / `/redoc` — generated from
|
||||||
|
running code, so query it rather than inferring routes from source or from the README's
|
||||||
|
endpoint list, which can drift.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Domain-first layout under `src/domains/<name>/{controller,models,schemas,service}.py`
|
||||||
|
(auth, dashboard, health, housekeeping, infrastructure, static, tools). `src/main.py`
|
||||||
|
wires only `src.domains.*` — verify by reading its imports.
|
||||||
|
|
||||||
|
**Legacy top-level packages — "not in `main.py`" does not mean dead.** Routes are wired
|
||||||
|
only from `src.domains.*`, so grepping `main.py`'s imports looks like it settles which
|
||||||
|
packages are live. It does not. `main.py:55` calls `initialize_oidc()`, and that function
|
||||||
|
(`src/shared/security.py:21`) deliberately imports and configures **both** `src.auth.oidc`
|
||||||
|
and `src.domains.auth.oidc` — a function-body import, invisible to a grep of `main.py`.
|
||||||
|
That one call drags in `src/auth/`, `src/controllers/`, `src/db/`, `src/logging_config.py`
|
||||||
|
and `src/base_schema.py` at startup.
|
||||||
|
|
||||||
|
Three tiers, established by importing the app inside the container and reading
|
||||||
|
`sys.modules` (verified 2026-08-09):
|
||||||
|
|
||||||
|
| Tier | Packages |
|
||||||
|
|---|---|
|
||||||
|
| Serving routes | `src/domains/`, `src/shared/`, `src/service_groups/` |
|
||||||
|
| **Loaded and configured**, but serving no routes | `src/auth/`, `src/db/`, `src/controllers/`, `src/logging_config.py`, `src/base_schema.py` |
|
||||||
|
| Genuinely unreferenced | `src/agent/`, `src/api/`, `src/clients/`, `src/dns/`, `src/memory/`, `src/models/` |
|
||||||
|
|
||||||
|
`src/auth/` is the trap. Its `oidc_config` singleton is configured at every startup with
|
||||||
|
the real Authentik issuers — the log line `src.auth.oidc:configure` proves it — so a test
|
||||||
|
importing `src.auth.oidc` is exercising live, configured code, not a fossil. No `src/domains/*`
|
||||||
|
module depends on it, so it is configured defensively rather than used; that makes it a
|
||||||
|
deletion candidate, but a considered one, not obvious cleanup.
|
||||||
|
|
||||||
|
**Before deleting anything from `src/`, import the app and read `sys.modules`** rather than
|
||||||
|
grepping `main.py`. Function-body imports exist here specifically to dodge circular imports,
|
||||||
|
and they are exactly what a grep misses.
|
||||||
|
|
||||||
|
**That check has its own blind spot, so do not read the third tier as a delete list.** The
|
||||||
|
table above is a snapshot taken after a cold `import src.main` — it shows what *startup*
|
||||||
|
loads. A module imported inside a request handler would be absent from it while being
|
||||||
|
entirely live, and absence would then be a timing artifact rather than evidence of death.
|
||||||
|
This bit on webber, where a tool package imported from inside an agent method looked
|
||||||
|
unloaded and was serving every request. Nothing in core-api is currently known to work that
|
||||||
|
way, but that is the weaker claim — it means nobody has exercised the routes and re-checked,
|
||||||
|
not that nobody does it. Before deleting a third-tier package, drive the endpoints that
|
||||||
|
would plausibly load it and take the snapshot again.
|
||||||
|
|
||||||
|
Some tests (`test_auth_controller.py`, `test_oidc.py`, `test_npm_client.py`,
|
||||||
|
`test_portainer_client.py`, `test_static_controller.py`, `test_tools_controller.py`) import
|
||||||
|
from the top-level paths rather than `src.domains.*`. Which of those cover live code follows
|
||||||
|
the table above — `test_oidc.py` does; the client tests target the unreferenced tier. Not
|
||||||
|
cleaned up in this pass; flagged, not fixed.
|
||||||
|
|
||||||
|
Shared infra (config, database, logging, security/OIDC, external API clients) lives in
|
||||||
|
`src/shared/`.
|
||||||
|
|
||||||
|
Group new work by **domain, not by file type** — a single large `routers/` folder is
|
||||||
|
the thing to avoid. Reference: [FastAPI best practices](https://github.com/zhanymkanov/fastapi-best-practices).
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (`alembic/versions/`). Models
|
||||||
|
live under `src/domains/<name>/models.py` and must be imported in `alembic/env.py` to
|
||||||
|
register with `Base.metadata` — check that file when adding a new model or `alembic
|
||||||
|
revision --autogenerate` will silently miss it.
|
||||||
|
|
||||||
|
## Working here
|
||||||
|
|
||||||
|
**Plan, act, reflect.** Outline which files you will touch and the side effects before
|
||||||
|
writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and
|
||||||
|
does the new behaviour have a test?
|
||||||
|
|
||||||
|
**Test locally first — the build-deploy loop is slow.** `./wakeup.sh` auto-reloads on
|
||||||
|
code changes (not on `requirements.txt` changes; restart the container/script after
|
||||||
|
adding a dependency). Deploy only when a feature is complete and tested.
|
||||||
|
|
||||||
|
Run tests through the venv explicitly, to avoid environment mismatch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest tests/
|
||||||
|
# or, with coverage:
|
||||||
|
.venv/bin/python -m pytest --cov=src --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure Portainer, NPM, Home Assistant, SearXNG,
|
||||||
|
Postgres, and Qdrant hosts/credentials.
|
||||||
|
|
||||||
|
No linter is configured in this repo (no ruff/flake8 config, none in `requirements.txt`
|
||||||
|
or `dev-requirements.txt`) — unlike some sibling repos, don't assume `ruff check` exists
|
||||||
|
here.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` is the only workflow: triggered on `v*` tag push, it
|
||||||
|
creates a Gitea release, builds and pushes the image, then pings Watchtower. There is
|
||||||
|
**no CI test/lint gate** — pytest only runs locally or on request. Verify tests pass
|
||||||
|
before tagging a release.
|
||||||
|
|
||||||
|
## Work tracking
|
||||||
|
|
||||||
|
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets
|
||||||
|
and its internal decisions live here in `.pql/` and `governance/`, and travel with a clone,
|
||||||
|
because `.pql/changelog/` is committed and replayed by the git hooks (workspace D-15). The databases are
|
||||||
|
gitignored and rebuildable with `pql plan rebuild`.
|
||||||
|
|
||||||
|
`pql` is **not** on the non-interactive `PATH` — invoke it as
|
||||||
|
`/home/jpmschweitzer/.local/bin/pql`. From inside this repo no `--vault` is needed: pql anchors
|
||||||
|
at the nearest `.git/` ancestor, which is this repo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
||||||
|
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
||||||
|
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
||||||
|
```
|
||||||
|
|
||||||
|
Stack-level decisions that constrain this service live in the **workspace** vault and need the
|
||||||
|
flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
||||||
|
to a workspace decision. Cite the id in the ticket body instead.
|
||||||
|
|
||||||
|
Do not add a TODO section to a markdown file.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch
|
||||||
|
that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a
|
||||||
|
feature branch for every change; that rule was retired workspace-wide on 2026-08-08
|
||||||
|
and does not apply here anymore.)
|
||||||
|
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
||||||
|
- **Atomic commits** — one logical change each.
|
||||||
|
- **Stage explicitly. Never `git add -A`** — it is denied by policy, and it sweeps in
|
||||||
|
whatever else is dirty, including secrets.
|
||||||
|
- Update `CHANGELOG.md` with every user-facing change, under `[Unreleased]` in `Added` /
|
||||||
|
`Changed` / `Fixed`.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Ask whether a deploy is wanted first — it is not automatic.
|
||||||
|
|
||||||
|
1. Bump the version in `pyproject.toml` (patch for fixes, minor for features).
|
||||||
|
2. Move `[Unreleased]` entries into a dated version section in `CHANGELOG.md`.
|
||||||
|
3. Stage the changed files by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
|
||||||
|
4. Gitea CI (`build.yml`) builds and pushes the image on the tag; Watchtower deploys it.
|
||||||
|
5. Verify: `curl http://192.168.86.149:8083/health`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- OIDC authentication via Authentik, multi-issuer/multi-audience support.
|
||||||
|
- Admin endpoints require authentication when `OIDC_ENABLED=true`.
|
||||||
|
- README claims the container "runs as non-root user (uid 1000)" — **checked and
|
||||||
|
false**: the Dockerfile has no `USER` directive, so the container runs as root.
|
||||||
|
Not fixed here (out of scope for a docs normalization pass); flagging so it isn't
|
||||||
|
restated as fact.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# core-api — the repo's command surface (D-27).
|
||||||
|
#
|
||||||
|
# Paths resolve here rather than in callers (D-10). `python3` on this host is
|
||||||
|
# 3.8 and cannot parse these sources, and a bare `pytest` resolves only in a
|
||||||
|
# login shell — so both are named explicitly through the venv.
|
||||||
|
|
||||||
|
VENV := $(CURDIR)/.venv
|
||||||
|
PYTHON ?= python3.12
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.PHONY: setup
|
||||||
|
setup: ## Create the venv, install dependencies, and prove the result is usable
|
||||||
|
$(PYTHON) -m venv .venv
|
||||||
|
$(VENV)/bin/pip install -r requirements.txt -r dev-requirements.txt
|
||||||
|
# Exit 0 from pip install is not evidence (D-24) — it is the step's job not
|
||||||
|
# to fail, so a broken result and a working one look identical from here.
|
||||||
|
# On 2026-08-09 the venv existed and pip had exited 0, but sqlalchemy was
|
||||||
|
# declared in requirements.txt and not installed; that surfaced as 11
|
||||||
|
# pytest collection errors that read as broken imports, not as a setup
|
||||||
|
# problem. `--collect-only` exercises every import the suite touches
|
||||||
|
# without running a single test, so it catches exactly that class of
|
||||||
|
# drift and stays cheap. `pip check` was considered too, but it only
|
||||||
|
# verifies the *installed* set's internal consistency against itself —
|
||||||
|
# it would not have caught this case, because sqlalchemy was still
|
||||||
|
# present as another package's transitive dependency even when dropped
|
||||||
|
# from requirements.txt. collect-only checks declared-vs-actually-usable
|
||||||
|
# directly, which is the axis that broke.
|
||||||
|
$(VENV)/bin/python -m pytest --collect-only tests/
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Run the test suite
|
||||||
|
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||||
|
$(VENV)/bin/python -m pytest tests/
|
||||||
|
|
||||||
|
# No `lint` target, deliberately. This repo configures no linter — no ruff or
|
||||||
|
# flake8 config, and neither in requirements. Per D-27 the name is reserved for
|
||||||
|
# repos that lint; an empty target here would report clean for something never
|
||||||
|
# run. Add the target when a linter is added, not before.
|
||||||
|
|
||||||
|
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
|
||||||
|
# gitleaks lands. Without this the scan reports "not installed" on every push,
|
||||||
|
# which is a check that fails open (D-24).
|
||||||
|
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
|
||||||
|
|
||||||
|
.PHONY: secrets
|
||||||
|
secrets: ## Scan the commits about to be pushed for credentials
|
||||||
|
@ci/secrets.sh
|
||||||
|
|
||||||
|
# The call surface is identical in every repo; what it runs is not.
|
||||||
|
#
|
||||||
|
# `secrets` runs first, deliberately: it is the only failure here that cannot be
|
||||||
|
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
|
||||||
|
# credential is cached and indexed whether or not it is later deleted.
|
||||||
|
#
|
||||||
|
# Some of these fail today, and are left wired anyway. The state was measured
|
||||||
|
# once and written down in T-56 rather than being worked around here — a gate
|
||||||
|
# quietly narrowed to what already passes is a gate that reports success for
|
||||||
|
# doing nothing, which is the failure this workspace keeps rediscovering.
|
||||||
|
.PHONY: pre-push
|
||||||
|
pre-push: secrets ## Everything the pre-push hook runs
|
||||||
|
@echo " -- not gated here yet: lint (no linter configured) and test (T-56)"
|
||||||
@@ -19,8 +19,7 @@ Central API service providing infrastructure management, home automation, and ut
|
|||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
|
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
|
||||||
- **Health Checks**: Comprehensive service health monitoring
|
- **Health Checks**: Service health monitoring with database connectivity status
|
||||||
- **AI Metrics Proxy**: Forward metrics requests to Core-AI service
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -35,10 +34,8 @@ src/
|
|||||||
├── clients/
|
├── clients/
|
||||||
│ ├── homeassistant_client.py # Home Assistant REST client
|
│ ├── homeassistant_client.py # Home Assistant REST client
|
||||||
│ ├── npm_client.py # Nginx Proxy Manager client
|
│ ├── npm_client.py # Nginx Proxy Manager client
|
||||||
│ ├── ollama_client.py # Ollama LLM client
|
|
||||||
│ └── portainer_client.py # Portainer API client
|
│ └── portainer_client.py # Portainer API client
|
||||||
├── controllers/
|
├── controllers/
|
||||||
│ ├── ai_controller.py # AI metrics proxy
|
|
||||||
│ ├── health_controller.py # Health endpoints
|
│ ├── health_controller.py # Health endpoints
|
||||||
│ ├── housekeeping_controller.py # Home automation endpoints
|
│ ├── housekeeping_controller.py # Home automation endpoints
|
||||||
│ ├── infrastructure_controller.py # Infrastructure management
|
│ ├── infrastructure_controller.py # Infrastructure management
|
||||||
@@ -88,9 +85,6 @@ src/
|
|||||||
### Tools (`/tools`)
|
### Tools (`/tools`)
|
||||||
- `POST /tools/dns/lookup` - DNS record lookup
|
- `POST /tools/dns/lookup` - DNS record lookup
|
||||||
|
|
||||||
### AI (`/ai`)
|
|
||||||
- `GET /ai/metrics` - Proxy to Core-AI metrics
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
@@ -103,10 +97,6 @@ src/
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Copy credentials template
|
|
||||||
cp src/credentials.example.py src/credentials.py
|
|
||||||
# Edit src/credentials.py with your values
|
|
||||||
|
|
||||||
# Run locally
|
# Run locally
|
||||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||||
```
|
```
|
||||||
@@ -169,10 +159,9 @@ docker run -p 8083:8083 core-code:latest
|
|||||||
| `NPM_PASSWORD` | NPM admin password | - |
|
| `NPM_PASSWORD` | NPM admin password | - |
|
||||||
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
||||||
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
||||||
| `OLLAMA_URL` | Ollama API URL | `http://localhost:11434` |
|
|
||||||
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
||||||
| `OIDC_ISSUER` | OIDC issuer URL | - |
|
| `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
|
||||||
| `OIDC_AUDIENCE` | OIDC audience | - |
|
| `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
|
||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
@@ -183,9 +172,9 @@ Once deployed, access documentation at:
|
|||||||
|
|
||||||
## Health Checks
|
## Health Checks
|
||||||
|
|
||||||
- **Basic**: `GET /health` - Returns status and Ollama connection
|
- **Basic**: `GET /health` - Fast liveness check for container orchestration
|
||||||
- **Full**: `GET /health/full` - Returns all component statuses (503 if unhealthy)
|
- **Full**: `GET /health/full` - Returns database status (503 if unhealthy)
|
||||||
- **Diagnostics**: `GET /health/diagnostics` - Detailed service information
|
- **Diagnostics**: `GET /health/diagnostics` - Service info and configuration
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Secret scan over the commits about to be pushed.
|
||||||
|
#
|
||||||
|
# Lives here rather than inside .githooks/pre-push so it can be read, run by
|
||||||
|
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
|
||||||
|
# not a home for logic. Identical in every repo in this workspace (D-27).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
|
||||||
|
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
|
||||||
|
# Without this the scan reports "not installed" on every push.
|
||||||
|
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||||
|
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
|
||||||
|
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scan the outgoing range, not full history. History here carries findings
|
||||||
|
# that are settled — test fixtures and vendored third-party code — and a gate
|
||||||
|
# that fails on something unfixable gets bypassed within a week. What matters
|
||||||
|
# is what is about to leave this machine.
|
||||||
|
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
|
||||||
|
range="$upstream..HEAD"
|
||||||
|
elif git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||||
|
range="origin/main..HEAD"
|
||||||
|
else
|
||||||
|
range=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$range" ]; then
|
||||||
|
gitleaks dir . --redact --no-banner --exit-code 1 || {
|
||||||
|
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
|
||||||
|
|
||||||
|
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
|
||||||
|
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
|
||||||
|
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
|
||||||
|
echo " then remove and rotate it, or suppress deliberately:" >&2
|
||||||
|
echo " inline '# gitleaks:allow <reason>'" >&2
|
||||||
|
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " ok secrets"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Decisions, Questions, Rejected
|
||||||
|
|
||||||
|
This directory holds structured planning records that pql parses
|
||||||
|
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
|
||||||
|
a markdown file. Files live in three per-type subdirectories:
|
||||||
|
|
||||||
|
- `decisions/<domain>.md` — confirmed design decisions
|
||||||
|
- `questions/<domain>.md` — open questions that may resolve into
|
||||||
|
decisions or rejected proposals
|
||||||
|
- `rejected/<domain>.md` — rejected proposals (kept for the audit
|
||||||
|
trail)
|
||||||
|
|
||||||
|
The parser infers domain from the filename stem and record type
|
||||||
|
from the parent subdirectory.
|
||||||
|
|
||||||
|
D-records that propose implementation work link to `initiative`-type
|
||||||
|
tickets via `decision_ref`. Run `pql decisions show <id>
|
||||||
|
--with-tickets` to inspect implementation status.
|
||||||
|
|
||||||
|
## Recommended domains
|
||||||
|
|
||||||
|
Start with this canonical set; create files as records land in
|
||||||
|
each domain:
|
||||||
|
|
||||||
|
- **architecture** — structural commitments (storage, layering,
|
||||||
|
languages, libraries)
|
||||||
|
- **process** — team workflow (commits, branches, releases, reviews)
|
||||||
|
- **design** — user-facing surface (UX, UI, public APIs)
|
||||||
|
- **coding-conventions** — team-internal code shape (style, lint,
|
||||||
|
file layout)
|
||||||
|
- **testing** — quality strategy (coverage, layers, gates)
|
||||||
|
|
||||||
|
You might also want, project-permitting:
|
||||||
|
|
||||||
|
- `accessibility` — if you ship user-facing software
|
||||||
|
- `security` — if you handle user data or network surfaces
|
||||||
|
- `licensing` — if you release open-source or commercial
|
||||||
|
- `documentation` — if user-docs are non-trivial
|
||||||
|
- `deployment` — if shipping is non-trivial
|
||||||
|
- `performance` — if you have perf budgets / SLOs
|
||||||
|
|
||||||
|
<!-- pql:records (auto-generated; do not edit manually) -->
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- _(none)_
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- _(none)_
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- _(none)_
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
proxy_buffers 8 16k;
|
||||||
|
proxy_buffer_size 32k;
|
||||||
|
|
||||||
|
# CORS headers for Flutter web
|
||||||
|
add_header Access-Control-Allow-Origin "https://home.schweitz.net" always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||||
|
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.7.0"
|
version = "1.11.0"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -32,3 +32,6 @@ cryptography>=44.0.1 # CVE-2024-12797
|
|||||||
sqlalchemy[asyncio]~=2.0.0
|
sqlalchemy[asyncio]~=2.0.0
|
||||||
asyncpg>=0.30.0
|
asyncpg>=0.30.0
|
||||||
alembic~=1.13.0
|
alembic~=1.13.0
|
||||||
|
|
||||||
|
# Vector Database
|
||||||
|
qdrant-client>=1.9.0
|
||||||
|
|||||||
+69
-6
@@ -13,6 +13,7 @@ from src.logging_config import get_logger
|
|||||||
from src.db import get_async_session
|
from src.db import get_async_session
|
||||||
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
||||||
from src.auth.service import AuthService
|
from src.auth.service import AuthService
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -222,21 +223,83 @@ class AuthController(BaseController):
|
|||||||
responses={
|
responses={
|
||||||
200: {"description": "User profile"},
|
200: {"description": "User profile"},
|
||||||
401: {"description": "Not authenticated"},
|
401: {"description": "Not authenticated"},
|
||||||
|
404: {"description": "User not found in database"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
|
forward_auth_user: Optional[dict] = Depends(get_forward_auth_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> JSONResponse:
|
) -> AuthSyncResponse:
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user's profile
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
Note: This endpoint requires a valid session or API key.
|
Authentication is handled by NPM forward auth with Authentik.
|
||||||
For now, returns 501 Not Implemented until session management is added.
|
The proxy sets X-authentik-* headers which this endpoint reads.
|
||||||
|
|
||||||
|
For internal/LAN access (no forward auth headers), returns 401.
|
||||||
|
Use POST /auth/sync with an OIDC token for mobile app authentication.
|
||||||
"""
|
"""
|
||||||
# TODO: Implement with get_current_user dependency
|
# Require forward auth for this endpoint
|
||||||
|
if forward_auth_user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=501,
|
status_code=401,
|
||||||
detail="Not implemented - use /auth/sync with access token",
|
detail="Authentication required - access via authenticated proxy or use /auth/sync",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = AuthService(session)
|
||||||
|
|
||||||
|
# Try to find user by Authentik UID first, then by email
|
||||||
|
user = None
|
||||||
|
uid = forward_auth_user.get("uid")
|
||||||
|
if uid:
|
||||||
|
try:
|
||||||
|
import uuid
|
||||||
|
authentik_id = uuid.UUID(uid)
|
||||||
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Invalid UUID, try email
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
email = forward_auth_user.get("email")
|
||||||
|
if email:
|
||||||
|
user = await service.get_user_by_email(email)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
# User authenticated with Authentik but not synced to database yet
|
||||||
|
# This can happen on first login via web
|
||||||
|
logger.info(f"User {forward_auth_user.get('email')} not found, creating from forward auth")
|
||||||
|
|
||||||
|
# Create user from forward auth headers
|
||||||
|
from src.auth.schemas import TokenInfoSchema
|
||||||
|
token_info = TokenInfoSchema(
|
||||||
|
sub=forward_auth_user.get("uid", ""),
|
||||||
|
email=forward_auth_user.get("email", ""),
|
||||||
|
name=forward_auth_user.get("name"),
|
||||||
|
groups=forward_auth_user.get("groups", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user, _ = await service.sync_user(token_info)
|
||||||
|
await service.sync_roles(user, token_info.groups)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create user from forward auth: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to create user profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync roles from current groups (in case they changed)
|
||||||
|
groups = forward_auth_user.get("groups", [])
|
||||||
|
roles = await service.sync_roles(user, groups)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return AuthSyncResponse(
|
||||||
|
user=service.user_to_schema(user),
|
||||||
|
roles=service.roles_to_schema(roles),
|
||||||
|
preferences=service.preferences_to_schema(user.preferences),
|
||||||
|
is_new_user=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
+60
-23
@@ -22,29 +22,42 @@ class OIDCConfig:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# These will be set from environment variables in config.py
|
# These will be set from environment variables in config.py
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
self.issuer = ""
|
self.issuers: list[str] = []
|
||||||
self.audience = ""
|
self.audiences: list[str] = []
|
||||||
self.jwks_uri = ""
|
|
||||||
|
|
||||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||||
"""Configure OIDC settings"""
|
"""Configure OIDC settings"""
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.issuer = issuer
|
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||||
self.audience = audience
|
self.audiences = audiences
|
||||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
|
||||||
|
def get_jwks_uri(self, issuer: str) -> str:
|
||||||
|
"""Get JWKS URI for a specific issuer"""
|
||||||
|
return f"{issuer.rstrip('/')}/jwks/"
|
||||||
|
|
||||||
|
def is_valid_issuer(self, issuer: str) -> bool:
|
||||||
|
"""Check if issuer is in the allowed list"""
|
||||||
|
normalized = issuer.rstrip('/')
|
||||||
|
return normalized in self.issuers
|
||||||
|
|
||||||
|
|
||||||
# Global OIDC config instance
|
# Global OIDC config instance
|
||||||
oidc_config = OIDCConfig()
|
oidc_config = OIDCConfig()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
# Per-issuer JWKS cache
|
||||||
def get_jwks() -> Dict:
|
_jwks_cache: Dict[str, Dict] = {}
|
||||||
"""
|
|
||||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
|
||||||
|
|
||||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
|
||||||
|
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||||
|
|
||||||
|
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issuer: The token issuer URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JWKS dictionary containing public keys for token verification
|
JWKS dictionary containing public keys for token verification
|
||||||
@@ -55,15 +68,24 @@ def get_jwks() -> Dict:
|
|||||||
if not oidc_config.enabled:
|
if not oidc_config.enabled:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_issuer = issuer.rstrip('/')
|
||||||
|
|
||||||
|
# Return cached JWKS if available
|
||||||
|
if normalized_issuer in _jwks_cache:
|
||||||
|
return _jwks_cache[normalized_issuer]
|
||||||
|
|
||||||
|
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
response = httpx.get(jwks_uri, timeout=10.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
jwks = response.json()
|
jwks = response.json()
|
||||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||||
|
_jwks_cache[normalized_issuer] = jwks
|
||||||
return jwks
|
return jwks
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch JWKS: {e}")
|
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Authentication service unavailable"
|
detail="Authentication service unavailable"
|
||||||
@@ -109,6 +131,21 @@ async def get_current_user(
|
|||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# First, extract issuer and audience from unverified claims
|
||||||
|
unverified_claims = jwt.get_unverified_claims(token)
|
||||||
|
token_issuer = unverified_claims.get("iss", "")
|
||||||
|
token_audience = unverified_claims.get("aud", "")
|
||||||
|
|
||||||
|
# Validate issuer is in our allowed list
|
||||||
|
if not oidc_config.is_valid_issuer(token_issuer):
|
||||||
|
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||||
|
|
||||||
|
# Validate audience is in our allowed list
|
||||||
|
if token_audience not in oidc_config.audiences:
|
||||||
|
logger.warning(f"Invalid token audience: {token_audience}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||||
|
|
||||||
# Decode token header to get key ID
|
# Decode token header to get key ID
|
||||||
unverified_header = jwt.get_unverified_header(token)
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
kid = unverified_header.get("kid")
|
kid = unverified_header.get("kid")
|
||||||
@@ -116,8 +153,8 @@ async def get_current_user(
|
|||||||
if not kid:
|
if not kid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||||
|
|
||||||
# Find matching key in JWKS
|
# Find matching key in JWKS for this specific issuer
|
||||||
jwks = get_jwks()
|
jwks = get_jwks_for_issuer(token_issuer)
|
||||||
rsa_key = None
|
rsa_key = None
|
||||||
|
|
||||||
for key in jwks.get("keys", []):
|
for key in jwks.get("keys", []):
|
||||||
@@ -129,17 +166,17 @@ async def get_current_user(
|
|||||||
logger.warning(f"No matching key found for kid: {kid}")
|
logger.warning(f"No matching key found for kid: {kid}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||||
|
|
||||||
# Verify and decode token
|
# Verify and decode token using the token's actual issuer and audience
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
rsa_key,
|
rsa_key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=oidc_config.audience,
|
audience=token_audience, # Use the token's audience (already validated)
|
||||||
issuer=oidc_config.issuer,
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_email = payload.get("email", "unknown")
|
user_email = payload.get("email", "unknown")
|
||||||
logger.info(f"Authenticated user: {user_email}")
|
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -13,7 +13,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.db.models import User, Role, UserPreferences, Group
|
from src.db.models import User, Role, UserPreferences, Group
|
||||||
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
||||||
@@ -86,6 +86,42 @@ class AuthService:
|
|||||||
logger.error(f"Authentik userinfo request error: {e}")
|
logger.error(f"Authentik userinfo request error: {e}")
|
||||||
raise ValueError("Authentication service unavailable")
|
raise ValueError("Authentication service unavailable")
|
||||||
|
|
||||||
|
async def get_user_by_email(self, email: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by email address
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.email == email)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get user by Authentik UUID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authentik_id: Authentik user UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||||
"""
|
"""
|
||||||
Create or update user from OIDC token info
|
Create or update user from OIDC token info
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import httpx
|
|||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import httpx
|
|||||||
import json
|
import json
|
||||||
from typing import Optional, Dict, List, Any
|
from typing import Optional, Dict, List, Any
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
-160
@@ -1,160 +0,0 @@
|
|||||||
"""
|
|
||||||
Global configuration for Core Code API
|
|
||||||
|
|
||||||
All configuration is loaded from environment variables or .env file.
|
|
||||||
See .env.example for available settings.
|
|
||||||
"""
|
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
|
|
||||||
def _get_version_from_pyproject() -> str:
|
|
||||||
"""Load version from pyproject.toml"""
|
|
||||||
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
|
||||||
try:
|
|
||||||
with open(pyproject_path, "rb") as f:
|
|
||||||
data = tomllib.load(f)
|
|
||||||
return data.get("project", {}).get("version", "0.0.0")
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "0.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
__version__ = _get_version_from_pyproject()
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Global application settings"""
|
|
||||||
|
|
||||||
# Application
|
|
||||||
app_name: str = "Core Code API"
|
|
||||||
app_version: str = __version__
|
|
||||||
debug: bool = False
|
|
||||||
|
|
||||||
# Server
|
|
||||||
host: str = "0.0.0.0"
|
|
||||||
port: int = 8083
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
cors_origins: list[str] = ["*"]
|
|
||||||
cors_credentials: bool = True
|
|
||||||
cors_methods: list[str] = ["*"]
|
|
||||||
cors_headers: list[str] = ["*"]
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log_level: str = "DEBUG"
|
|
||||||
|
|
||||||
# Ollama Configuration (for AI orchestration)
|
|
||||||
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
|
|
||||||
ollama_timeout: int = 300 # 5 minutes
|
|
||||||
|
|
||||||
# Model Configuration
|
|
||||||
default_model: str = "mistral-nemo-large:latest"
|
|
||||||
agent_model: str = "mistral-nemo-large:latest" # Must support tool calling with ADK (~4GB VRAM)
|
|
||||||
code_models: str = "mistral-nemo-large:latest"
|
|
||||||
# Previous config (gemma3:12b used ~10GB VRAM)
|
|
||||||
# default_model: str = "gemma3:12b"
|
|
||||||
# agent_model: str = "gemma3:12b"
|
|
||||||
|
|
||||||
# System Prompt Variant (for A/B testing)
|
|
||||||
# Options: v1_verbose, v2_concise, v3_imperative, v4_minimal, v4_gemini_suggestion, v5_adk_optimized, v7_adk_best_practice, v8_holistic
|
|
||||||
system_prompt_variant: str = "v8_holistic"
|
|
||||||
|
|
||||||
# Agent Configuration
|
|
||||||
agent_fallback_enabled: bool = True
|
|
||||||
|
|
||||||
# Model Aliases (OpenAI → Local)
|
|
||||||
alias_gpt35: str = "gemma:7b"
|
|
||||||
alias_gpt4: str = "mistral:7b"
|
|
||||||
alias_gpt4_turbo: str = "mixtral:8x7b"
|
|
||||||
alias_gpt4_code: str = "codestral:latest"
|
|
||||||
|
|
||||||
# Memory Configuration
|
|
||||||
memory_tier1_max_turns: int = 10
|
|
||||||
memory_consolidation_threshold: int = 10
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
qdrant_host: str = "qdrant"
|
|
||||||
qdrant_port: int = 6333
|
|
||||||
qdrant_collection_conversations: str = "core_api_conversations"
|
|
||||||
qdrant_collection_documents: str = "core_api_documents"
|
|
||||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
|
||||||
|
|
||||||
# Embeddings (using Ollama - no local models needed)
|
|
||||||
embedding_model: str = "nomic-embed-text" # Ollama embedding model
|
|
||||||
embedding_dimension: int = 768 # nomic-embed-text dimension
|
|
||||||
embedding_batch_size: int = 32
|
|
||||||
|
|
||||||
# Search Configuration
|
|
||||||
search_provider: str = "searxng"
|
|
||||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
|
||||||
|
|
||||||
# Infrastructure Management (Portainer)
|
|
||||||
portainer_url: str # Required - set PORTAINER_URL in .env
|
|
||||||
portainer_api_key: str # Required - set PORTAINER_API_KEY in .env
|
|
||||||
|
|
||||||
# Infrastructure Management (Nginx Proxy Manager)
|
|
||||||
npm_url: str # Required - set NPM_URL in .env
|
|
||||||
npm_email: str # Required - set NPM_EMAIL in .env
|
|
||||||
npm_password: str # Required - set NPM_PASSWORD in .env
|
|
||||||
|
|
||||||
# Home Assistant Configuration
|
|
||||||
homeassistant_url: str # Required - set HOMEASSISTANT_URL in .env
|
|
||||||
homeassistant_token: str # Required - set HOMEASSISTANT_TOKEN in .env
|
|
||||||
homeassistant_timeout: int = 30
|
|
||||||
|
|
||||||
# PostgreSQL Database
|
|
||||||
postgres_host: str # Required - set POSTGRES_HOST in .env (e.g., localhost:5432)
|
|
||||||
postgres_user: str = "core_api"
|
|
||||||
postgres_password: str # Required - set POSTGRES_PASSWORD in .env
|
|
||||||
postgres_database: str = "core_api"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database_url(self) -> str:
|
|
||||||
"""Construct database URL from components"""
|
|
||||||
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
|
|
||||||
|
|
||||||
# OIDC Authentication (Authentik)
|
|
||||||
oidc_enabled: bool = False # Set to True to require authentication
|
|
||||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
|
||||||
oidc_audience: str = "core-api"
|
|
||||||
|
|
||||||
# Authentik API (for token validation and user management)
|
|
||||||
# Must use domain name (not IP) when AUTHENTIK_COOKIE_DOMAIN is set
|
|
||||||
authentik_url: str = "https://auth.schweitz.net" # Authentik base URL
|
|
||||||
authentik_username: str = "" # Admin username for API access (AUTHENTIK_USERNAME env var)
|
|
||||||
authentik_password: str = "" # Admin password for API access (AUTHENTIK_PASSWORD env var)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_aliases(self) -> dict:
|
|
||||||
"""Computed property for model aliases"""
|
|
||||||
return {
|
|
||||||
"gpt-3.5-turbo": self.alias_gpt35,
|
|
||||||
"gpt-4": self.alias_gpt4,
|
|
||||||
"gpt-4-turbo": self.alias_gpt4_turbo,
|
|
||||||
"gpt-4-code": self.alias_gpt4_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_lightweight_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated lightweight models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.lightweight_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
def get_heavy_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated heavy models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.heavy_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
def get_code_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated code models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
case_sensitive = False
|
|
||||||
extra = "ignore" # Ignore extra env vars not defined in Settings
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Cached settings instance"""
|
|
||||||
return Settings()
|
|
||||||
@@ -7,13 +7,11 @@ from fastapi import APIRouter, Response
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from src.controllers.base import BaseController
|
from src.controllers.base import BaseController
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.models.ollama_client import get_ollama_client
|
|
||||||
from src.db import get_database
|
from src.db import get_database
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,63 +71,18 @@ class HealthController(BaseController):
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/health/full",
|
"/health/full",
|
||||||
summary="Fast health check for Docker",
|
summary="Full health check with database",
|
||||||
)
|
)
|
||||||
async def full_health_check(response: Response):
|
async def full_health_check(response: Response):
|
||||||
"""
|
"""
|
||||||
Fast health check for container orchestration (Docker/K8s).
|
Health check including database connectivity.
|
||||||
|
|
||||||
Checks component availability WITHOUT running expensive operations.
|
Returns 200 OK if database is available, otherwise 503.
|
||||||
Returns 200 OK if all components are available, otherwise 503.
|
|
||||||
|
|
||||||
For detailed diagnostics, use /health/diagnostics instead.
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Check 1: Ollama connection + verify agent model is available
|
# Check database connection
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
ollama_healthy = False
|
|
||||||
ollama_error = None
|
|
||||||
model_available = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ping Ollama
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
|
|
||||||
# Verify the agent model is pulled and check what's currently loaded
|
|
||||||
models_info = {}
|
|
||||||
if ollama_healthy:
|
|
||||||
try:
|
|
||||||
models_response = await ollama_client.list_models()
|
|
||||||
available_models = [m.get('name', '') for m in models_response.get('models', [])]
|
|
||||||
model_available = settings.agent_model in available_models
|
|
||||||
|
|
||||||
# Get info about currently loaded models (those with size in memory)
|
|
||||||
loaded_models = [
|
|
||||||
m.get('name', '') for m in models_response.get('models', [])
|
|
||||||
if m.get('size', 0) > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
models_info = {
|
|
||||||
"configured": settings.agent_model,
|
|
||||||
"available": model_available,
|
|
||||||
"total_in_ollama": len(available_models),
|
|
||||||
"currently_loaded": loaded_models if loaded_models else ["none"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if not model_available:
|
|
||||||
ollama_error = f"Model '{settings.agent_model}' not found in Ollama. Available: {', '.join(available_models[:3])}"
|
|
||||||
ollama_healthy = False
|
|
||||||
except Exception as e:
|
|
||||||
ollama_error = f"Could not list Ollama models: {str(e)}"
|
|
||||||
ollama_healthy = False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
ollama_error = str(e)
|
|
||||||
logger.warning(f"Ollama health check failed: {ollama_error}")
|
|
||||||
|
|
||||||
# Check 2: Database connection
|
|
||||||
database = get_database()
|
database = get_database()
|
||||||
db_healthy = False
|
db_healthy = False
|
||||||
db_error = None
|
db_error = None
|
||||||
@@ -140,27 +93,17 @@ class HealthController(BaseController):
|
|||||||
db_error = str(e)
|
db_error = str(e)
|
||||||
logger.warning(f"Database health check failed: {db_error}")
|
logger.warning(f"Database health check failed: {db_error}")
|
||||||
|
|
||||||
is_healthy = ollama_healthy and db_healthy
|
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
status_code = 200 if is_healthy else 503
|
status_code = 200 if db_healthy else 503
|
||||||
response.status_code = status_code
|
response.status_code = status_code
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy" if is_healthy else "unhealthy",
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
"response_time_ms": elapsed_ms,
|
"response_time_ms": elapsed_ms,
|
||||||
"components": {
|
"components": {
|
||||||
"ollama": {
|
|
||||||
"status": "✅ healthy" if ollama_healthy else "❌ unhealthy",
|
|
||||||
"models": models_info if models_info else {
|
|
||||||
"configured": settings.agent_model,
|
|
||||||
"available": False
|
|
||||||
},
|
|
||||||
"error": ollama_error
|
|
||||||
},
|
|
||||||
"database": {
|
"database": {
|
||||||
"status": "✅ healthy" if db_healthy else "❌ unhealthy",
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
"error": db_error
|
"error": db_error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,18 +113,13 @@ class HealthController(BaseController):
|
|||||||
"/health/diagnostics",
|
"/health/diagnostics",
|
||||||
summary="Detailed system diagnostics",
|
summary="Detailed system diagnostics",
|
||||||
)
|
)
|
||||||
async def diagnostics(deep_test: bool = False):
|
async def diagnostics():
|
||||||
"""
|
"""
|
||||||
Comprehensive system diagnostics with detailed component information.
|
System diagnostics with service information.
|
||||||
|
|
||||||
Query Parameters:
|
|
||||||
- deep_test: Set to true to actually test agent generation (slow, ~5-10s)
|
|
||||||
|
|
||||||
Returns detailed information about all system components.
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
diagnostics = {
|
diagnostics = {
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"service": {
|
"service": {
|
||||||
@@ -189,31 +127,10 @@ class HealthController(BaseController):
|
|||||||
"version": settings.app_version,
|
"version": settings.app_version,
|
||||||
"purpose": "Infrastructure management and tools API"
|
"purpose": "Infrastructure management and tools API"
|
||||||
},
|
},
|
||||||
"components": {}
|
"configuration": {
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Ollama Connection
|
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
try:
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
diagnostics["components"]["ollama"] = {
|
|
||||||
"status": "✅ connected",
|
|
||||||
"url": settings.ollama_base_url,
|
|
||||||
"timeout": settings.ollama_timeout,
|
|
||||||
"default_model": settings.default_model
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
diagnostics["components"]["ollama"] = {
|
|
||||||
"status": "❌ error",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Configuration
|
|
||||||
diagnostics["configuration"] = {
|
|
||||||
"agent_fallback_enabled": settings.agent_fallback_enabled,
|
|
||||||
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
|
|
||||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
diagnostics["response_time_ms"] = elapsed_ms
|
diagnostics["response_time_ms"] = elapsed_ms
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ Tools Controller
|
|||||||
|
|
||||||
Provides utility tool endpoints including:
|
Provides utility tool endpoints including:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
|
- Environment data (weather, forecast, sun times, air quality)
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from src.controllers.base import BaseController
|
from src.controllers.base import BaseController
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||||
from src.dns.service import DNSService
|
from src.dns.service import DNSService
|
||||||
from src.dns.exceptions import DNSQueryError
|
from src.dns.exceptions import DNSQueryError
|
||||||
|
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||||
|
from src.domains.tools.environment.service import get_environment_service
|
||||||
|
from src.auth.oidc import get_optional_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ class ToolsController(BaseController):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(prefix="/tools", tags=["Tools"])
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
|
self.environment_service = get_environment_service()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -95,6 +102,60 @@ class ToolsController(BaseController):
|
|||||||
detail="An unexpected error occurred during DNS lookup"
|
detail="An unexpected error occurred during DNS lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/environment",
|
||||||
|
response_model=EnvironmentResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get environment data",
|
||||||
|
description="""
|
||||||
|
Fetch current environment data including weather, forecast, sun times,
|
||||||
|
and optionally air quality.
|
||||||
|
|
||||||
|
Data is retrieved from the user's volatile Qdrant collection which is
|
||||||
|
populated by background data collectors.
|
||||||
|
|
||||||
|
**Data Sources:**
|
||||||
|
- Weather: Current temperature, conditions, humidity, wind
|
||||||
|
- Forecast: Multi-day weather outlook
|
||||||
|
- Sun Times: Sunrise, sunset, daylight duration
|
||||||
|
- Air Quality: AQI and pollutant levels (when available)
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- Uses authenticated user's `preferred_username` if available
|
||||||
|
- Falls back to 'default' for unauthenticated requests
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_environment(
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
) -> EnvironmentResponse:
|
||||||
|
"""
|
||||||
|
Get current environment data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Optional authenticated user info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Environment data with weather, forecast, sun times, and air quality
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Determine user identifier
|
||||||
|
user_id = "default"
|
||||||
|
if user:
|
||||||
|
logger.debug(f"User claims: {user}")
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id} (preferred_username={user.get('preferred_username')}, sub={user.get('sub')})")
|
||||||
|
else:
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id} (no auth)")
|
||||||
|
result = await self.environment_service.get_current(user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching environment data: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to fetch environment data"
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from src.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from src.domains.auth.schemas import (
|
|||||||
ApiKeysListResponse,
|
ApiKeysListResponse,
|
||||||
)
|
)
|
||||||
from src.domains.auth.service import AuthService
|
from src.domains.auth.service import AuthService
|
||||||
from src.domains.auth.oidc import get_current_user
|
from src.domains.auth.oidc import get_current_user, get_current_user_or_forward_auth
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -322,22 +322,26 @@ class AuthController(BaseController):
|
|||||||
responses={
|
responses={
|
||||||
200: {"description": "User profile with roles and preferences"},
|
200: {"description": "User profile with roles and preferences"},
|
||||||
401: {"description": "Not authenticated"},
|
401: {"description": "Not authenticated"},
|
||||||
404: {"description": "User not found in database"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_current_user_profile(
|
async def get_current_user_profile(
|
||||||
user_claims: dict = Depends(get_current_user),
|
user_claims: dict = Depends(get_current_user_or_forward_auth),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> UserProfileResponse:
|
) -> UserProfileResponse:
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user's profile
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
Returns the user's profile, roles, and preferences.
|
Returns the user's profile, roles, and preferences.
|
||||||
Requires authentication via Bearer token or API key.
|
Supports both:
|
||||||
|
- Bearer token (mobile/native clients)
|
||||||
|
- NPM forward auth headers (web clients via proxy)
|
||||||
|
|
||||||
|
For forward auth users, auto-creates the user in the database
|
||||||
|
if they don't exist yet (first login via web).
|
||||||
"""
|
"""
|
||||||
service = AuthService(session)
|
service = AuthService(session)
|
||||||
|
|
||||||
# Get authentik_id from claims (JWT 'sub' field)
|
# Get authentik_id from claims (JWT 'sub' field or forward auth 'uid')
|
||||||
authentik_id_str = user_claims.get("sub")
|
authentik_id_str = user_claims.get("sub")
|
||||||
if not authentik_id_str or authentik_id_str == "local-user":
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
@@ -348,7 +352,23 @@ class AuthController(BaseController):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
user = await service.get_user_by_authentik_id(authentik_id)
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
|
||||||
|
# If user not found and using forward auth, auto-create them
|
||||||
if user is None:
|
if user is None:
|
||||||
|
auth_method = user_claims.get("auth_method")
|
||||||
|
if auth_method == "forward_auth":
|
||||||
|
# Auto-sync user from forward auth headers
|
||||||
|
logger.info(f"Auto-creating user from forward auth: {user_claims.get('email')}")
|
||||||
|
user, is_new = await service.sync_user_from_claims(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=user_claims.get("email", ""),
|
||||||
|
name=user_claims.get("name", user_claims.get("preferred_username", "")),
|
||||||
|
groups=user_claims.get("groups", []),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
else:
|
||||||
|
# JWT auth but user not in DB - they need to sync first
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="User not found - please sync via /auth/sync first",
|
detail="User not found - please sync via /auth/sync first",
|
||||||
|
|||||||
+116
-28
@@ -12,7 +12,6 @@ Permission Format: domain.category:action
|
|||||||
Examples:
|
Examples:
|
||||||
- control-room.general:admin - Full access to Control Room
|
- control-room.general:admin - Full access to Control Room
|
||||||
- media.general:viewer - View-only access to Media area
|
- media.general:viewer - View-only access to Media area
|
||||||
- ai.ollama:user - User-level access to Ollama specifically (future)
|
|
||||||
|
|
||||||
Action Hierarchy (higher implies lower):
|
Action Hierarchy (higher implies lower):
|
||||||
- admin > editor > user > viewer
|
- admin > editor > user > viewer
|
||||||
@@ -64,29 +63,42 @@ class OIDCConfig:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# These will be set from environment variables in config.py
|
# These will be set from environment variables in config.py
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
self.issuer = ""
|
self.issuers: list[str] = []
|
||||||
self.audience = ""
|
self.audiences: list[str] = []
|
||||||
self.jwks_uri = ""
|
|
||||||
|
|
||||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||||
"""Configure OIDC settings"""
|
"""Configure OIDC settings"""
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.issuer = issuer
|
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||||
self.audience = audience
|
self.audiences = audiences
|
||||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
|
||||||
|
def get_jwks_uri(self, issuer: str) -> str:
|
||||||
|
"""Get JWKS URI for a specific issuer"""
|
||||||
|
return f"{issuer.rstrip('/')}/jwks/"
|
||||||
|
|
||||||
|
def is_valid_issuer(self, issuer: str) -> bool:
|
||||||
|
"""Check if issuer is in the allowed list"""
|
||||||
|
normalized = issuer.rstrip('/')
|
||||||
|
return normalized in self.issuers
|
||||||
|
|
||||||
|
|
||||||
# Global OIDC config instance
|
# Global OIDC config instance
|
||||||
oidc_config = OIDCConfig()
|
oidc_config = OIDCConfig()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
# Per-issuer JWKS cache
|
||||||
def get_jwks() -> Dict:
|
_jwks_cache: Dict[str, Dict] = {}
|
||||||
"""
|
|
||||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
|
||||||
|
|
||||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
|
||||||
|
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||||
|
|
||||||
|
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issuer: The token issuer URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JWKS dictionary containing public keys for token verification
|
JWKS dictionary containing public keys for token verification
|
||||||
@@ -97,15 +109,24 @@ def get_jwks() -> Dict:
|
|||||||
if not oidc_config.enabled:
|
if not oidc_config.enabled:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_issuer = issuer.rstrip('/')
|
||||||
|
|
||||||
|
# Return cached JWKS if available
|
||||||
|
if normalized_issuer in _jwks_cache:
|
||||||
|
return _jwks_cache[normalized_issuer]
|
||||||
|
|
||||||
|
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
response = httpx.get(jwks_uri, timeout=10.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
jwks = response.json()
|
jwks = response.json()
|
||||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||||
|
_jwks_cache[normalized_issuer] = jwks
|
||||||
return jwks
|
return jwks
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch JWKS: {e}")
|
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Authentication service unavailable"
|
detail="Authentication service unavailable"
|
||||||
@@ -158,15 +179,30 @@ async def get_current_user(
|
|||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Decode token header to get key ID
|
# First, decode token without verification to get issuer and key ID
|
||||||
unverified_header = jwt.get_unverified_header(token)
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
|
unverified_claims = jwt.get_unverified_claims(token)
|
||||||
|
|
||||||
kid = unverified_header.get("kid")
|
kid = unverified_header.get("kid")
|
||||||
|
token_issuer = unverified_claims.get("iss", "")
|
||||||
|
token_audience = unverified_claims.get("aud", "")
|
||||||
|
|
||||||
if not kid:
|
if not kid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||||
|
|
||||||
# Find matching key in JWKS
|
# Validate issuer is in allowed list
|
||||||
jwks = get_jwks()
|
logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}")
|
||||||
|
if not oidc_config.is_valid_issuer(token_issuer):
|
||||||
|
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||||
|
|
||||||
|
# Validate audience is in allowed list
|
||||||
|
if token_audience not in oidc_config.audiences:
|
||||||
|
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||||
|
|
||||||
|
# Get JWKS for this specific issuer
|
||||||
|
jwks = get_jwks_for_issuer(token_issuer)
|
||||||
rsa_key = None
|
rsa_key = None
|
||||||
|
|
||||||
for key in jwks.get("keys", []):
|
for key in jwks.get("keys", []):
|
||||||
@@ -178,17 +214,17 @@ async def get_current_user(
|
|||||||
logger.warning(f"No matching key found for kid: {kid}")
|
logger.warning(f"No matching key found for kid: {kid}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||||
|
|
||||||
# Verify and decode token
|
# Verify and decode token using the token's actual issuer and audience
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
rsa_key,
|
rsa_key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
audience=oidc_config.audience,
|
audience=token_audience, # Use the token's audience (already validated)
|
||||||
issuer=oidc_config.issuer,
|
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||||
)
|
)
|
||||||
|
|
||||||
user_email = payload.get("email", "unknown")
|
user_email = payload.get("email", "unknown")
|
||||||
logger.info(f"Authenticated user: {user_email}")
|
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -286,12 +322,14 @@ async def get_optional_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not credentials:
|
if not credentials:
|
||||||
|
logger.debug("No credentials provided for optional auth")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await get_current_user(credentials)
|
return await get_current_user(credentials)
|
||||||
except HTTPException:
|
except HTTPException as e:
|
||||||
# Invalid token - return None instead of raising
|
# Invalid token - log and return None instead of raising
|
||||||
|
logger.warning(f"Optional auth failed: {e.detail}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -396,6 +434,56 @@ async def get_forward_auth_admin(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_forward_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Combined auth: Try forward auth headers first, then JWT Bearer token.
|
||||||
|
|
||||||
|
Supports both:
|
||||||
|
- Web clients via NPM forward auth (X-authentik-* headers from proxy)
|
||||||
|
- Mobile/native clients via OIDC JWT Bearer tokens
|
||||||
|
|
||||||
|
This is the preferred dependency for /auth/users/me and similar endpoints
|
||||||
|
that need to work with both web (cookie-based via NPM) and mobile (token-based).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object containing headers
|
||||||
|
credentials: HTTP Bearer token from Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims dictionary with at minimum: sub, email, name, groups, auth_method
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 401: If neither forward auth headers nor valid JWT provided
|
||||||
|
"""
|
||||||
|
# 1. Try forward auth headers first (web via NPM)
|
||||||
|
username = request.headers.get("x-authentik-username")
|
||||||
|
email = request.headers.get("x-authentik-email")
|
||||||
|
|
||||||
|
if username and email:
|
||||||
|
# Forward auth headers present - use them
|
||||||
|
groups = request.headers.get("x-authentik-groups", "")
|
||||||
|
name = request.headers.get("x-authentik-name", username)
|
||||||
|
uid = request.headers.get("x-authentik-uid")
|
||||||
|
|
||||||
|
user_info = {
|
||||||
|
"sub": uid, # Use authentik UID as subject (for user lookup)
|
||||||
|
"email": email,
|
||||||
|
"preferred_username": username,
|
||||||
|
"name": name,
|
||||||
|
"groups": [g.strip() for g in groups.split(",")] if groups else [],
|
||||||
|
"auth_method": "forward_auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Authenticated via forward auth: {email}")
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
# 2. Fall back to JWT Bearer token (mobile/native)
|
||||||
|
return await get_current_user(credentials)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Permission-Based Access Control
|
# Permission-Based Access Control
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -504,7 +592,7 @@ def _extract_permissions_from_groups(groups: List[str]) -> List[str]:
|
|||||||
Examples:
|
Examples:
|
||||||
- tatlock-control-room-general-admin -> control-room.general:admin
|
- tatlock-control-room-general-admin -> control-room.general:admin
|
||||||
- tatlock-media-viewer -> media.general:viewer (shorthand)
|
- tatlock-media-viewer -> media.general:viewer (shorthand)
|
||||||
- tatlock-ai-ollama-user -> ai.ollama:user
|
- tatlock-tools-dns-user -> tools.dns:user
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
groups: List of Authentik group names
|
groups: List of Authentik group names
|
||||||
|
|||||||
@@ -102,7 +102,13 @@ class AuthService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (User, is_new_user)
|
Tuple of (User, is_new_user)
|
||||||
"""
|
"""
|
||||||
|
# Parse authentik_id - may be UUID or other format
|
||||||
|
try:
|
||||||
authentik_id = uuid.UUID(token_info.sub)
|
authentik_id = uuid.UUID(token_info.sub)
|
||||||
|
except ValueError:
|
||||||
|
# If sub is not a valid UUID, derive one deterministically
|
||||||
|
logger.warning(f"sub claim is not a UUID: {token_info.sub}, deriving UUID")
|
||||||
|
authentik_id = uuid.uuid5(uuid.NAMESPACE_OID, token_info.sub)
|
||||||
|
|
||||||
# Try to find existing user
|
# Try to find existing user
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -124,11 +130,14 @@ class AuthService:
|
|||||||
avatar_url=token_info.picture,
|
avatar_url=token_info.picture,
|
||||||
last_login=datetime.now(timezone.utc),
|
last_login=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
# Initialize relationships to avoid lazy loading issues in async
|
||||||
|
user.roles = []
|
||||||
self.session.add(user)
|
self.session.add(user)
|
||||||
await self.session.flush() # Get the user ID
|
await self.session.flush() # Get the user ID
|
||||||
|
|
||||||
# Create default preferences
|
# Create default preferences and attach to user
|
||||||
preferences = UserPreferences(user_id=user.id)
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
user.preferences = preferences
|
||||||
self.session.add(preferences)
|
self.session.add(preferences)
|
||||||
|
|
||||||
logger.info(f"Created new user: {token_info.email}")
|
logger.info(f"Created new user: {token_info.email}")
|
||||||
@@ -144,6 +153,74 @@ class AuthService:
|
|||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return user, is_new
|
return user, is_new
|
||||||
|
|
||||||
|
async def sync_user_from_claims(
|
||||||
|
self,
|
||||||
|
authentik_id: uuid.UUID,
|
||||||
|
email: str,
|
||||||
|
name: str,
|
||||||
|
groups: list[str],
|
||||||
|
avatar_url: Optional[str] = None,
|
||||||
|
) -> tuple[User, bool]:
|
||||||
|
"""
|
||||||
|
Create or update user from forward auth claims (NPM X-authentik-* headers)
|
||||||
|
|
||||||
|
This is similar to sync_user() but works with raw claims instead of
|
||||||
|
TokenInfoSchema. Used for auto-syncing users on first web login via NPM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authentik_id: The Authentik user UUID (from X-authentik-uid)
|
||||||
|
email: User email (from X-authentik-email)
|
||||||
|
name: User display name (from X-authentik-name)
|
||||||
|
groups: List of group names (from X-authentik-groups)
|
||||||
|
avatar_url: Optional avatar URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (User, is_new_user)
|
||||||
|
"""
|
||||||
|
# Try to find existing user
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
is_new = user is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=email,
|
||||||
|
name=name or email,
|
||||||
|
avatar_url=avatar_url,
|
||||||
|
last_login=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self.session.add(user)
|
||||||
|
await self.session.flush() # Get the user ID
|
||||||
|
|
||||||
|
# Create default preferences
|
||||||
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
self.session.add(preferences)
|
||||||
|
|
||||||
|
logger.info(f"Created new user from forward auth: {email}")
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
user.email = email
|
||||||
|
user.name = name or email
|
||||||
|
if avatar_url:
|
||||||
|
user.avatar_url = avatar_url
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.info(f"Updated existing user from forward auth: {email}")
|
||||||
|
|
||||||
|
# Sync roles from groups
|
||||||
|
await self.sync_roles(user, groups)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return user, is_new
|
||||||
|
|
||||||
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
||||||
"""
|
"""
|
||||||
Synchronize user roles from Authentik groups via group_roles mapping
|
Synchronize user roles from Authentik groups via group_roles mapping
|
||||||
|
|||||||
@@ -70,66 +70,18 @@ class HealthController(BaseController):
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/health/full",
|
"/health/full",
|
||||||
summary="Fast health check for Docker",
|
summary="Full health check with database",
|
||||||
)
|
)
|
||||||
async def full_health_check(response: Response):
|
async def full_health_check(response: Response):
|
||||||
"""
|
"""
|
||||||
Fast health check for container orchestration (Docker/K8s).
|
Health check including database connectivity.
|
||||||
|
|
||||||
Checks component availability WITHOUT running expensive operations.
|
Returns 200 OK if database is available, otherwise 503.
|
||||||
Returns 200 OK if all components are available, otherwise 503.
|
|
||||||
|
|
||||||
For detailed diagnostics, use /health/diagnostics instead.
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
# Check database connection
|
||||||
from src.models.ollama_client import get_ollama_client
|
|
||||||
|
|
||||||
# Check 1: Ollama connection + verify agent model is available
|
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
ollama_healthy = False
|
|
||||||
ollama_error = None
|
|
||||||
model_available = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ping Ollama
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
|
|
||||||
# Verify the agent model is pulled and check what's currently loaded
|
|
||||||
models_info = {}
|
|
||||||
if ollama_healthy:
|
|
||||||
try:
|
|
||||||
models_response = await ollama_client.list_models()
|
|
||||||
available_models = [m.get('name', '') for m in models_response.get('models', [])]
|
|
||||||
model_available = settings.agent_model in available_models
|
|
||||||
|
|
||||||
# Get info about currently loaded models (those with size in memory)
|
|
||||||
loaded_models = [
|
|
||||||
m.get('name', '') for m in models_response.get('models', [])
|
|
||||||
if m.get('size', 0) > 0
|
|
||||||
]
|
|
||||||
|
|
||||||
models_info = {
|
|
||||||
"configured": settings.agent_model,
|
|
||||||
"available": model_available,
|
|
||||||
"total_in_ollama": len(available_models),
|
|
||||||
"currently_loaded": loaded_models if loaded_models else ["none"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if not model_available:
|
|
||||||
ollama_error = f"Model '{settings.agent_model}' not found in Ollama. Available: {', '.join(available_models[:3])}"
|
|
||||||
ollama_healthy = False
|
|
||||||
except Exception as e:
|
|
||||||
ollama_error = f"Could not list Ollama models: {str(e)}"
|
|
||||||
ollama_healthy = False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
ollama_error = str(e)
|
|
||||||
logger.warning(f"Ollama health check failed: {ollama_error}")
|
|
||||||
|
|
||||||
# Check 2: Database connection
|
|
||||||
database = get_database()
|
database = get_database()
|
||||||
db_healthy = False
|
db_healthy = False
|
||||||
db_error = None
|
db_error = None
|
||||||
@@ -140,25 +92,15 @@ class HealthController(BaseController):
|
|||||||
db_error = str(e)
|
db_error = str(e)
|
||||||
logger.warning(f"Database health check failed: {db_error}")
|
logger.warning(f"Database health check failed: {db_error}")
|
||||||
|
|
||||||
is_healthy = ollama_healthy and db_healthy
|
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
status_code = 200 if is_healthy else 503
|
status_code = 200 if db_healthy else 503
|
||||||
response.status_code = status_code
|
response.status_code = status_code
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy" if is_healthy else "unhealthy",
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
"response_time_ms": elapsed_ms,
|
"response_time_ms": elapsed_ms,
|
||||||
"components": {
|
"components": {
|
||||||
"ollama": {
|
|
||||||
"status": "healthy" if ollama_healthy else "unhealthy",
|
|
||||||
"models": models_info if models_info else {
|
|
||||||
"configured": settings.agent_model,
|
|
||||||
"available": False
|
|
||||||
},
|
|
||||||
"error": ollama_error
|
|
||||||
},
|
|
||||||
"database": {
|
"database": {
|
||||||
"status": "healthy" if db_healthy else "unhealthy",
|
"status": "healthy" if db_healthy else "unhealthy",
|
||||||
"error": db_error
|
"error": db_error
|
||||||
@@ -170,19 +112,13 @@ class HealthController(BaseController):
|
|||||||
"/health/diagnostics",
|
"/health/diagnostics",
|
||||||
summary="Detailed system diagnostics",
|
summary="Detailed system diagnostics",
|
||||||
)
|
)
|
||||||
async def diagnostics(deep_test: bool = False):
|
async def diagnostics():
|
||||||
"""
|
"""
|
||||||
Comprehensive system diagnostics with detailed component information.
|
System diagnostics with service information.
|
||||||
|
|
||||||
Query Parameters:
|
|
||||||
- deep_test: Set to true to actually test agent generation (slow, ~5-10s)
|
|
||||||
|
|
||||||
Returns detailed information about all system components.
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from src.models.ollama_client import get_ollama_client
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
diagnostics = {
|
diagnostics = {
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"service": {
|
"service": {
|
||||||
@@ -190,31 +126,10 @@ class HealthController(BaseController):
|
|||||||
"version": settings.app_version,
|
"version": settings.app_version,
|
||||||
"purpose": "Infrastructure management and tools API"
|
"purpose": "Infrastructure management and tools API"
|
||||||
},
|
},
|
||||||
"components": {}
|
"configuration": {
|
||||||
}
|
|
||||||
|
|
||||||
# 1. Ollama Connection
|
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
try:
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
diagnostics["components"]["ollama"] = {
|
|
||||||
"status": "connected",
|
|
||||||
"url": settings.ollama_base_url,
|
|
||||||
"timeout": settings.ollama_timeout,
|
|
||||||
"default_model": settings.default_model
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
diagnostics["components"]["ollama"] = {
|
|
||||||
"status": "error",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Configuration
|
|
||||||
diagnostics["configuration"] = {
|
|
||||||
"agent_fallback_enabled": settings.agent_fallback_enabled,
|
|
||||||
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
|
|
||||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
diagnostics["response_time_ms"] = elapsed_ms
|
diagnostics["response_time_ms"] = elapsed_ms
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ Tools Controller
|
|||||||
Provides utility tool endpoints including:
|
Provides utility tool endpoints including:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
- System stats
|
- System stats
|
||||||
|
- Environment data (weather, forecast, sun times)
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from typing import Dict, Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
|
||||||
from src.shared.base import BaseController
|
from src.shared.base import BaseController
|
||||||
from src.shared.logging import get_logger
|
from src.shared.logging import get_logger
|
||||||
@@ -14,6 +16,11 @@ from src.domains.tools.dns.service import DNSService
|
|||||||
from src.domains.tools.dns.exceptions import DNSQueryError
|
from src.domains.tools.dns.exceptions import DNSQueryError
|
||||||
from src.domains.tools.system.schemas import SystemStatsResponse
|
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||||
from src.domains.tools.system.service import SystemStatsService
|
from src.domains.tools.system.service import SystemStatsService
|
||||||
|
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
from src.domains.tools.news.schemas import NewsResponse
|
||||||
|
from src.domains.tools.news.service import NewsService
|
||||||
|
from src.domains.auth.oidc import get_optional_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -25,12 +32,15 @@ class ToolsController(BaseController):
|
|||||||
Provides endpoints for:
|
Provides endpoints for:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
- System stats
|
- System stats
|
||||||
|
- Environment data (weather, forecast, sun times)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(prefix="/tools", tags=["Tools"])
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
self.system_stats_service = SystemStatsService()
|
self.system_stats_service = SystemStatsService()
|
||||||
|
self.environment_service = EnvironmentService()
|
||||||
|
self.news_service = NewsService()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -146,6 +156,125 @@ class ToolsController(BaseController):
|
|||||||
detail=f"Failed to collect system stats: {str(e)}"
|
detail=f"Failed to collect system stats: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/environment",
|
||||||
|
response_model=EnvironmentResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get environment data",
|
||||||
|
description="""
|
||||||
|
Get current environment data including weather, forecast, and sun times.
|
||||||
|
|
||||||
|
Fetches data from the Qdrant volatile collection for the authenticated user.
|
||||||
|
Falls back to 'default' user if not authenticated.
|
||||||
|
|
||||||
|
**Data Returned:**
|
||||||
|
- **Weather:** Current temperature, conditions, humidity, wind
|
||||||
|
- **Forecast:** Multi-day weather outlook
|
||||||
|
- **Sun Times:** Sunrise, sunset, daylight duration
|
||||||
|
- **Air Quality:** AQI and pollutant levels (if available)
|
||||||
|
|
||||||
|
**Data Source:** Qdrant volatile_{user} collection
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Dashboard environment widgets
|
||||||
|
- Home automation context
|
||||||
|
- Weather-based automations
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_environment(
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
) -> EnvironmentResponse:
|
||||||
|
"""
|
||||||
|
Get current environment data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Optional authenticated user from OIDC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Environment data including weather, forecast, sun times
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user identifier from OIDC claims, fallback to 'default'
|
||||||
|
user_id = "default"
|
||||||
|
if user:
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||||
|
if "@" in user_id:
|
||||||
|
user_id = user_id.split("@")[0]
|
||||||
|
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id}")
|
||||||
|
result = await self.environment_service.get_current(user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get environment data: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch environment data: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/news",
|
||||||
|
response_model=NewsResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get news headlines",
|
||||||
|
description="""
|
||||||
|
Get news headlines for the authenticated user.
|
||||||
|
|
||||||
|
Fetches news data from the Qdrant volatile collection for the authenticated user.
|
||||||
|
Falls back to 'default' user if not authenticated.
|
||||||
|
|
||||||
|
**Data Returned:**
|
||||||
|
- **Headlines:** List of news headlines with title, description, source, url
|
||||||
|
- **Category:** News category (general, technology, etc.)
|
||||||
|
- **Sources:** List of news sources
|
||||||
|
|
||||||
|
**Data Source:** Qdrant volatile_{user} collection (news namespace)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- Dashboard news ticker
|
||||||
|
- News feed widgets
|
||||||
|
- Information display
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def get_news(
|
||||||
|
user: Optional[Dict] = Depends(get_optional_user),
|
||||||
|
) -> NewsResponse:
|
||||||
|
"""
|
||||||
|
Get news headlines
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Optional authenticated user from OIDC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
News headlines response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 500 for processing errors
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get user identifier from OIDC claims, fallback to 'default'
|
||||||
|
user_id = "default"
|
||||||
|
if user:
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||||
|
if "@" in user_id:
|
||||||
|
user_id = user_id.split("@")[0]
|
||||||
|
|
||||||
|
logger.info(f"Fetching news data for user: {user_id}")
|
||||||
|
result = await self.news_service.get_news(user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get news data: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to fetch news data: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Environment data module for Tools domain.
|
||||||
|
|
||||||
|
Provides access to weather, forecast, sun times, and air quality data
|
||||||
|
from the Qdrant volatile collection.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.environment.schemas import (
|
||||||
|
WeatherData,
|
||||||
|
ForecastDay,
|
||||||
|
SunTimesData,
|
||||||
|
AirQualityData,
|
||||||
|
EnvironmentResponse,
|
||||||
|
)
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WeatherData",
|
||||||
|
"ForecastDay",
|
||||||
|
"SunTimesData",
|
||||||
|
"AirQualityData",
|
||||||
|
"EnvironmentResponse",
|
||||||
|
"EnvironmentService",
|
||||||
|
]
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Environment data schemas for Tools domain.
|
||||||
|
|
||||||
|
Provides Pydantic models for weather, forecast, sun times, and air quality data
|
||||||
|
retrieved from the Qdrant volatile collection.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherData(BaseSchema):
|
||||||
|
"""Current weather conditions."""
|
||||||
|
|
||||||
|
temperature: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Current temperature in Celsius"
|
||||||
|
)
|
||||||
|
feels_like: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Feels-like temperature in Celsius"
|
||||||
|
)
|
||||||
|
conditions: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Weather conditions description (e.g., 'Partly Cloudy')"
|
||||||
|
)
|
||||||
|
humidity: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
le=100,
|
||||||
|
description="Humidity percentage"
|
||||||
|
)
|
||||||
|
wind_speed: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Wind speed in km/h"
|
||||||
|
)
|
||||||
|
wind_direction: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Wind direction (e.g., 'NW')"
|
||||||
|
)
|
||||||
|
pressure: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Atmospheric pressure in hPa"
|
||||||
|
)
|
||||||
|
visibility: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Visibility in km"
|
||||||
|
)
|
||||||
|
uv_index: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="UV index"
|
||||||
|
)
|
||||||
|
location: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Location name"
|
||||||
|
)
|
||||||
|
icon: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Weather icon code or URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ForecastDay(BaseSchema):
|
||||||
|
"""Single day forecast data."""
|
||||||
|
|
||||||
|
date: str = Field(
|
||||||
|
...,
|
||||||
|
description="Date string (e.g., '2025-01-07')"
|
||||||
|
)
|
||||||
|
high: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="High temperature in Celsius"
|
||||||
|
)
|
||||||
|
low: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Low temperature in Celsius"
|
||||||
|
)
|
||||||
|
conditions: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Weather conditions description"
|
||||||
|
)
|
||||||
|
precipitation_chance: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
le=100,
|
||||||
|
description="Chance of precipitation percentage"
|
||||||
|
)
|
||||||
|
icon: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Weather icon code or URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SunTimesData(BaseSchema):
|
||||||
|
"""Sunrise and sunset times."""
|
||||||
|
|
||||||
|
sunrise: Optional[datetime] = Field(
|
||||||
|
None,
|
||||||
|
description="Sunrise time"
|
||||||
|
)
|
||||||
|
sunset: Optional[datetime] = Field(
|
||||||
|
None,
|
||||||
|
description="Sunset time"
|
||||||
|
)
|
||||||
|
daylight_minutes: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="Total daylight duration in minutes"
|
||||||
|
)
|
||||||
|
solar_noon: Optional[datetime] = Field(
|
||||||
|
None,
|
||||||
|
description="Solar noon time"
|
||||||
|
)
|
||||||
|
dawn: Optional[datetime] = Field(
|
||||||
|
None,
|
||||||
|
description="Civil dawn time"
|
||||||
|
)
|
||||||
|
dusk: Optional[datetime] = Field(
|
||||||
|
None,
|
||||||
|
description="Civil dusk time"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AirQualityData(BaseSchema):
|
||||||
|
"""Air quality information."""
|
||||||
|
|
||||||
|
aqi: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Air Quality Index"
|
||||||
|
)
|
||||||
|
quality: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Quality category (Good, Moderate, Unhealthy, etc.)"
|
||||||
|
)
|
||||||
|
pm25: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="PM2.5 concentration in microg/m3"
|
||||||
|
)
|
||||||
|
pm10: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="PM10 concentration in microg/m3"
|
||||||
|
)
|
||||||
|
o3: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Ozone concentration in ppb"
|
||||||
|
)
|
||||||
|
no2: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
description="Nitrogen dioxide concentration in ppb"
|
||||||
|
)
|
||||||
|
location: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Location name"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentResponse(BaseSchema):
|
||||||
|
"""Combined environment data response."""
|
||||||
|
|
||||||
|
weather: Optional[WeatherData] = Field(
|
||||||
|
None,
|
||||||
|
description="Current weather conditions"
|
||||||
|
)
|
||||||
|
forecast: Optional[List[ForecastDay]] = Field(
|
||||||
|
None,
|
||||||
|
description="Multi-day weather forecast"
|
||||||
|
)
|
||||||
|
sun_times: Optional[SunTimesData] = Field(
|
||||||
|
None,
|
||||||
|
description="Sunrise/sunset times"
|
||||||
|
)
|
||||||
|
air_quality: Optional[AirQualityData] = Field(
|
||||||
|
None,
|
||||||
|
description="Air quality data (None if not available)"
|
||||||
|
)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=datetime.utcnow,
|
||||||
|
description="Timestamp when data was fetched"
|
||||||
|
)
|
||||||
|
user: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="User identifier used for data lookup"
|
||||||
|
)
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
Environment data service for Tools domain.
|
||||||
|
|
||||||
|
Fetches weather, forecast, sun times, and air quality data from
|
||||||
|
the Qdrant volatile collection.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.clients.qdrant_client import get_qdrant_client
|
||||||
|
from src.domains.tools.environment.schemas import (
|
||||||
|
WeatherData,
|
||||||
|
ForecastDay,
|
||||||
|
SunTimesData,
|
||||||
|
AirQualityData,
|
||||||
|
EnvironmentResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentService:
|
||||||
|
"""
|
||||||
|
Service for fetching environment data from Qdrant volatile collection.
|
||||||
|
|
||||||
|
Retrieves weather, forecast, sun times, and optionally air quality
|
||||||
|
data for a specific user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize environment service with Qdrant client."""
|
||||||
|
self.qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
def _parse_weather(self, raw_data: Optional[Dict[str, Any]]) -> Optional[WeatherData]:
|
||||||
|
"""
|
||||||
|
Parse raw weather data into WeatherData schema.
|
||||||
|
|
||||||
|
Handles various field naming conventions that might come from
|
||||||
|
different weather APIs.
|
||||||
|
"""
|
||||||
|
if not raw_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle wind direction - convert degrees to cardinal if integer
|
||||||
|
wind_dir = raw_data.get("wind_direction") or raw_data.get("wind_dir")
|
||||||
|
if isinstance(wind_dir, (int, float)):
|
||||||
|
wind_dir = self._degrees_to_cardinal(wind_dir)
|
||||||
|
|
||||||
|
return WeatherData(
|
||||||
|
temperature=raw_data.get("temperature") or raw_data.get("temp"),
|
||||||
|
feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"),
|
||||||
|
conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"),
|
||||||
|
humidity=raw_data.get("humidity"),
|
||||||
|
wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"),
|
||||||
|
wind_direction=wind_dir,
|
||||||
|
pressure=raw_data.get("pressure"),
|
||||||
|
visibility=raw_data.get("visibility"),
|
||||||
|
uv_index=raw_data.get("uv_index") or raw_data.get("uv"),
|
||||||
|
location=raw_data.get("location") or raw_data.get("city"),
|
||||||
|
icon=raw_data.get("icon") or raw_data.get("icon_url"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse weather data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _degrees_to_cardinal(self, degrees: float) -> str:
|
||||||
|
"""Convert wind direction degrees to cardinal direction."""
|
||||||
|
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||||
|
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
|
||||||
|
index = round(degrees / 22.5) % 16
|
||||||
|
return directions[index]
|
||||||
|
|
||||||
|
def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]:
|
||||||
|
"""
|
||||||
|
Parse raw forecast data into list of ForecastDay schemas.
|
||||||
|
|
||||||
|
Handles both list format and dict with nested list.
|
||||||
|
"""
|
||||||
|
if not raw_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Normalize to list
|
||||||
|
forecast_list = raw_data
|
||||||
|
if isinstance(raw_data, dict):
|
||||||
|
# Check 'daily' first (scheduler format), then 'forecast', then 'days'
|
||||||
|
# Note: 'days' might be an integer count, so check 'daily' first
|
||||||
|
forecast_list = raw_data.get("daily") or raw_data.get("forecast")
|
||||||
|
if forecast_list is None:
|
||||||
|
days_value = raw_data.get("days")
|
||||||
|
if isinstance(days_value, list):
|
||||||
|
forecast_list = days_value
|
||||||
|
else:
|
||||||
|
forecast_list = []
|
||||||
|
|
||||||
|
if not isinstance(forecast_list, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
days = []
|
||||||
|
for day in forecast_list:
|
||||||
|
if isinstance(day, dict):
|
||||||
|
days.append(ForecastDay(
|
||||||
|
date=day.get("date", ""),
|
||||||
|
high=day.get("high") or day.get("temp_high") or day.get("maxtemp") or day.get("temp_max"),
|
||||||
|
low=day.get("low") or day.get("temp_low") or day.get("mintemp") or day.get("temp_min"),
|
||||||
|
conditions=day.get("conditions") or day.get("weather") or day.get("description"),
|
||||||
|
precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"),
|
||||||
|
icon=day.get("icon"),
|
||||||
|
))
|
||||||
|
|
||||||
|
return days if days else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse forecast data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_sun_times(self, raw_data: Optional[Dict[str, Any]]) -> Optional[SunTimesData]:
|
||||||
|
"""
|
||||||
|
Parse raw sun times data into SunTimesData schema.
|
||||||
|
|
||||||
|
Handles datetime strings and calculates daylight minutes if not provided.
|
||||||
|
"""
|
||||||
|
if not raw_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prefer ISO format fields (sunrise_iso, sunset_iso) over time-only fields
|
||||||
|
sunrise = raw_data.get("sunrise_iso") or raw_data.get("sunrise")
|
||||||
|
sunset = raw_data.get("sunset_iso") or raw_data.get("sunset")
|
||||||
|
|
||||||
|
# Parse datetime strings if needed
|
||||||
|
if isinstance(sunrise, str):
|
||||||
|
# Handle time-only format (HH:MM) by combining with today's date
|
||||||
|
if len(sunrise) <= 5 and ":" in sunrise:
|
||||||
|
today = datetime.now().date()
|
||||||
|
sunrise = datetime.strptime(f"{today} {sunrise}", "%Y-%m-%d %H:%M")
|
||||||
|
else:
|
||||||
|
sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00"))
|
||||||
|
if isinstance(sunset, str):
|
||||||
|
# Handle time-only format (HH:MM) by combining with today's date
|
||||||
|
if len(sunset) <= 5 and ":" in sunset:
|
||||||
|
today = datetime.now().date()
|
||||||
|
sunset = datetime.strptime(f"{today} {sunset}", "%Y-%m-%d %H:%M")
|
||||||
|
else:
|
||||||
|
sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
# Get daylight from various field names
|
||||||
|
daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight")
|
||||||
|
if daylight_minutes is None:
|
||||||
|
# Try to calculate from daylight_duration_seconds or daylight_hours
|
||||||
|
daylight_seconds = raw_data.get("daylight_duration_seconds")
|
||||||
|
if daylight_seconds:
|
||||||
|
daylight_minutes = int(daylight_seconds / 60)
|
||||||
|
else:
|
||||||
|
daylight_hours = raw_data.get("daylight_hours")
|
||||||
|
if daylight_hours:
|
||||||
|
daylight_minutes = int(daylight_hours * 60)
|
||||||
|
elif sunrise and sunset:
|
||||||
|
daylight_minutes = int((sunset - sunrise).total_seconds() / 60)
|
||||||
|
|
||||||
|
# Parse optional fields
|
||||||
|
solar_noon = raw_data.get("solar_noon")
|
||||||
|
if isinstance(solar_noon, str):
|
||||||
|
solar_noon = datetime.fromisoformat(solar_noon.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
dawn = raw_data.get("dawn") or raw_data.get("civil_dawn")
|
||||||
|
if isinstance(dawn, str):
|
||||||
|
dawn = datetime.fromisoformat(dawn.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
dusk = raw_data.get("dusk") or raw_data.get("civil_dusk")
|
||||||
|
if isinstance(dusk, str):
|
||||||
|
dusk = datetime.fromisoformat(dusk.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
return SunTimesData(
|
||||||
|
sunrise=sunrise,
|
||||||
|
sunset=sunset,
|
||||||
|
daylight_minutes=daylight_minutes,
|
||||||
|
solar_noon=solar_noon,
|
||||||
|
dawn=dawn,
|
||||||
|
dusk=dusk,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse sun times data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_air_quality(self, raw_data: Any) -> Optional[AirQualityData]:
|
||||||
|
"""
|
||||||
|
Parse raw air quality data into AirQualityData schema.
|
||||||
|
|
||||||
|
Handles both dict format and simple integer AQI value.
|
||||||
|
"""
|
||||||
|
if raw_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle simple integer AQI
|
||||||
|
if isinstance(raw_data, (int, float)):
|
||||||
|
aqi = int(raw_data)
|
||||||
|
return AirQualityData(
|
||||||
|
aqi=aqi,
|
||||||
|
quality=self._aqi_to_quality(aqi),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(raw_data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try various AQI field names - prefer US AQI, then European, then generic
|
||||||
|
aqi = raw_data.get("aqi") or raw_data.get("aqi_us") or raw_data.get("aqi_european") or raw_data.get("index")
|
||||||
|
if isinstance(aqi, (int, float)):
|
||||||
|
aqi = int(aqi)
|
||||||
|
|
||||||
|
return AirQualityData(
|
||||||
|
aqi=aqi,
|
||||||
|
quality=raw_data.get("quality") or (self._aqi_to_quality(aqi) if aqi else None),
|
||||||
|
pm25=raw_data.get("pm25") or raw_data.get("pm2_5"),
|
||||||
|
pm10=raw_data.get("pm10"),
|
||||||
|
o3=raw_data.get("o3") or raw_data.get("ozone"),
|
||||||
|
no2=raw_data.get("no2") or raw_data.get("nitrogen_dioxide"),
|
||||||
|
location=raw_data.get("location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse air quality data: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _aqi_to_quality(self, aqi: int) -> str:
|
||||||
|
"""Convert AQI value to quality category string."""
|
||||||
|
if aqi <= 50:
|
||||||
|
return "Good"
|
||||||
|
elif aqi <= 100:
|
||||||
|
return "Moderate"
|
||||||
|
elif aqi <= 150:
|
||||||
|
return "Unhealthy for Sensitive Groups"
|
||||||
|
elif aqi <= 200:
|
||||||
|
return "Unhealthy"
|
||||||
|
elif aqi <= 300:
|
||||||
|
return "Very Unhealthy"
|
||||||
|
else:
|
||||||
|
return "Hazardous"
|
||||||
|
|
||||||
|
async def get_current(self, user: str = "default") -> EnvironmentResponse:
|
||||||
|
"""
|
||||||
|
Get current environment data for a user.
|
||||||
|
|
||||||
|
Fetches weather, forecast, sun times, and air quality from
|
||||||
|
the user's volatile collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (default: 'default')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EnvironmentResponse with all available data
|
||||||
|
"""
|
||||||
|
logger.info(f"Fetching environment data for user: {user}")
|
||||||
|
|
||||||
|
# Get raw data from Qdrant
|
||||||
|
raw_data = await self.qdrant.get_environment_data(user)
|
||||||
|
|
||||||
|
# Parse each data type
|
||||||
|
weather = self._parse_weather(raw_data.get("weather"))
|
||||||
|
forecast = self._parse_forecast(raw_data.get("forecast"))
|
||||||
|
sun_times = self._parse_sun_times(raw_data.get("sun_times"))
|
||||||
|
air_quality = self._parse_air_quality(raw_data.get("air_quality"))
|
||||||
|
|
||||||
|
return EnvironmentResponse(
|
||||||
|
weather=weather,
|
||||||
|
forecast=forecast,
|
||||||
|
sun_times=sun_times,
|
||||||
|
air_quality=air_quality,
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_environment_service: Optional[EnvironmentService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_environment_service() -> EnvironmentService:
|
||||||
|
"""Get or create singleton environment service instance."""
|
||||||
|
global _environment_service
|
||||||
|
if _environment_service is None:
|
||||||
|
_environment_service = EnvironmentService()
|
||||||
|
return _environment_service
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
News subdomain for Tools.
|
||||||
|
|
||||||
|
Provides news headlines from Qdrant volatile collection.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
News data schemas for Tools domain.
|
||||||
|
|
||||||
|
Provides Pydantic models for news headlines retrieved from the Qdrant volatile collection.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class NewsHeadline(BaseSchema):
|
||||||
|
"""Single news headline."""
|
||||||
|
|
||||||
|
title: str = Field(
|
||||||
|
...,
|
||||||
|
description="Headline title"
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Brief description or summary"
|
||||||
|
)
|
||||||
|
source: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="News source name"
|
||||||
|
)
|
||||||
|
url: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Link to full article"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsResponse(BaseSchema):
|
||||||
|
"""News headlines response."""
|
||||||
|
|
||||||
|
headlines: List[NewsHeadline] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of news headlines"
|
||||||
|
)
|
||||||
|
category: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="News category (e.g., 'general', 'technology')"
|
||||||
|
)
|
||||||
|
sources: Optional[List[str]] = Field(
|
||||||
|
None,
|
||||||
|
description="List of source names"
|
||||||
|
)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=datetime.utcnow,
|
||||||
|
description="Timestamp when data was fetched"
|
||||||
|
)
|
||||||
|
user: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="User identifier used for data lookup"
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
News data service for Tools domain.
|
||||||
|
|
||||||
|
Fetches news headlines from the Qdrant volatile collection.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.clients.qdrant_client import get_qdrant_client
|
||||||
|
from src.domains.tools.news.schemas import (
|
||||||
|
NewsHeadline,
|
||||||
|
NewsResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsService:
|
||||||
|
"""
|
||||||
|
Service for fetching news data from Qdrant volatile collection.
|
||||||
|
|
||||||
|
Retrieves news headlines for a specific user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize news service with Qdrant client."""
|
||||||
|
self.qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
def _parse_headlines(self, raw_data: Any) -> List[NewsHeadline]:
|
||||||
|
"""
|
||||||
|
Parse raw news data into list of NewsHeadline schemas.
|
||||||
|
|
||||||
|
Handles various formats from different news sources.
|
||||||
|
"""
|
||||||
|
if not raw_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle dict with nested headlines list
|
||||||
|
headlines_list = raw_data
|
||||||
|
if isinstance(raw_data, dict):
|
||||||
|
headlines_list = raw_data.get("headlines") or raw_data.get("articles") or []
|
||||||
|
|
||||||
|
if not isinstance(headlines_list, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
headlines = []
|
||||||
|
for item in headlines_list:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
headlines.append(NewsHeadline(
|
||||||
|
title=item.get("title", ""),
|
||||||
|
description=item.get("description") or item.get("summary"),
|
||||||
|
source=item.get("source") or item.get("provider"),
|
||||||
|
url=item.get("url") or item.get("link"),
|
||||||
|
))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
# Simple string headlines
|
||||||
|
headlines.append(NewsHeadline(title=item))
|
||||||
|
|
||||||
|
return headlines
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse news headlines: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_news(self, user: str = "default") -> NewsResponse:
|
||||||
|
"""
|
||||||
|
Get news headlines for a user.
|
||||||
|
|
||||||
|
Fetches news from the user's volatile collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (default: 'default')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NewsResponse with headlines
|
||||||
|
"""
|
||||||
|
logger.info(f"Fetching news data for user: {user}")
|
||||||
|
|
||||||
|
# Get raw data from Qdrant
|
||||||
|
news_records = await self.qdrant.get_by_namespace(user, "news")
|
||||||
|
|
||||||
|
headlines = []
|
||||||
|
category = None
|
||||||
|
sources = None
|
||||||
|
|
||||||
|
if news_records:
|
||||||
|
raw_data = news_records[0].get("raw_data", {})
|
||||||
|
headlines = self._parse_headlines(raw_data)
|
||||||
|
if isinstance(raw_data, dict):
|
||||||
|
category = raw_data.get("category")
|
||||||
|
sources = raw_data.get("sources")
|
||||||
|
|
||||||
|
return NewsResponse(
|
||||||
|
headlines=headlines,
|
||||||
|
category=category,
|
||||||
|
sources=sources,
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_news_service: Optional[NewsService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_news_service() -> NewsService:
|
||||||
|
"""Get or create singleton news service instance."""
|
||||||
|
global _news_service
|
||||||
|
if _news_service is None:
|
||||||
|
_news_service = NewsService()
|
||||||
|
return _news_service
|
||||||
+1
-12
@@ -10,7 +10,6 @@ from src.shared.config import get_settings
|
|||||||
from src.shared.logging import setup_logging, get_logger
|
from src.shared.logging import setup_logging, get_logger
|
||||||
from src.shared.database import get_database
|
from src.shared.database import get_database
|
||||||
from src.shared.security import initialize_oidc
|
from src.shared.security import initialize_oidc
|
||||||
from src.models.ollama_client import get_ollama_client, close_ollama_client
|
|
||||||
|
|
||||||
# Import domain controllers
|
# Import domain controllers
|
||||||
from src.domains.health import health_controller
|
from src.domains.health import health_controller
|
||||||
@@ -42,17 +41,8 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
||||||
logger.info(f"Debug mode: {settings.debug}")
|
logger.info(f"Debug mode: {settings.debug}")
|
||||||
logger.info(f"Log level: {settings.log_level}")
|
logger.info(f"Log level: {settings.log_level}")
|
||||||
logger.info(f"Ollama URL: {settings.ollama_base_url}")
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# Check Ollama connectivity
|
|
||||||
ollama_client = get_ollama_client()
|
|
||||||
ollama_healthy = await ollama_client.health_check()
|
|
||||||
if ollama_healthy:
|
|
||||||
logger.info("Ollama connection successful")
|
|
||||||
else:
|
|
||||||
logger.warning("Ollama connection failed - AI features may not work")
|
|
||||||
|
|
||||||
# Check database connectivity
|
# Check database connectivity
|
||||||
database = get_database()
|
database = get_database()
|
||||||
db_healthy = await database.health_check()
|
db_healthy = await database.health_check()
|
||||||
@@ -68,7 +58,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down application")
|
logger.info("Shutting down application")
|
||||||
await close_ollama_client()
|
|
||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +83,7 @@ See `/docs` for the full API reference.
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
debug=settings.debug,
|
debug=settings.debug,
|
||||||
swagger_ui_init_oauth={
|
swagger_ui_init_oauth={
|
||||||
"clientId": settings.oidc_audience,
|
"clientId": settings.oidc_audiences[0] if settings.oidc_audiences else "core-api",
|
||||||
"usePkceWithAuthorizationCodeGrant": True,
|
"usePkceWithAuthorizationCodeGrant": True,
|
||||||
} if settings.oidc_enabled else None
|
} if settings.oidc_enabled else None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"""
|
|
||||||
Embedding model client for text vectorization
|
|
||||||
|
|
||||||
Uses sentence-transformers for generating embeddings.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import List, Optional
|
|
||||||
from sentence_transformers import SentenceTransformer
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingClient:
|
|
||||||
"""Client for generating text embeddings"""
|
|
||||||
|
|
||||||
def __init__(self, model_name: Optional[str] = None):
|
|
||||||
"""
|
|
||||||
Initialize embedding client
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_name: Optional model name, defaults to config
|
|
||||||
"""
|
|
||||||
self.model_name = model_name or settings.embedding_model
|
|
||||||
self.dimension = settings.embedding_dimension
|
|
||||||
self._model: Optional[SentenceTransformer] = None
|
|
||||||
logger.info(f"Initializing EmbeddingClient with model: {self.model_name}")
|
|
||||||
|
|
||||||
def _load_model(self) -> SentenceTransformer:
|
|
||||||
"""
|
|
||||||
Lazy load the embedding model
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Loaded SentenceTransformer model
|
|
||||||
"""
|
|
||||||
if self._model is None:
|
|
||||||
logger.info(f"Loading embedding model: {self.model_name}")
|
|
||||||
self._model = SentenceTransformer(self.model_name)
|
|
||||||
logger.info(f"Model loaded successfully. Embedding dimension: {self.dimension}")
|
|
||||||
return self._model
|
|
||||||
|
|
||||||
def embed_text(self, text: str) -> List[float]:
|
|
||||||
"""
|
|
||||||
Generate embedding for a single text
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text to embed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of floats representing the embedding vector
|
|
||||||
"""
|
|
||||||
model = self._load_model()
|
|
||||||
embedding = model.encode(text, convert_to_numpy=True)
|
|
||||||
return embedding.tolist()
|
|
||||||
|
|
||||||
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
||||||
"""
|
|
||||||
Generate embeddings for multiple texts
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: List of input texts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of embedding vectors
|
|
||||||
"""
|
|
||||||
model = self._load_model()
|
|
||||||
embeddings = model.encode(
|
|
||||||
texts,
|
|
||||||
batch_size=settings.embedding_batch_size,
|
|
||||||
convert_to_numpy=True,
|
|
||||||
show_progress_bar=False
|
|
||||||
)
|
|
||||||
return embeddings.tolist()
|
|
||||||
|
|
||||||
def get_dimension(self) -> int:
|
|
||||||
"""
|
|
||||||
Get embedding dimension
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding vector dimension
|
|
||||||
"""
|
|
||||||
return self.dimension
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
_embedding_client: Optional[EmbeddingClient] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_embedding_client() -> EmbeddingClient:
|
|
||||||
"""
|
|
||||||
Get or create global embedding client instance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EmbeddingClient instance
|
|
||||||
"""
|
|
||||||
global _embedding_client
|
|
||||||
if _embedding_client is None:
|
|
||||||
_embedding_client = EmbeddingClient()
|
|
||||||
return _embedding_client
|
|
||||||
|
|
||||||
|
|
||||||
async def embed_text_async(text: str) -> List[float]:
|
|
||||||
"""
|
|
||||||
Async wrapper for embedding text
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding vector
|
|
||||||
"""
|
|
||||||
client = get_embedding_client()
|
|
||||||
return client.embed_text(text)
|
|
||||||
|
|
||||||
|
|
||||||
async def embed_batch_async(texts: List[str]) -> List[List[float]]:
|
|
||||||
"""
|
|
||||||
Async wrapper for batch embedding
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: List of input texts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of embedding vectors
|
|
||||||
"""
|
|
||||||
client = get_embedding_client()
|
|
||||||
return client.embed_batch(texts)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""
|
|
||||||
Ollama-based embedding client for text vectorization
|
|
||||||
|
|
||||||
Uses Ollama's embedding API instead of local sentence-transformers.
|
|
||||||
This eliminates the need for PyTorch and heavy ML dependencies.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import httpx
|
|
||||||
from typing import List, Optional
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaEmbeddingClient:
|
|
||||||
"""Client for generating text embeddings using Ollama"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
model_name: Optional[str] = None,
|
|
||||||
base_url: Optional[str] = None,
|
|
||||||
timeout: int = 30
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize Ollama embedding client
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_name: Embedding model name (default: nomic-embed-text)
|
|
||||||
base_url: Ollama base URL (default from settings)
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
"""
|
|
||||||
self.model_name = model_name or settings.embedding_model
|
|
||||||
self.base_url = (base_url or settings.ollama_base_url).rstrip("/")
|
|
||||||
self.timeout = timeout
|
|
||||||
self.dimension = settings.embedding_dimension
|
|
||||||
|
|
||||||
logger.info(f"Initializing OllamaEmbeddingClient with model: {self.model_name}")
|
|
||||||
logger.info(f"Ollama URL: {self.base_url}")
|
|
||||||
|
|
||||||
async def embed_text(self, text: str) -> List[float]:
|
|
||||||
"""
|
|
||||||
Generate embedding for a single text using Ollama
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text to embed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of floats representing the embedding vector
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.base_url}/api/embeddings",
|
|
||||||
json={
|
|
||||||
"model": self.model_name,
|
|
||||||
"prompt": text
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
return result["embedding"]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating embedding via Ollama: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
||||||
"""
|
|
||||||
Generate embeddings for multiple texts
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: List of input texts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of embedding vectors
|
|
||||||
"""
|
|
||||||
embeddings = []
|
|
||||||
for text in texts:
|
|
||||||
embedding = await self.embed_text(text)
|
|
||||||
embeddings.append(embedding)
|
|
||||||
return embeddings
|
|
||||||
|
|
||||||
def get_dimension(self) -> int:
|
|
||||||
"""
|
|
||||||
Get embedding dimension
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding vector dimension
|
|
||||||
"""
|
|
||||||
return self.dimension
|
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
|
||||||
_embedding_client: Optional[OllamaEmbeddingClient] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_embedding_client() -> OllamaEmbeddingClient:
|
|
||||||
"""
|
|
||||||
Get or create global Ollama embedding client instance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OllamaEmbeddingClient instance
|
|
||||||
"""
|
|
||||||
global _embedding_client
|
|
||||||
if _embedding_client is None:
|
|
||||||
_embedding_client = OllamaEmbeddingClient()
|
|
||||||
return _embedding_client
|
|
||||||
|
|
||||||
|
|
||||||
async def embed_text_async(text: str) -> List[float]:
|
|
||||||
"""
|
|
||||||
Async wrapper for embedding text
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding vector
|
|
||||||
"""
|
|
||||||
client = get_embedding_client()
|
|
||||||
return await client.embed_text(text)
|
|
||||||
|
|
||||||
|
|
||||||
async def embed_batch_async(texts: List[str]) -> List[List[float]]:
|
|
||||||
"""
|
|
||||||
Async wrapper for batch embedding
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: List of input texts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of embedding vectors
|
|
||||||
"""
|
|
||||||
client = get_embedding_client()
|
|
||||||
return await client.embed_batch(texts)
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""
|
|
||||||
Ollama client for model inference.
|
|
||||||
Handles both streaming and non-streaming requests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import AsyncIterator, Dict, Any, Optional
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaClient:
|
|
||||||
"""Client for interacting with Ollama API."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.base_url = settings.ollama_base_url
|
|
||||||
self.timeout = settings.ollama_timeout
|
|
||||||
self.client = httpx.AsyncClient(timeout=self.timeout)
|
|
||||||
logger.info(f"Initialized Ollama client: {self.base_url}")
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close the HTTP client."""
|
|
||||||
await self.client.aclose()
|
|
||||||
|
|
||||||
def resolve_model(self, model_name: str) -> str:
|
|
||||||
"""
|
|
||||||
Resolve model alias to actual Ollama model.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_name: Requested model name (e.g., "gpt-3.5-turbo")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Actual Ollama model name (e.g., "gemma:7b")
|
|
||||||
"""
|
|
||||||
resolved = settings.model_aliases.get(model_name, model_name)
|
|
||||||
if resolved != model_name:
|
|
||||||
logger.info(f"Model resolution: {model_name} → {resolved}")
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
async def generate_non_streaming(
|
|
||||||
self,
|
|
||||||
model: str,
|
|
||||||
prompt: str,
|
|
||||||
temperature: float = 0.7,
|
|
||||||
max_tokens: Optional[int] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Generate non-streaming response from Ollama using chat endpoint.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model: Model name
|
|
||||||
prompt: User prompt
|
|
||||||
temperature: Sampling temperature
|
|
||||||
max_tokens: Maximum tokens to generate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'response' and 'tokens' keys
|
|
||||||
"""
|
|
||||||
actual_model = self.resolve_model(model)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": actual_model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
"options": {
|
|
||||||
"temperature": temperature,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if max_tokens:
|
|
||||||
payload["options"]["num_predict"] = max_tokens
|
|
||||||
|
|
||||||
logger.debug(f"Ollama request to {actual_model}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await self.client.post(
|
|
||||||
f"{self.base_url}/api/chat",
|
|
||||||
json=payload
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"response": result.get("message", {}).get("content", ""),
|
|
||||||
"tokens": {
|
|
||||||
"prompt": result.get("prompt_eval_count", 0),
|
|
||||||
"completion": result.get("eval_count", 0),
|
|
||||||
"total": result.get("prompt_eval_count", 0) + result.get("eval_count", 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.error(f"Ollama request failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def generate_streaming(
|
|
||||||
self,
|
|
||||||
model: str,
|
|
||||||
prompt: str,
|
|
||||||
temperature: float = 0.7,
|
|
||||||
max_tokens: Optional[int] = None
|
|
||||||
) -> AsyncIterator[str]:
|
|
||||||
"""
|
|
||||||
Generate streaming response from Ollama using chat endpoint.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model: Model name
|
|
||||||
prompt: User prompt
|
|
||||||
temperature: Sampling temperature
|
|
||||||
max_tokens: Maximum tokens to generate
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
Token strings
|
|
||||||
"""
|
|
||||||
actual_model = self.resolve_model(model)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": actual_model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": prompt}
|
|
||||||
],
|
|
||||||
"stream": True,
|
|
||||||
"options": {
|
|
||||||
"temperature": temperature,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if max_tokens:
|
|
||||||
payload["options"]["num_predict"] = max_tokens
|
|
||||||
|
|
||||||
logger.debug(f"Ollama streaming request to {actual_model}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with self.client.stream(
|
|
||||||
"POST",
|
|
||||||
f"{self.base_url}/api/chat",
|
|
||||||
json=payload
|
|
||||||
) as response:
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
async for line in response.aiter_lines():
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
chunk = json.loads(line)
|
|
||||||
if "message" in chunk:
|
|
||||||
content = chunk["message"].get("content", "")
|
|
||||||
if content:
|
|
||||||
yield content
|
|
||||||
|
|
||||||
# Check if done
|
|
||||||
if chunk.get("done", False):
|
|
||||||
break
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(f"Failed to parse JSON: {line}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.error(f"Ollama streaming request failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def health_check(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if Ollama is healthy.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if healthy, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = await self.client.get(
|
|
||||||
f"{self.base_url}/api/tags",
|
|
||||||
timeout=5.0
|
|
||||||
)
|
|
||||||
return response.status_code == 200
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ollama health check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def list_models(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
List all available models in Ollama.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'models' key containing list of model info
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = await self.client.get(
|
|
||||||
f"{self.base_url}/api/tags",
|
|
||||||
timeout=5.0
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list Ollama models: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
# Global client instance
|
|
||||||
_ollama_client: Optional[OllamaClient] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_ollama_client() -> OllamaClient:
|
|
||||||
"""Get or create the global Ollama client instance."""
|
|
||||||
global _ollama_client
|
|
||||||
if _ollama_client is None:
|
|
||||||
_ollama_client = OllamaClient()
|
|
||||||
return _ollama_client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_ollama_client():
|
|
||||||
"""Close the global Ollama client."""
|
|
||||||
global _ollama_client
|
|
||||||
if _ollama_client is not None:
|
|
||||||
await _ollama_client.close()
|
|
||||||
_ollama_client = None
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""
|
|
||||||
Security initialization module
|
|
||||||
|
|
||||||
Handles OIDC configuration and authentication setup
|
|
||||||
"""
|
|
||||||
from src.config import Settings
|
|
||||||
from src.auth.oidc import oidc_config
|
|
||||||
from src.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_oidc(settings: Settings) -> None:
|
|
||||||
"""
|
|
||||||
Initialize OIDC authentication configuration
|
|
||||||
|
|
||||||
Configures the global oidc_config instance with settings from environment.
|
|
||||||
If OIDC is enabled, logs the issuer URL for verification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
settings: Application settings containing OIDC configuration
|
|
||||||
"""
|
|
||||||
oidc_config.configure(
|
|
||||||
enabled=settings.oidc_enabled,
|
|
||||||
issuer=settings.oidc_issuer,
|
|
||||||
audience=settings.oidc_audience
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.oidc_enabled:
|
|
||||||
logger.info(f"✓ OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
|
||||||
else:
|
|
||||||
logger.info("○ OIDC authentication disabled - API is publicly accessible")
|
|
||||||
@@ -7,6 +7,7 @@ from src.shared.clients.portainer_client import PortainerClient, get_portainer_c
|
|||||||
from src.shared.clients.npm_client import NPMClient, get_npm_client
|
from src.shared.clients.npm_client import NPMClient, get_npm_client
|
||||||
from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
|
from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
|
||||||
from src.shared.clients.authentik_client import AuthentikClient, get_authentik_client
|
from src.shared.clients.authentik_client import AuthentikClient, get_authentik_client
|
||||||
|
from src.shared.clients.qdrant_client import QdrantReadClient, get_qdrant_client
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PortainerClient",
|
"PortainerClient",
|
||||||
@@ -17,4 +18,6 @@ __all__ = [
|
|||||||
"get_homeassistant_client",
|
"get_homeassistant_client",
|
||||||
"AuthentikClient",
|
"AuthentikClient",
|
||||||
"get_authentik_client",
|
"get_authentik_client",
|
||||||
|
"QdrantReadClient",
|
||||||
|
"get_qdrant_client",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
Qdrant Vector Database Client (Read-Only)
|
||||||
|
|
||||||
|
Provides read-only access to Qdrant collections for querying volatile data.
|
||||||
|
Used to fetch weather, forecast, and sun times from the volatile_{user} collection.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantReadClient:
|
||||||
|
"""
|
||||||
|
Read-only Qdrant client for accessing volatile data.
|
||||||
|
|
||||||
|
Connects to Qdrant and provides methods to query collections
|
||||||
|
with filtering by namespace and TTL expiry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
VOLATILE_COLLECTION_PREFIX = "volatile_"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: Optional[str] = None,
|
||||||
|
port: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Qdrant read client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Qdrant server host (default from settings)
|
||||||
|
port: Qdrant server port (default from settings)
|
||||||
|
"""
|
||||||
|
self.host = host or settings.qdrant_host
|
||||||
|
self.port = port or settings.qdrant_port
|
||||||
|
self._client: Optional[QdrantClient] = None
|
||||||
|
|
||||||
|
logger.info(f"Initialized QdrantReadClient: {self.host}:{self.port}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> QdrantClient:
|
||||||
|
"""Lazy-load Qdrant client connection."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = QdrantClient(
|
||||||
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _get_volatile_collection(self, user: str) -> str:
|
||||||
|
"""Get volatile collection name for user."""
|
||||||
|
return f"{self.VOLATILE_COLLECTION_PREFIX}{user}"
|
||||||
|
|
||||||
|
def _current_timestamp_ms(self) -> int:
|
||||||
|
"""Get current timestamp in milliseconds."""
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
async def collection_exists(self, collection_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a collection exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_name: Name of collection to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if collection exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
collections = self.client.get_collections()
|
||||||
|
existing = [c.name for c in collections.collections]
|
||||||
|
return collection_name in existing
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking collection existence: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_by_namespace(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
namespace: str,
|
||||||
|
include_expired: bool = False
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all records for a specific namespace from user's volatile collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (e.g., 'jpmschweitzer' or 'default')
|
||||||
|
namespace: Namespace to filter (e.g., 'weather', 'forecast', 'sun')
|
||||||
|
include_expired: Whether to include expired records (default False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of records with payload data
|
||||||
|
"""
|
||||||
|
collection_name = self._get_volatile_collection(user)
|
||||||
|
|
||||||
|
if not await self.collection_exists(collection_name):
|
||||||
|
logger.debug(f"Collection {collection_name} does not exist")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build filter conditions
|
||||||
|
conditions = [
|
||||||
|
FieldCondition(
|
||||||
|
key="namespace",
|
||||||
|
match=MatchValue(value=namespace)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add TTL expiry filter unless including expired
|
||||||
|
if not include_expired:
|
||||||
|
now_ms = self._current_timestamp_ms()
|
||||||
|
conditions.append(
|
||||||
|
FieldCondition(
|
||||||
|
key="ttl_expiry",
|
||||||
|
range=Range(gt=now_ms)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
query_filter = Filter(must=conditions)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Scroll through matching records
|
||||||
|
points, _ = self.client.scroll(
|
||||||
|
collection_name=collection_name,
|
||||||
|
scroll_filter=query_filter,
|
||||||
|
limit=100,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for point in points:
|
||||||
|
payload = dict(point.payload) if point.payload else {}
|
||||||
|
results.append({
|
||||||
|
"id": str(point.id),
|
||||||
|
"namespace": payload.get("namespace"),
|
||||||
|
"key": payload.get("key"),
|
||||||
|
"raw_data": payload.get("raw_data", {}),
|
||||||
|
"source": payload.get("source"),
|
||||||
|
"ttl_expiry": payload.get("ttl_expiry"),
|
||||||
|
"updated_at": payload.get("updated_at"),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Found {len(results)} records in {collection_name}/{namespace}"
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching from {collection_name}/{namespace}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_environment_data(
|
||||||
|
self,
|
||||||
|
user: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get all environment data (weather, forecast, sun times) for a user.
|
||||||
|
|
||||||
|
Convenience method that fetches all environment-related namespaces
|
||||||
|
in a single call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'weather', 'forecast', 'sun_times', 'air_quality' keys
|
||||||
|
(each may be None if no data found)
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"weather": None,
|
||||||
|
"forecast": None,
|
||||||
|
"sun_times": None,
|
||||||
|
"air_quality": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch weather data
|
||||||
|
weather_records = await self.get_by_namespace(user, "weather")
|
||||||
|
if weather_records:
|
||||||
|
# Get the first/most recent weather record
|
||||||
|
result["weather"] = weather_records[0].get("raw_data")
|
||||||
|
# Check if air quality is embedded in weather data
|
||||||
|
if result["weather"]:
|
||||||
|
aqi = result["weather"].get("aqi") or result["weather"].get("air_quality")
|
||||||
|
if aqi:
|
||||||
|
result["air_quality"] = aqi if isinstance(aqi, dict) else {"aqi": aqi}
|
||||||
|
|
||||||
|
# Fetch forecast data - pass raw_data to service for parsing
|
||||||
|
forecast_records = await self.get_by_namespace(user, "forecast")
|
||||||
|
if forecast_records:
|
||||||
|
result["forecast"] = forecast_records[0].get("raw_data")
|
||||||
|
|
||||||
|
# Fetch sun times data
|
||||||
|
sun_records = await self.get_by_namespace(user, "sun")
|
||||||
|
if sun_records:
|
||||||
|
result["sun_times"] = sun_records[0].get("raw_data")
|
||||||
|
|
||||||
|
# Check for separate air quality namespace if not embedded
|
||||||
|
if result["air_quality"] is None:
|
||||||
|
aq_records = await self.get_by_namespace(user, "air_quality")
|
||||||
|
if aq_records:
|
||||||
|
result["air_quality"] = aq_records[0].get("raw_data")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check Qdrant connectivity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with connection status and info
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
collections = self.client.get_collections()
|
||||||
|
volatile_collections = [
|
||||||
|
c.name for c in collections.collections
|
||||||
|
if c.name.startswith(self.VOLATILE_COLLECTION_PREFIX)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"connected": True,
|
||||||
|
"host": f"{self.host}:{self.port}",
|
||||||
|
"volatile_collections": volatile_collections,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Qdrant health check failed: {e}")
|
||||||
|
return {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"connected": False,
|
||||||
|
"host": f"{self.host}:{self.port}",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance for reuse
|
||||||
|
_qdrant_client: Optional[QdrantReadClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_qdrant_client() -> QdrantReadClient:
|
||||||
|
"""Get or create singleton Qdrant client instance."""
|
||||||
|
global _qdrant_client
|
||||||
|
if _qdrant_client is None:
|
||||||
|
_qdrant_client = QdrantReadClient()
|
||||||
|
return _qdrant_client
|
||||||
+17
-48
@@ -36,8 +36,15 @@ class Settings(BaseSettings):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8083
|
port: int = 8083
|
||||||
|
|
||||||
# CORS
|
# CORS - Note: When cors_credentials is True, cannot use "*" for origins
|
||||||
cors_origins: list[str] = ["*"]
|
# Set CORS_ORIGINS env var to override (comma-separated list)
|
||||||
|
cors_origins: list[str] = [
|
||||||
|
"https://home.schweitz.net",
|
||||||
|
"https://tatlock.schweitz.net",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
]
|
||||||
cors_credentials: bool = True
|
cors_credentials: bool = True
|
||||||
cors_methods: list[str] = ["*"]
|
cors_methods: list[str] = ["*"]
|
||||||
cors_headers: list[str] = ["*"]
|
cors_headers: list[str] = ["*"]
|
||||||
@@ -45,31 +52,6 @@ class Settings(BaseSettings):
|
|||||||
# Logging
|
# Logging
|
||||||
log_level: str = "DEBUG"
|
log_level: str = "DEBUG"
|
||||||
|
|
||||||
# Ollama Configuration (for AI orchestration)
|
|
||||||
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
|
|
||||||
ollama_timeout: int = 300 # 5 minutes
|
|
||||||
|
|
||||||
# Model Configuration
|
|
||||||
default_model: str = "mistral-nemo-large:latest"
|
|
||||||
agent_model: str = "mistral-nemo-large:latest"
|
|
||||||
code_models: str = "mistral-nemo-large:latest"
|
|
||||||
|
|
||||||
# System Prompt Variant (for A/B testing)
|
|
||||||
system_prompt_variant: str = "v8_holistic"
|
|
||||||
|
|
||||||
# Agent Configuration
|
|
||||||
agent_fallback_enabled: bool = True
|
|
||||||
|
|
||||||
# Model Aliases (OpenAI → Local)
|
|
||||||
alias_gpt35: str = "gemma:7b"
|
|
||||||
alias_gpt4: str = "mistral:7b"
|
|
||||||
alias_gpt4_turbo: str = "mixtral:8x7b"
|
|
||||||
alias_gpt4_code: str = "codestral:latest"
|
|
||||||
|
|
||||||
# Memory Configuration
|
|
||||||
memory_tier1_max_turns: int = 10
|
|
||||||
memory_consolidation_threshold: int = 10
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
# Qdrant Configuration
|
||||||
qdrant_host: str = "qdrant"
|
qdrant_host: str = "qdrant"
|
||||||
qdrant_port: int = 6333
|
qdrant_port: int = 6333
|
||||||
@@ -77,11 +59,6 @@ class Settings(BaseSettings):
|
|||||||
qdrant_collection_documents: str = "core_api_documents"
|
qdrant_collection_documents: str = "core_api_documents"
|
||||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
qdrant_collection_user_facts: str = "core_api_user_facts"
|
||||||
|
|
||||||
# Embeddings (using Ollama)
|
|
||||||
embedding_model: str = "nomic-embed-text"
|
|
||||||
embedding_dimension: int = 768
|
|
||||||
embedding_batch_size: int = 32
|
|
||||||
|
|
||||||
# Search Configuration
|
# Search Configuration
|
||||||
search_provider: str = "searxng"
|
search_provider: str = "searxng"
|
||||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
searxng_url: str # Required - set SEARXNG_URL in .env
|
||||||
@@ -113,28 +90,20 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# OIDC Authentication (Authentik)
|
# OIDC Authentication (Authentik)
|
||||||
oidc_enabled: bool = False
|
oidc_enabled: bool = False
|
||||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
# Accept tokens from multiple OAuth providers (each has its own issuer/JWKS)
|
||||||
oidc_audience: str = "core-api"
|
oidc_issuers: list[str] = [
|
||||||
|
"https://auth.schweitz.net/application/o/core-api/",
|
||||||
|
"https://auth.schweitz.net/application/o/tatlock-ui/",
|
||||||
|
"https://auth.schweitz.net/application/o/tatlock/",
|
||||||
|
]
|
||||||
|
# Accept tokens from multiple clients
|
||||||
|
oidc_audiences: list[str] = ["core-api", "tatlock-ui", "tatlock"]
|
||||||
|
|
||||||
# Authentik API (for token validation and user management)
|
# Authentik API (for token validation and user management)
|
||||||
authentik_url: str = "https://auth.schweitz.net"
|
authentik_url: str = "https://auth.schweitz.net"
|
||||||
authentik_username: str = ""
|
authentik_username: str = ""
|
||||||
authentik_password: str = ""
|
authentik_password: str = ""
|
||||||
|
|
||||||
@property
|
|
||||||
def model_aliases(self) -> dict:
|
|
||||||
"""Computed property for model aliases"""
|
|
||||||
return {
|
|
||||||
"gpt-3.5-turbo": self.alias_gpt35,
|
|
||||||
"gpt-4": self.alias_gpt4,
|
|
||||||
"gpt-4-turbo": self.alias_gpt4_turbo,
|
|
||||||
"gpt-4-code": self.alias_gpt4_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_code_models(self) -> list[str]:
|
|
||||||
"""Parse comma-separated code models"""
|
|
||||||
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = False
|
case_sensitive = False
|
||||||
|
|||||||
+13
-5
@@ -17,15 +17,23 @@ def initialize_oidc(settings: Settings) -> None:
|
|||||||
settings: Application settings containing OIDC configuration
|
settings: Application settings containing OIDC configuration
|
||||||
"""
|
"""
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from src.auth.oidc import oidc_config
|
# Configure BOTH oidc modules (src.auth and src.domains.auth)
|
||||||
|
from src.auth.oidc import oidc_config as auth_oidc_config
|
||||||
|
from src.domains.auth.oidc import oidc_config as domains_oidc_config
|
||||||
|
|
||||||
oidc_config.configure(
|
auth_oidc_config.configure(
|
||||||
enabled=settings.oidc_enabled,
|
enabled=settings.oidc_enabled,
|
||||||
issuer=settings.oidc_issuer,
|
issuers=settings.oidc_issuers,
|
||||||
audience=settings.oidc_audience
|
audiences=settings.oidc_audiences
|
||||||
|
)
|
||||||
|
|
||||||
|
domains_oidc_config.configure(
|
||||||
|
enabled=settings.oidc_enabled,
|
||||||
|
issuers=settings.oidc_issuers,
|
||||||
|
audiences=settings.oidc_audiences
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.oidc_enabled:
|
if settings.oidc_enabled:
|
||||||
logger.info(f"OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
logger.info(f"OIDC authentication enabled (issuers: {settings.oidc_issuers})")
|
||||||
else:
|
else:
|
||||||
logger.info("OIDC authentication disabled - API is publicly accessible")
|
logger.info("OIDC authentication disabled - API is publicly accessible")
|
||||||
|
|||||||
@@ -812,3 +812,219 @@ class TestPhase4Schemas:
|
|||||||
response = ApiKeysListResponse(items=[key], total=1)
|
response = ApiKeysListResponse(items=[key], total=1)
|
||||||
assert len(response.items) == 1
|
assert len(response.items) == 1
|
||||||
assert response.total == 1
|
assert response.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET /auth/users/me Endpoint Tests (NPM Forward Auth)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAuthMeEndpoint:
|
||||||
|
"""
|
||||||
|
Test GET /auth/users/me with NPM forward auth.
|
||||||
|
|
||||||
|
The path is /auth/users/me, not /auth/me — the route is declared as "/me"
|
||||||
|
inside AuthController.create_router(), which mounts under a users prefix.
|
||||||
|
These tests asserted /auth/me and had never passed; the generated spec is
|
||||||
|
authoritative and both the local app and the deployed service agree on 62
|
||||||
|
paths including this one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PATH = "/auth/users/me"
|
||||||
|
|
||||||
|
def test_auth_me_in_openapi(self, client):
|
||||||
|
"""Auth me endpoint should be in OpenAPI spec."""
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
spec = response.json()
|
||||||
|
assert self.PATH in spec["paths"]
|
||||||
|
assert "get" in spec["paths"][self.PATH]
|
||||||
|
|
||||||
|
def test_auth_me_returns_401_without_forward_auth(self, client):
|
||||||
|
"""Should return 401 when accessed without forward auth headers."""
|
||||||
|
response = client.get(self.PATH)
|
||||||
|
# Without NPM forward auth headers, should return 401
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_auth_me_response_schema(self, client):
|
||||||
|
"""Auth me should return AuthSyncResponse schema."""
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
spec = response.json()
|
||||||
|
|
||||||
|
# Check response schema references AuthSyncResponse
|
||||||
|
me_endpoint = spec["paths"][self.PATH]["get"]
|
||||||
|
assert "responses" in me_endpoint
|
||||||
|
assert "200" in me_endpoint["responses"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardAuthParsing:
|
||||||
|
"""Test NPM forward auth header parsing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parses_all_headers(self):
|
||||||
|
"""Should parse all X-authentik-* headers."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "john.doe@example.com",
|
||||||
|
"x-authentik-groups": "tatlock-admins, tatlock-media-viewers",
|
||||||
|
"x-authentik-name": "John Doe",
|
||||||
|
"x-authentik-uid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["username"] == "jdoe"
|
||||||
|
assert result["email"] == "john.doe@example.com"
|
||||||
|
assert result["name"] == "John Doe"
|
||||||
|
assert result["uid"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
assert "tatlock-admins" in result["groups"]
|
||||||
|
assert "tatlock-media-viewers" in result["groups"]
|
||||||
|
assert result["auth_method"] == "forward_auth"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_for_internal_access(self):
|
||||||
|
"""Should return None when no forward auth headers (internal access)."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.headers.get.return_value = None
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_401_missing_email(self):
|
||||||
|
"""Should raise 401 when username present but email missing."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_empty_groups(self):
|
||||||
|
"""Should handle empty groups header."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "jdoe@example.com",
|
||||||
|
"x-authentik-groups": "",
|
||||||
|
"x-authentik-name": None,
|
||||||
|
"x-authentik-uid": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
# An empty groups header means no groups, not one group named "".
|
||||||
|
# oidc.py has guarded this since the initial commit — this assertion
|
||||||
|
# expected [""] and had never passed. [""] would also be unsafe: any
|
||||||
|
# authorization check doing `"" in groups` would match.
|
||||||
|
assert result["groups"] == []
|
||||||
|
assert result["name"] == "jdoe" # Falls back to username
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strips_whitespace_from_groups(self):
|
||||||
|
"""Should strip whitespace from group names."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "jdoe@example.com",
|
||||||
|
"x-authentik-groups": " group1 , group2 ,group3",
|
||||||
|
"x-authentik-name": "John",
|
||||||
|
"x-authentik-uid": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["groups"] == ["group1", "group2", "group3"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthServiceNewMethods:
|
||||||
|
"""Test new AuthService methods for /auth/me."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email(self):
|
||||||
|
"""Should find user by email."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.email = "test@example.com"
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_email("test@example.com")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.email == "test@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email_not_found(self):
|
||||||
|
"""Should return None when user not found."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_email("notfound@example.com")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_authentik_id(self):
|
||||||
|
"""Should find user by Authentik UUID."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
test_id = uuid.uuid4()
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.authentik_id = test_id
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_authentik_id(test_id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.authentik_id == test_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_authentik_id_not_found(self):
|
||||||
|
"""Should return None when user not found by Authentik ID."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_authentik_id(uuid.uuid4())
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for config module."""
|
"""Tests for config module."""
|
||||||
import pytest
|
import pytest
|
||||||
from src.config import (
|
from src.shared.config import (
|
||||||
__version__,
|
__version__,
|
||||||
Settings,
|
Settings,
|
||||||
get_settings,
|
get_settings,
|
||||||
@@ -89,10 +89,6 @@ class TestGetSettings:
|
|||||||
settings2 = get_settings()
|
settings2 = get_settings()
|
||||||
assert settings1 is settings2
|
assert settings1 is settings2
|
||||||
|
|
||||||
def test_model_aliases_property(self):
|
# Removed: test_model_aliases_property. Settings.model_aliases mapped
|
||||||
"""Model aliases property should return dict."""
|
# gpt-3.5-turbo and gpt-4 onto local models, and was deleted along with the
|
||||||
settings = get_settings()
|
# Ollama integration in c1f16d4. The test outlived the feature it covered.
|
||||||
aliases = settings.model_aliases
|
|
||||||
assert isinstance(aliases, dict)
|
|
||||||
assert "gpt-3.5-turbo" in aliases
|
|
||||||
assert "gpt-4" in aliases
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests for environment service and schemas."""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvironmentService:
|
||||||
|
"""Test EnvironmentService methods."""
|
||||||
|
|
||||||
|
def test_aqi_to_quality_good(self):
|
||||||
|
"""AQI 0-50 should return Good."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
assert service._aqi_to_quality(0) == "Good"
|
||||||
|
assert service._aqi_to_quality(25) == "Good"
|
||||||
|
assert service._aqi_to_quality(50) == "Good"
|
||||||
|
|
||||||
|
def test_aqi_to_quality_moderate(self):
|
||||||
|
"""AQI 51-100 should return Moderate."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
assert service._aqi_to_quality(51) == "Moderate"
|
||||||
|
assert service._aqi_to_quality(75) == "Moderate"
|
||||||
|
assert service._aqi_to_quality(100) == "Moderate"
|
||||||
|
|
||||||
|
def test_aqi_to_quality_unhealthy_sensitive(self):
|
||||||
|
"""AQI 101-150 should return Unhealthy for Sensitive Groups."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
assert service._aqi_to_quality(101) == "Unhealthy for Sensitive Groups"
|
||||||
|
assert service._aqi_to_quality(150) == "Unhealthy for Sensitive Groups"
|
||||||
|
|
||||||
|
def test_aqi_to_quality_unhealthy(self):
|
||||||
|
"""AQI 151-200 should return Unhealthy."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
assert service._aqi_to_quality(151) == "Unhealthy"
|
||||||
|
assert service._aqi_to_quality(200) == "Unhealthy"
|
||||||
|
|
||||||
|
def test_aqi_to_quality_very_unhealthy(self):
|
||||||
|
"""AQI 201-300 should return Very Unhealthy."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
assert service._aqi_to_quality(201) == "Very Unhealthy"
|
||||||
|
assert service._aqi_to_quality(300) == "Very Unhealthy"
|
||||||
|
|
||||||
|
def test_aqi_to_quality_hazardous(self):
|
||||||
|
"""AQI >300 should return Hazardous."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
assert service._aqi_to_quality(301) == "Hazardous"
|
||||||
|
assert service._aqi_to_quality(500) == "Hazardous"
|
||||||
|
|
||||||
|
def test_parse_weather_with_valid_data(self):
|
||||||
|
"""Parse weather should return WeatherData for valid input."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
raw = {
|
||||||
|
"temperature": 15.5,
|
||||||
|
"conditions": "Cloudy",
|
||||||
|
"humidity": 72,
|
||||||
|
"location": "Rotterdam",
|
||||||
|
}
|
||||||
|
result = service._parse_weather(raw)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.temperature == 15.5
|
||||||
|
assert result.conditions == "Cloudy"
|
||||||
|
assert result.humidity == 72
|
||||||
|
|
||||||
|
def test_parse_weather_with_none(self):
|
||||||
|
"""Parse weather should return None for None input."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
result = service._parse_weather(None)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_parse_forecast_with_list(self):
|
||||||
|
"""Parse forecast should handle list format."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
raw = [
|
||||||
|
{"date": "2026-01-06", "high": 12, "low": 5, "conditions": "Cloudy"},
|
||||||
|
{"date": "2026-01-07", "high": 14, "low": 6, "conditions": "Sunny"},
|
||||||
|
]
|
||||||
|
result = service._parse_forecast(raw)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].date == "2026-01-06"
|
||||||
|
assert result[0].high == 12
|
||||||
|
|
||||||
|
def test_parse_forecast_with_dict(self):
|
||||||
|
"""Parse forecast should handle dict with days key."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
raw = {
|
||||||
|
"days": [
|
||||||
|
{"date": "2026-01-06", "high": 12, "low": 5, "conditions": "Cloudy"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = service._parse_forecast(raw)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_parse_sun_times_with_strings(self):
|
||||||
|
"""Parse sun times should handle ISO datetime strings."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
raw = {
|
||||||
|
"sunrise": "2026-01-06T08:45:00",
|
||||||
|
"sunset": "2026-01-06T16:50:00",
|
||||||
|
}
|
||||||
|
result = service._parse_sun_times(raw)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.sunrise.hour == 8
|
||||||
|
assert result.sunrise.minute == 45
|
||||||
|
assert result.sunset.hour == 16
|
||||||
|
assert result.daylight_minutes == 485
|
||||||
|
|
||||||
|
def test_parse_air_quality_with_int(self):
|
||||||
|
"""Parse air quality should handle simple integer AQI."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
result = service._parse_air_quality(42)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.aqi == 42
|
||||||
|
assert result.quality == "Good"
|
||||||
|
|
||||||
|
def test_parse_air_quality_with_dict(self):
|
||||||
|
"""Parse air quality should handle dict format."""
|
||||||
|
from src.domains.tools.environment.service import EnvironmentService
|
||||||
|
service = EnvironmentService.__new__(EnvironmentService)
|
||||||
|
|
||||||
|
raw = {
|
||||||
|
"aqi": 75,
|
||||||
|
"pm25": 8.5,
|
||||||
|
"pm10": 15,
|
||||||
|
}
|
||||||
|
result = service._parse_air_quality(raw)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.aqi == 75
|
||||||
|
assert result.quality == "Moderate"
|
||||||
|
assert result.pm25 == 8.5
|
||||||
+19
-87
@@ -80,24 +80,24 @@ class TestFullHealthCheck:
|
|||||||
"""Test /health/full endpoint."""
|
"""Test /health/full endpoint."""
|
||||||
|
|
||||||
@patch("src.shared.database.Database.health_check")
|
@patch("src.shared.database.Database.health_check")
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_full_health_returns_200_when_healthy(self, mock_db_health, client):
|
||||||
def test_full_health_returns_503_when_unhealthy(self, mock_get_ollama, mock_db_health, client):
|
"""Full health should return 200 when database is healthy."""
|
||||||
"""Full health should return 503 when Ollama unhealthy."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = False
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
mock_db_health.return_value = True
|
mock_db_health.return_value = True
|
||||||
|
|
||||||
|
response = client.get("/health/full")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@patch("src.shared.database.Database.health_check")
|
||||||
|
def test_full_health_returns_503_when_unhealthy(self, mock_db_health, client):
|
||||||
|
"""Full health should return 503 when database is unhealthy."""
|
||||||
|
mock_db_health.return_value = False
|
||||||
|
|
||||||
response = client.get("/health/full")
|
response = client.get("/health/full")
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
@patch("src.shared.database.Database.health_check")
|
@patch("src.shared.database.Database.health_check")
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_full_health_returns_components_status(self, mock_db_health, client):
|
||||||
def test_full_health_returns_components_status(self, mock_get_ollama, mock_db_health, client):
|
|
||||||
"""Full health should return component status."""
|
"""Full health should return component status."""
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = False
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
mock_db_health.return_value = True
|
mock_db_health.return_value = True
|
||||||
|
|
||||||
response = client.get("/health/full")
|
response = client.get("/health/full")
|
||||||
@@ -105,63 +105,32 @@ class TestFullHealthCheck:
|
|||||||
|
|
||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert "components" in data
|
assert "components" in data
|
||||||
assert "ollama" in data["components"]
|
|
||||||
assert "database" in data["components"]
|
assert "database" in data["components"]
|
||||||
assert "response_time_ms" in data
|
assert "response_time_ms" in data
|
||||||
|
|
||||||
@patch("src.shared.database.Database.health_check")
|
@patch("src.shared.database.Database.health_check")
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_full_health_handles_database_error(self, mock_db_health, client):
|
||||||
def test_full_health_handles_list_models_error(self, mock_get_ollama, mock_db_health, client):
|
"""Full health should handle database errors gracefully."""
|
||||||
"""Full health should handle list_models errors."""
|
mock_db_health.side_effect = Exception("Connection error")
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = True
|
|
||||||
mock_client.list_models.side_effect = Exception("Connection error")
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
mock_db_health.return_value = True
|
|
||||||
|
|
||||||
response = client.get("/health/full")
|
response = client.get("/health/full")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Should report error in component status
|
|
||||||
assert "ollama" in data["components"]
|
|
||||||
|
|
||||||
@patch("src.shared.database.Database.health_check")
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
|
||||||
def test_full_health_handles_health_check_exception(self, mock_get_ollama, mock_db_health, client):
|
|
||||||
"""Full health should handle health check exceptions gracefully."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
# Return False instead of raising exception to test unhealthy path
|
|
||||||
mock_client.health_check.return_value = False
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
mock_db_health.return_value = True
|
|
||||||
|
|
||||||
response = client.get("/health/full")
|
|
||||||
# Should return 503 for unhealthy
|
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
data = response.json()
|
|
||||||
assert data["status"] == "unhealthy"
|
assert data["status"] == "unhealthy"
|
||||||
|
assert "error" in data["components"]["database"]
|
||||||
|
|
||||||
|
|
||||||
class TestDiagnosticsEndpoint:
|
class TestDiagnosticsEndpoint:
|
||||||
"""Test /health/diagnostics endpoint."""
|
"""Test /health/diagnostics endpoint."""
|
||||||
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_diagnostics_returns_200(self, client):
|
||||||
def test_diagnostics_returns_200(self, mock_get_ollama, client):
|
|
||||||
"""Diagnostics should return 200."""
|
"""Diagnostics should return 200."""
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = True
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
|
|
||||||
response = client.get("/health/diagnostics")
|
response = client.get("/health/diagnostics")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_diagnostics_returns_service_info(self, client):
|
||||||
def test_diagnostics_returns_service_info(self, mock_get_ollama, client):
|
|
||||||
"""Diagnostics should return service information."""
|
"""Diagnostics should return service information."""
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = True
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
|
|
||||||
response = client.get("/health/diagnostics")
|
response = client.get("/health/diagnostics")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
@@ -169,54 +138,17 @@ class TestDiagnosticsEndpoint:
|
|||||||
assert "name" in data["service"]
|
assert "name" in data["service"]
|
||||||
assert "version" in data["service"]
|
assert "version" in data["service"]
|
||||||
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_diagnostics_returns_configuration(self, client):
|
||||||
def test_diagnostics_returns_components(self, mock_get_ollama, client):
|
|
||||||
"""Diagnostics should return component details."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = True
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
|
|
||||||
response = client.get("/health/diagnostics")
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
assert "components" in data
|
|
||||||
assert "ollama" in data["components"]
|
|
||||||
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
|
||||||
def test_diagnostics_returns_configuration(self, mock_get_ollama, client):
|
|
||||||
"""Diagnostics should return configuration info."""
|
"""Diagnostics should return configuration info."""
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = True
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
|
|
||||||
response = client.get("/health/diagnostics")
|
response = client.get("/health/diagnostics")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
assert "configuration" in data
|
assert "configuration" in data
|
||||||
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
def test_diagnostics_returns_response_time(self, client):
|
||||||
def test_diagnostics_returns_response_time(self, mock_get_ollama, client):
|
|
||||||
"""Diagnostics should return response time."""
|
"""Diagnostics should return response time."""
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.return_value = True
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
|
|
||||||
response = client.get("/health/diagnostics")
|
response = client.get("/health/diagnostics")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
assert "response_time_ms" in data
|
assert "response_time_ms" in data
|
||||||
assert isinstance(data["response_time_ms"], int)
|
assert isinstance(data["response_time_ms"], int)
|
||||||
|
|
||||||
@patch("src.models.ollama_client.get_ollama_client")
|
|
||||||
def test_diagnostics_handles_ollama_error(self, mock_get_ollama, client):
|
|
||||||
"""Diagnostics should handle Ollama connection errors."""
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.health_check.side_effect = Exception("Connection refused")
|
|
||||||
mock_get_ollama.return_value = mock_client
|
|
||||||
|
|
||||||
response = client.get("/health/diagnostics")
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Should still return 200 with error info
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "error" in data["components"]["ollama"]
|
|
||||||
|
|||||||
+114
-62
@@ -1,118 +1,173 @@
|
|||||||
"""Tests for OIDC authentication module."""
|
"""Tests for OIDC authentication module."""
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock, AsyncMock
|
from unittest.mock import patch, MagicMock
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from src.auth.oidc import OIDCConfig, oidc_config, get_jwks, get_current_user
|
from src.auth.oidc import (
|
||||||
|
OIDCConfig,
|
||||||
|
oidc_config,
|
||||||
|
get_jwks_for_issuer,
|
||||||
|
get_current_user,
|
||||||
|
_jwks_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_jwks_cache():
|
||||||
|
"""
|
||||||
|
The JWKS cache is module-level state, so a fetch in one test would satisfy
|
||||||
|
the next one and hide a regression. Clearing on both sides keeps the tests
|
||||||
|
order-independent.
|
||||||
|
"""
|
||||||
|
_jwks_cache.clear()
|
||||||
|
yield
|
||||||
|
_jwks_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
class TestOIDCConfig:
|
class TestOIDCConfig:
|
||||||
"""Test OIDCConfig class."""
|
"""Test OIDCConfig class."""
|
||||||
|
|
||||||
def test_init_defaults(self):
|
def test_init_defaults(self):
|
||||||
"""Config should initialize with disabled state."""
|
"""Config should initialize disabled with no issuers or audiences."""
|
||||||
config = OIDCConfig()
|
config = OIDCConfig()
|
||||||
|
|
||||||
assert config.enabled is False
|
assert config.enabled is False
|
||||||
assert config.issuer == ""
|
assert config.issuers == []
|
||||||
assert config.audience == ""
|
assert config.audiences == []
|
||||||
assert config.jwks_uri == ""
|
|
||||||
|
|
||||||
def test_configure_sets_values(self):
|
def test_configure_sets_values(self):
|
||||||
"""configure should set all values."""
|
"""configure should set all values."""
|
||||||
config = OIDCConfig()
|
config = OIDCConfig()
|
||||||
config.configure(
|
config.configure(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
issuer="https://auth.example.com",
|
issuers=["https://auth.example.com"],
|
||||||
audience="core-api"
|
audiences=["core-api"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert config.enabled is True
|
assert config.enabled is True
|
||||||
assert config.issuer == "https://auth.example.com"
|
assert config.issuers == ["https://auth.example.com"]
|
||||||
assert config.audience == "core-api"
|
assert config.audiences == ["core-api"]
|
||||||
assert config.jwks_uri == "https://auth.example.com/jwks/"
|
|
||||||
|
|
||||||
def test_configure_strips_trailing_slash(self):
|
def test_configure_strips_trailing_slash(self):
|
||||||
"""configure should handle trailing slash in issuer."""
|
"""configure should normalise issuers by dropping the trailing slash."""
|
||||||
config = OIDCConfig()
|
config = OIDCConfig()
|
||||||
config.configure(
|
config.configure(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
issuer="https://auth.example.com/",
|
issuers=["https://auth.example.com/"],
|
||||||
audience="core-api"
|
audiences=["core-api"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert config.jwks_uri == "https://auth.example.com/jwks/"
|
assert config.issuers == ["https://auth.example.com"]
|
||||||
|
|
||||||
|
def test_configure_accepts_multiple_issuers(self):
|
||||||
|
"""The point of the multi-issuer change: more than one is allowed."""
|
||||||
|
config = OIDCConfig()
|
||||||
|
config.configure(
|
||||||
|
enabled=True,
|
||||||
|
issuers=["https://a.example.com/", "https://b.example.com"],
|
||||||
|
audiences=["core-api", "other"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.issuers == ["https://a.example.com", "https://b.example.com"]
|
||||||
|
assert config.audiences == ["core-api", "other"]
|
||||||
|
|
||||||
|
def test_get_jwks_uri_derives_from_issuer(self):
|
||||||
|
"""The JWKS URI is derived per issuer rather than configured."""
|
||||||
|
config = OIDCConfig()
|
||||||
|
|
||||||
|
assert config.get_jwks_uri("https://auth.example.com") == "https://auth.example.com/jwks/"
|
||||||
|
assert config.get_jwks_uri("https://auth.example.com/") == "https://auth.example.com/jwks/"
|
||||||
|
|
||||||
|
def test_is_valid_issuer_only_accepts_configured(self):
|
||||||
|
"""
|
||||||
|
An unconfigured issuer must be rejected. This is the security-relevant
|
||||||
|
half of multi-issuer support: accepting any issuer would let a token
|
||||||
|
from an unrelated identity provider through.
|
||||||
|
"""
|
||||||
|
config = OIDCConfig()
|
||||||
|
config.configure(
|
||||||
|
enabled=True,
|
||||||
|
issuers=["https://auth.example.com"],
|
||||||
|
audiences=["core-api"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.is_valid_issuer("https://auth.example.com") is True
|
||||||
|
assert config.is_valid_issuer("https://auth.example.com/") is True
|
||||||
|
assert config.is_valid_issuer("https://evil.example.com") is False
|
||||||
|
|
||||||
|
|
||||||
class TestGetJWKS:
|
class TestGetJWKSForIssuer:
|
||||||
"""Test get_jwks function."""
|
"""Test get_jwks_for_issuer function."""
|
||||||
|
|
||||||
|
ISSUER = "https://auth.example.com"
|
||||||
|
|
||||||
def test_returns_empty_when_disabled(self):
|
def test_returns_empty_when_disabled(self):
|
||||||
"""get_jwks should return empty dict when OIDC disabled."""
|
"""Should return an empty dict when OIDC is disabled."""
|
||||||
# Save original state
|
|
||||||
original_enabled = oidc_config.enabled
|
original_enabled = oidc_config.enabled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oidc_config.enabled = False
|
oidc_config.enabled = False
|
||||||
# Clear the cache
|
|
||||||
get_jwks.cache_clear()
|
|
||||||
|
|
||||||
result = get_jwks()
|
assert get_jwks_for_issuer(self.ISSUER) == {}
|
||||||
|
|
||||||
assert result == {}
|
|
||||||
finally:
|
finally:
|
||||||
# Restore original state
|
|
||||||
oidc_config.enabled = original_enabled
|
oidc_config.enabled = original_enabled
|
||||||
get_jwks.cache_clear()
|
|
||||||
|
|
||||||
@patch("src.auth.oidc.httpx.get")
|
@patch("src.auth.oidc.httpx.get")
|
||||||
def test_fetches_jwks_when_enabled(self, mock_get):
|
def test_fetches_jwks_when_enabled(self, mock_get):
|
||||||
"""get_jwks should fetch JWKS when enabled."""
|
"""Should fetch from the issuer's derived JWKS URI."""
|
||||||
# Save original state
|
|
||||||
original_enabled = oidc_config.enabled
|
original_enabled = oidc_config.enabled
|
||||||
original_jwks_uri = oidc_config.jwks_uri
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oidc_config.enabled = True
|
oidc_config.enabled = True
|
||||||
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
|
|
||||||
get_jwks.cache_clear()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"keys": [{"kid": "test"}]}
|
mock_response.json.return_value = {"keys": [{"kid": "abc"}]}
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
result = get_jwks()
|
result = get_jwks_for_issuer(self.ISSUER)
|
||||||
|
|
||||||
assert "keys" in result
|
assert result == {"keys": [{"kid": "abc"}]}
|
||||||
mock_get.assert_called_once()
|
mock_get.assert_called_once()
|
||||||
|
assert mock_get.call_args[0][0] == f"{self.ISSUER}/jwks/"
|
||||||
finally:
|
finally:
|
||||||
oidc_config.enabled = original_enabled
|
oidc_config.enabled = original_enabled
|
||||||
oidc_config.jwks_uri = original_jwks_uri
|
|
||||||
get_jwks.cache_clear()
|
|
||||||
|
|
||||||
@patch("src.auth.oidc.httpx.get")
|
@patch("src.auth.oidc.httpx.get")
|
||||||
def test_raises_exception_on_error(self, mock_get):
|
def test_caches_per_issuer(self, mock_get):
|
||||||
"""get_jwks should raise HTTPException on fetch error."""
|
"""
|
||||||
# Save original state
|
A second call for the same issuer must not refetch, and a different
|
||||||
|
issuer must. Caching by issuer is the behaviour the multi-issuer change
|
||||||
|
introduced, and a shared cache would have served one issuer's keys for
|
||||||
|
another — which would be a verification bypass, not just a slow path.
|
||||||
|
"""
|
||||||
original_enabled = oidc_config.enabled
|
original_enabled = oidc_config.enabled
|
||||||
original_jwks_uri = oidc_config.jwks_uri
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oidc_config.enabled = True
|
oidc_config.enabled = True
|
||||||
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
|
mock_response = MagicMock()
|
||||||
get_jwks.cache_clear()
|
mock_response.json.return_value = {"keys": []}
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
mock_get.side_effect = Exception("Connection error")
|
get_jwks_for_issuer(self.ISSUER)
|
||||||
|
get_jwks_for_issuer(self.ISSUER + "/") # same issuer, normalised
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
get_jwks_for_issuer("https://other.example.com")
|
||||||
|
assert mock_get.call_count == 2
|
||||||
|
finally:
|
||||||
|
oidc_config.enabled = original_enabled
|
||||||
|
|
||||||
|
@patch("src.auth.oidc.httpx.get")
|
||||||
|
def test_raises_503_on_fetch_error(self, mock_get):
|
||||||
|
"""A JWKS fetch failure should surface as 503, not leak the cause."""
|
||||||
|
original_enabled = oidc_config.enabled
|
||||||
|
try:
|
||||||
|
oidc_config.enabled = True
|
||||||
|
mock_get.side_effect = Exception("Connection failed")
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
get_jwks()
|
get_jwks_for_issuer(self.ISSUER)
|
||||||
|
|
||||||
assert exc_info.value.status_code == 503
|
assert exc_info.value.status_code == 503
|
||||||
finally:
|
finally:
|
||||||
oidc_config.enabled = original_enabled
|
oidc_config.enabled = original_enabled
|
||||||
oidc_config.jwks_uri = original_jwks_uri
|
|
||||||
get_jwks.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetCurrentUser:
|
class TestGetCurrentUser:
|
||||||
@@ -121,9 +176,7 @@ class TestGetCurrentUser:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_none_when_disabled(self):
|
async def test_returns_none_when_disabled(self):
|
||||||
"""get_current_user should return None when OIDC disabled."""
|
"""get_current_user should return None when OIDC disabled."""
|
||||||
# Save original state
|
|
||||||
original_enabled = oidc_config.enabled
|
original_enabled = oidc_config.enabled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oidc_config.enabled = False
|
oidc_config.enabled = False
|
||||||
|
|
||||||
@@ -136,9 +189,7 @@ class TestGetCurrentUser:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_raises_401_when_enabled_without_token(self):
|
async def test_raises_401_when_enabled_without_token(self):
|
||||||
"""get_current_user should raise 401 when enabled but no token."""
|
"""get_current_user should raise 401 when enabled but no token."""
|
||||||
# Save original state
|
|
||||||
original_enabled = oidc_config.enabled
|
original_enabled = oidc_config.enabled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
oidc_config.enabled = True
|
oidc_config.enabled = True
|
||||||
|
|
||||||
@@ -157,11 +208,12 @@ class TestOIDCGlobalConfig:
|
|||||||
"""oidc_config should be an OIDCConfig instance."""
|
"""oidc_config should be an OIDCConfig instance."""
|
||||||
assert isinstance(oidc_config, OIDCConfig)
|
assert isinstance(oidc_config, OIDCConfig)
|
||||||
|
|
||||||
def test_global_config_starts_disabled(self):
|
def test_global_config_exposes_the_multi_issuer_surface(self):
|
||||||
"""oidc_config should start disabled by default."""
|
"""
|
||||||
# This tests the initial state before any configure() is called
|
Asserts the shape rather than the values, since the live state depends
|
||||||
# The actual state depends on app configuration
|
on app configuration. These four are what callers depend on.
|
||||||
assert hasattr(oidc_config, 'enabled')
|
"""
|
||||||
assert hasattr(oidc_config, 'issuer')
|
assert hasattr(oidc_config, "enabled")
|
||||||
assert hasattr(oidc_config, 'audience')
|
assert hasattr(oidc_config, "issuers")
|
||||||
assert hasattr(oidc_config, 'jwks_uri')
|
assert hasattr(oidc_config, "audiences")
|
||||||
|
assert callable(oidc_config.get_jwks_uri)
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
"""Tests for Ollama client."""
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, AsyncMock, MagicMock
|
|
||||||
import json
|
|
||||||
|
|
||||||
from src.models.ollama_client import OllamaClient, get_ollama_client, close_ollama_client
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientInit:
|
|
||||||
"""Test OllamaClient initialization."""
|
|
||||||
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
def test_uses_settings_defaults(self, mock_settings):
|
|
||||||
"""Client should use settings for defaults."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 60
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
assert client.base_url == "http://ollama:11434"
|
|
||||||
assert client.timeout == 60
|
|
||||||
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
def test_creates_http_client(self, mock_settings):
|
|
||||||
"""Client should create httpx AsyncClient."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
assert client.client is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientClose:
|
|
||||||
"""Test client close functionality."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_close_closes_client(self, mock_settings):
|
|
||||||
"""close should close the HTTP client."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
|
|
||||||
await client.close()
|
|
||||||
mock_close.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientResolveModel:
|
|
||||||
"""Test model resolution."""
|
|
||||||
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
def test_resolves_aliased_model(self, mock_settings):
|
|
||||||
"""resolve_model should map alias to actual model."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
mock_settings.model_aliases = {"gpt-3.5-turbo": "gemma:7b"}
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
result = client.resolve_model("gpt-3.5-turbo")
|
|
||||||
|
|
||||||
assert result == "gemma:7b"
|
|
||||||
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
def test_returns_original_if_no_alias(self, mock_settings):
|
|
||||||
"""resolve_model should return original if no alias found."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
mock_settings.model_aliases = {}
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
result = client.resolve_model("llama2")
|
|
||||||
|
|
||||||
assert result == "llama2"
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientHealthCheck:
|
|
||||||
"""Test health check functionality."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_health_check_returns_true_on_200(self, mock_settings):
|
|
||||||
"""Health check should return True when Ollama responds 200."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
|
|
||||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
result = await client.health_check()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
mock_get.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_health_check_returns_false_on_error(self, mock_settings):
|
|
||||||
"""Health check should return False on connection error."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
|
||||||
mock_get.side_effect = Exception("Connection refused")
|
|
||||||
result = await client.health_check()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_health_check_returns_false_on_non_200(self, mock_settings):
|
|
||||||
"""Health check should return False on non-200 status."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 500
|
|
||||||
|
|
||||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
result = await client.health_check()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientListModels:
|
|
||||||
"""Test list models functionality."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_list_models_returns_dict(self, mock_settings):
|
|
||||||
"""list_models should return dict with models."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
models_data = {
|
|
||||||
"models": [
|
|
||||||
{"name": "llama2", "size": 1000000},
|
|
||||||
{"name": "gemma:7b", "size": 2000000}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = models_data
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
result = await client.list_models()
|
|
||||||
|
|
||||||
assert result == models_data
|
|
||||||
assert len(result["models"]) == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_list_models_raises_on_error(self, mock_settings):
|
|
||||||
"""list_models should raise on error."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
|
||||||
mock_get.side_effect = Exception("Connection error")
|
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
await client.list_models()
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientGenerateNonStreaming:
|
|
||||||
"""Test non-streaming generation."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_generate_non_streaming_returns_response(self, mock_settings):
|
|
||||||
"""generate_non_streaming should return response dict."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
mock_settings.model_aliases = {}
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
response_data = {
|
|
||||||
"message": {"content": "Hello! How can I help?"},
|
|
||||||
"prompt_eval_count": 10,
|
|
||||||
"eval_count": 20
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = response_data
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
result = await client.generate_non_streaming("llama2", "Hello")
|
|
||||||
|
|
||||||
assert result["response"] == "Hello! How can I help?"
|
|
||||||
assert result["tokens"]["prompt"] == 10
|
|
||||||
assert result["tokens"]["completion"] == 20
|
|
||||||
assert result["tokens"]["total"] == 30
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_generate_non_streaming_includes_max_tokens(self, mock_settings):
|
|
||||||
"""generate_non_streaming should include max_tokens in payload."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
mock_settings.model_aliases = {}
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = {"message": {"content": "Hi"}}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
await client.generate_non_streaming("llama2", "Hello", max_tokens=100)
|
|
||||||
|
|
||||||
call_args = mock_post.call_args
|
|
||||||
assert call_args[1]["json"]["options"]["num_predict"] == 100
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientGenerateStreaming:
|
|
||||||
"""Test streaming generation."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_generate_streaming_yields_content(self, mock_settings):
|
|
||||||
"""generate_streaming should yield content chunks."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
mock_settings.model_aliases = {}
|
|
||||||
|
|
||||||
client = OllamaClient()
|
|
||||||
|
|
||||||
# Create mock streaming response
|
|
||||||
async def mock_aiter_lines():
|
|
||||||
yield json.dumps({"message": {"content": "Hello"}})
|
|
||||||
yield json.dumps({"message": {"content": " world"}})
|
|
||||||
yield json.dumps({"done": True})
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
mock_response.aiter_lines = mock_aiter_lines
|
|
||||||
|
|
||||||
mock_stream_context = AsyncMock()
|
|
||||||
mock_stream_context.__aenter__.return_value = mock_response
|
|
||||||
mock_stream_context.__aexit__.return_value = None
|
|
||||||
|
|
||||||
with patch.object(client.client, "stream", return_value=mock_stream_context):
|
|
||||||
chunks = []
|
|
||||||
async for chunk in client.generate_streaming("llama2", "Hi"):
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
assert "Hello" in chunks
|
|
||||||
assert " world" in chunks
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaClientSingleton:
|
|
||||||
"""Test singleton pattern."""
|
|
||||||
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
def test_get_ollama_client_returns_same_instance(self, mock_settings):
|
|
||||||
"""get_ollama_client should return singleton."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
import src.models.ollama_client as module
|
|
||||||
module._ollama_client = None
|
|
||||||
|
|
||||||
client1 = get_ollama_client()
|
|
||||||
client2 = get_ollama_client()
|
|
||||||
|
|
||||||
assert client1 is client2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@patch("src.models.ollama_client.settings")
|
|
||||||
async def test_close_ollama_client_clears_singleton(self, mock_settings):
|
|
||||||
"""close_ollama_client should clear the singleton."""
|
|
||||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
|
||||||
mock_settings.ollama_timeout = 30
|
|
||||||
|
|
||||||
import src.models.ollama_client as module
|
|
||||||
module._ollama_client = None
|
|
||||||
|
|
||||||
client = get_ollama_client()
|
|
||||||
|
|
||||||
with patch.object(client.client, "aclose", new_callable=AsyncMock):
|
|
||||||
await close_ollama_client()
|
|
||||||
|
|
||||||
assert module._ollama_client is None
|
|
||||||
@@ -4,6 +4,7 @@ from fastapi.testclient import TestClient
|
|||||||
from unittest.mock import patch, AsyncMock, MagicMock
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
from src.domains.tools.controller import tools_controller
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -36,9 +37,21 @@ class TestDNSLookup:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@patch("src.controllers.tools_controller.DNSService")
|
def test_dns_lookup_returns_result(self, client):
|
||||||
def test_dns_lookup_returns_result(self, mock_dns_class, client):
|
"""DNS lookup should return lookup results.
|
||||||
"""DNS lookup should return lookup results."""
|
|
||||||
|
Patches the live singleton's `dns_service` attribute, not the
|
||||||
|
`src.controllers.tools_controller.DNSService` class: that module is
|
||||||
|
the legacy top-level package (not wired into `src.main`, see
|
||||||
|
CLAUDE.md "Legacy top-level packages"). `client` exercises
|
||||||
|
`src.main.app`, which routes through
|
||||||
|
`src.domains.tools.controller.tools_controller`, a singleton built
|
||||||
|
at import time — so patching the class there would also miss,
|
||||||
|
since `tools_controller.dns_service` is already a constructed
|
||||||
|
instance by the time a test patches the class. Patching the
|
||||||
|
instance attribute directly is the only patch that actually
|
||||||
|
intercepts this request path.
|
||||||
|
"""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.success = True
|
mock_response.success = True
|
||||||
mock_response.domain = "example.com"
|
mock_response.domain = "example.com"
|
||||||
@@ -59,8 +72,8 @@ class TestDNSLookup:
|
|||||||
|
|
||||||
mock_service = MagicMock()
|
mock_service = MagicMock()
|
||||||
mock_service.lookup = AsyncMock(return_value=mock_response)
|
mock_service.lookup = AsyncMock(return_value=mock_response)
|
||||||
mock_dns_class.return_value = mock_service
|
|
||||||
|
|
||||||
|
with patch.object(tools_controller, "dns_service", mock_service):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/tools/dns/lookup",
|
"/tools/dns/lookup",
|
||||||
json={"domain": "example.com", "record_type": "A"}
|
json={"domain": "example.com", "record_type": "A"}
|
||||||
|
|||||||
Reference in New Issue
Block a user