Compare commits
3
Commits
3a0fc3db05
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef24e05e2 | ||
|
|
a19d78d284 | ||
|
|
0f77e9a876 |
@@ -0,0 +1,15 @@
|
||||
**Phase 121 final verification pass — all green** (all 4 tasks already in `complete/`; verified, no defects found, no changes needed)
|
||||
|
||||
- Verified implementation vs phase design: migration `0021` (reversible, round-tripped via `alembic downgrade base` + `upgrade head` → head `0021`), `GitSource.token` column, `normalize_credential`/`clone_url_for`/`sanitize_url`, clone callers switched (`sync.py`, `import_docs.py`), masked token fields in add form + editor, `extra="forbid"` output shapes
|
||||
- Tests: `uv run pytest` → 2662 passed, 0 failed (exit 0); `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (≥90% gate)
|
||||
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||
- E2E in isolation: `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → **4 passed**
|
||||
|
||||
Completion criteria:
|
||||
1. Private repo (UI add or pasted embedded-token URL) clones with injected token; token absent from every API response, page text, title attr, and full HTML — **PASS** (integration raw-JSON assertions + E2E `_assert_token_nowhere`)
|
||||
2. Legacy embedded-token rows still clone from stored URL; output sanitized — **PASS** (`test_sync_legacy_row_clones_with_original_stored_url`, `test_get_masks_legacy_embedded_token_row`, env-fallback masking)
|
||||
3. Public/local sources byte-identical — **PASS** (verbatim-URL + no-userinfo-unchanged tests)
|
||||
4. pytest / coverage / ruff / pyright — **PASS** (see above)
|
||||
5. Commit + phase move — harness responsibility; task files already in `complete/`, changes left in working tree (no commit made, per protocol)
|
||||
|
||||
Notable: no deviations; DB left at head, functional. Next pending phase: **122_image_documents** (then 123_chat_image_questions).
|
||||
@@ -0,0 +1,105 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 231 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 129 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 219 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4317 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
Task 01 complete. Report:
|
||||
|
||||
**Implemented**
|
||||
- `GitSource.token` — `Text NULL` column in `app/models.py` with the phase-121/LOCKED-A2 field docstring (NULL = public/legacy row)
|
||||
- `alembic/versions/0021_git_source_token.py` — additive, fully reversible (drop_column downgrade); verified upgrade/downgrade/upgrade on the live dev DB
|
||||
- `app/schemas.py`: `GitSourceIn.token` + `GitSourcePatchIn.token` (max 500, before-mode trim validators, tri-state patch docstring); `GitSourceOut`/`GitSourceRow` gain **no** field — docstring contract + `extra="forbid"` so any `token` on construction raises
|
||||
- Tests: `tests/unit/test_git_source_token.py` (21 tests) + `tests/integration/test_migration_0021.py` (4 tests, house A13 pattern incl. ORM round-trip)
|
||||
|
||||
**Results**
|
||||
- `uv run pytest` — 2602 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90% ✓)
|
||||
- `uv run ruff check . && uv run pyright` — clean (0 errors; the two deliberate `token=` rejection lines carry house-style `# type: ignore[reportCallIssue]`)
|
||||
- `uv run alembic upgrade head` / `downgrade 0020` — apply/reverse cleanly; dev DB left at head (0021)
|
||||
|
||||
**Decisions**
|
||||
- Added the before-mode trim to `GitSourcePatchIn.token` as well (house `_trim_url` precedent; whitespace-only = clear, consistent with the tri-state)
|
||||
- `extra="forbid"` on both output shapes makes "no token ever" structural (ChatMessage precedent), satisfying the task's "construction with a token kwarg raises" pin
|
||||
|
||||
**Next pending task:** `02_clone_url_and_sanitization.md` (normalization on write, `clone_url_for`, `sanitize_url`)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 99%]
|
||||
.......... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 231 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 129 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 219 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4284 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
All gates green. Task 02 complete.
|
||||
|
||||
**Implemented**
|
||||
- `app/rag/git_sources.py`: three pure helpers — `sanitize_url` (anchored-regex userinfo strip, byte-identical/idempotent for clean URLs), `clone_url_for` (NULL token → stored URL verbatim; https? + token → `https://x-access-token:<token>@…`; non-https + token → unchanged + warning), `normalize_credential` (write-path: embedded userinfo → bare URL + token column; explicit token wins, LOCKED A6)
|
||||
- `app/api/git_sources.py`: POST normalizes (embedded `user:pass@` → bare URL + column; duplicate check on bare URL → 409); PATCH token tri-state (absent/None = no change, non-empty = replace, `""` = clear) with legacy-row re-normalization + IntegrityError→generic-409 backstop; `sanitize_url` on all 4 output sites (list DB rows, env rows, POST 201, PATCH 200)
|
||||
- `app/api/sync.py` + `scripts/import_docs.py`: clone call sites now use `clone_url_for(row)`; `repo_name` stays on the bare URL (credential-free checkout paths)
|
||||
|
||||
**Tests / gates**
|
||||
- 32 new unit tests (`tests/unit/test_git_source_token.py`), 13 new integration tests (`tests/integration/test_git_sources_api.py`) — token absent from raw JSON text of every response, legacy rows clone with original stored URL, sync receives injected URL
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL **99%** (>90%; touched modules 100%)
|
||||
- `uv run ruff check .` clean; `uv run pyright` 0 errors; sanity: `tests/e2e/test_git_sources_admin.py` 6/6 in isolation
|
||||
|
||||
**Notable decisions**
|
||||
- The "embedded credential" moved to the column is the *password* part of `user:pass@` (whole run for the no-colon `https://<token>@host` form) — only that authenticates via the injected `x-access-token:<password>@`
|
||||
- A token on a `kind=local` row is stored inert (never cloned, never echoed); env-fallback URLs are masked on output only
|
||||
|
||||
**Next pending task:** `03_ui_token_field.md`
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 231 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 129 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 219 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4317 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,17 @@
|
||||
All checks pass and the diff is clean. Final verification summary:
|
||||
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **2650 passed**, TOTAL coverage **99%** (>90% gate)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**
|
||||
- Affected story E2Es in isolation: `test_source_ignore_paths.py` 6 passed, `test_git_sources_admin.py` 6 passed, `test_hidden_folders_toggle.py` 6 passed
|
||||
|
||||
**Implemented (task 03 — UI masked token field):**
|
||||
- **`frontend/index.html`**: add form gains optional masked `#git-source-token` (`type="password"`, `autocomplete="off"`, "optional — private repos" hint span); the per-row editor dialog mirrors it with `#ignore-editor-token` ("leave blank to keep the current token"); dialog title widened "Ignored files and folders" → "Source settings" to match its new scope
|
||||
- **`frontend/assets/git-sources.js`**: submit body `(url, token) => ({ url, ...(token ? { token } : {}) })` (blank = key omitted); editor token always opens/resets blank, PATCH body includes `token` only when non-blank (tri-state no-change); display sites keep rendering server-sanitized `s.url` with the required one-line note; phase-121 docstring bullet
|
||||
- **`frontend/assets/styles.css`**: `#git-source-token` grouped with the URL input (incl. mobile rule), minimal `.field-hint` (ink-soft, 5.1:1 AA), `.ignore-editor-token` full-width 44px box
|
||||
- **Tests**: 7 new house-style source pins in `tests/unit/test_git_source_token.py` (masking, blank-omission on POST+PATCH, display sites, docstring, CSS); updated `test_source_ignore_paths.py` save-body pin + id guard for the intentional dialog extension
|
||||
|
||||
**Notable decisions:**
|
||||
- The task's "edit modal (L697)" is not a distinct modal — verified against the phase-authoring commit, L697 is the per-row "Ignore paths" editor dialog (the only per-row edit surface); extended that, matching the phase's "edit row (blank token) → token kept" contract and the "Sources page layout untouched" boundary
|
||||
- Task-03 unit pins landed now (task file defers to 04, but they assert this task's changes); the phase E2E file stays task 04's deliverable
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/121_git_source_tokens/04_token_tests.md` (finalize unit/integration + isolated E2E `test_git_source_tokens.py`)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 231 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 129 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 219 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4317 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,15 @@
|
||||
All work is verified and the tree is as it should be (only my new E2E file added; tasks 01–03 changes already in the working tree, untouched by me, no commits).
|
||||
|
||||
**Phase 121 / Task 04 — Token tests: complete**
|
||||
|
||||
Implemented:
|
||||
- `tests/e2e/test_git_source_tokens.py` (new, 4 tests, isolated port 8146, `BOR_GIT_SOURCES=""` + tmp sources/upload dirs pinned): add via UI (bare URL + masked token) → row renders bare URL in cell + `title` attr, token absent from body text / every `title` / page HTML / raw `GET /api/git-sources` JSON, DB = bare URL + token column; old-style `user:token@` paste normalized (A6) and hidden; editor token field `type=password`, opens blank, blank save → single PATCH 200 omitting the `token` key, stored token kept; confirm-modal removal → 204, list empty.
|
||||
- Verified task-01–03 deliverables already in place and green: `tests/unit/test_git_source_token.py` (50 tests — sanitize/clone/normalize/output-model/frontend pins) and the `tests/integration/test_git_sources_api.py` token block (POST/409/PATCH tri-state/legacy mask/sync-clone raw-JSON "token nowhere" on list, 201, PATCH, sync-status surfaces).
|
||||
|
||||
Gates (all green):
|
||||
- `uv run pytest` → 2650 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); `app/api/git_sources.py`, `app/rag/git_sources.py`, `app/schemas.py`, `app/models.py` all 100%
|
||||
- `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → 4 passed
|
||||
- `uv run ruff check . && uv run pyright` → clean, 0 errors
|
||||
|
||||
Decisions: no defects found in prior tasks; only fix was in my new file (Playwright `APIResponse.text` is a method — `r.text()`; body text via `page.evaluate` per house style). Next pending task: none in this phase — all 4 tasks done, phase is complete.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
.......................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 231 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 129 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 219 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4317 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**
|
||||
|
||||
**Verified (implementation already complete in working tree, reviewed end-to-end):**
|
||||
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
|
||||
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
|
||||
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
|
||||
- Prune guard: images-off syncs never prune `is_image` docs
|
||||
|
||||
**Test / lint / coverage (exact commands & outcomes):**
|
||||
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
|
||||
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
|
||||
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)
|
||||
|
||||
**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).
|
||||
|
||||
**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.
|
||||
|
||||
**Next pending phase:** `123_chat_image_questions`.
|
||||
@@ -0,0 +1,106 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 46 0 100%
|
||||
app/schemas.py 373 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4514 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
All green. The untracked `.agents/reports/` dir is a harness runtime artifact — left untouched.
|
||||
|
||||
## Task 01 complete — image toggle (`BOR_IMAGES` + extensions + dir, off by default)
|
||||
|
||||
**Implemented**
|
||||
- `app/config.py`: `images: bool = False`, `image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"`, `image_dir: str = "~/bor-sources/images"` (house docstrings, LOCKED A3 default); `image_extension_set` property (dotted lowercased frozenset, separate from `import_extension_set`); fail-loud validator for empty/malformed `image_extensions` (the `import_extensions` precedent)
|
||||
- `.env.example`: three entries + comment block (off by default, vision-model dependency note)
|
||||
- `app/api/config.py`: `GET /api/config` gains `"images": settings.images` (six-key contract; docstring notes phase-123 composer consumption)
|
||||
- Tests: new `tests/unit/test_image_documents.py` (7 tests: defaults, env overrides, case/trim parse, validators, set separation); `/api/config` pins updated to six keys in `test_api.py` (+ new `images=True` flag-tracking test), `test_save_as_doc_button.py`, `test_ui_settings_api.py`, and 2 E2E suites; conftest leak-pins for the 3 vars (house pattern)
|
||||
|
||||
**Verification**
|
||||
- `uv run pytest` → 2658 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%); touched modules 100%
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
- E2E sanity (isolated): `test_configurable_brand.py` 5 passed, `test_ui_customization.py` 4 passed
|
||||
|
||||
**Decisions**: added the `image_extensions` validator (not explicit in the task, but the design cites the `import_extensions` precedent and the house fail-loud rule); `images` placed after `docs_repo_configured` in the response dict. No live-infra touched.
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/122_image_documents/02_image_ingest.md`
|
||||
@@ -0,0 +1,105 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 97%]
|
||||
.................................................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 129 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 219 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4333 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,17 @@
|
||||
Everything is complete and green. The diff is coherent and matches the task spec exactly.
|
||||
|
||||
## Report — Task 02 (image ingest) complete
|
||||
|
||||
**Implemented**
|
||||
- `app/models.py`: `Document.is_image` (bool, server-default false) + `Document.image_path` (Text NULL), house docstrings
|
||||
- `alembic/versions/0022_documents_image.py`: both columns + tested downgrade (A13); applied to dev DB
|
||||
- `app/rag/importer.py`: walk admits `image_extension_set` only while `settings.images` (separate set, empty default = byte-identical off); `_index_file` image branch BEFORE `read_text` → `_index_image_file` (bytes sha256, persistent copy `image_dir/<doc-id>.<ext>` written only after successful description, stale-copy delete on change, `is_image`/`image_path` set, description = content, normal chunk/`_store_summary` pipeline); single commented seam `_describe_or_skip` for task 03; `_prune` guard (off → `is_image` docs survive; on → pruned + copy deleted); `ImportSummary.images_failed` + slot in the PLAN §9 log line
|
||||
- Tests: unit section in `tests/unit/test_image_documents.py` (walk filter, binary branch incl. `read_text`-never-called, fail-soft skip + log line, unchanged/backfill, stale-copy delete, prune guard); `tests/integration/test_migration_0022.py` (upgrade/downgrade/round-trip, backfill, ORM); `tests/integration/test_docs_api.py` `import_sources` e2e (on/off/prune)
|
||||
|
||||
**Results**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2672 passed, TOTAL 99% (>90% gate)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||
|
||||
**Decisions**: task-02 seam `_describe_or_skip` is a placeholder returning `None` (task 03's `describe_image` fills its body — per the task file's strict reading, since task 03 isn't merged); log-line pin and a walk-stub signature in `tests/unit/test_importer.py` updated for the additive changes. Note: source *removal* (`DELETE /api/git-sources/{id}`) doesn't yet delete image copies — outside this task's list; flag for a later task.
|
||||
|
||||
**Next pending task**: `03_image_description.md`
|
||||
@@ -0,0 +1,106 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
........ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 313 17 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4429 30 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,22 @@
|
||||
Everything is green and coherent. Final state of the task verified against all completion criteria.
|
||||
|
||||
## Report — Task 03 (image description) complete
|
||||
|
||||
**Implemented**
|
||||
- `describe_image` in `app/rag/summarizer.py` — ONE CHAT-model (vision) call (LOCKED A3), multimodal user message (`DESCRIBE_PROMPT` text part + `image_url` data-URL part), reply stripped + capped at `summary_max_chars`, `None` on client error/empty/non-2xx (logs the reason; the importer's warning names the doc)
|
||||
- New constants: `IMAGE_DESCRIPTION_MODE` marker + `DESCRIBE_PROMPT` (E2E-mock key, task 06 wires the branch), `IMAGE_MIMES` map + fallback (task 04 reuses for `Content-Type`)
|
||||
- Importer: `_describe_or_skip` seam filled (real `describe_image`, mime from `IMAGE_MIMES`); `_store_summary` image-aware — for `is_image` docs the description IS the summary (verbatim, no `lite` call/pointer), so `content == summary` and the `is_summary` chunk mirrors it; the phase-118 backfill reuses the same path
|
||||
- Widened `Embedder`/`SummaryLLM`/`FakeEmbedder` chat typings to multimodal (no `LLMClient` change needed); E2E mock `_user`/`_context` made list-safe for multimodal messages (byte-identical for string content)
|
||||
- Tests: 8 new unit + 1 new integration (mock-vision end-to-end, **no seam patch**); fixed task-02's backfill test for task-03 semantics (an image's remaining summary failure class is now the `is_summary` chunk's embed, not a failing `lite` call)
|
||||
|
||||
**Results**
|
||||
- `uv run pytest` → 2681 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (summarizer 100%, importer 95%)
|
||||
- `uv run ruff check . && uv run pyright` → clean
|
||||
- E2E spot-checks (smoke / agent-document-tools / oneshot-retry, in isolation) → all passed
|
||||
|
||||
**Decisions**
|
||||
- `describe_image` follows the existing one-shot `chat()` convention (house phase-96 empty-content retry policy) — no extra retry loop added
|
||||
- Image `doc.summary` carries the description verbatim (no `Source:` pointer line)
|
||||
|
||||
**Next pending task:** `04_serve_and_display.md`
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 99%]
|
||||
................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 46 0 100%
|
||||
app/schemas.py 344 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4454 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,18 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Implemented (task 04 — serve + display):**
|
||||
- `GET /api/documents/{id}/image` (app/api/docs.py): exact bytes via `FileResponse`, `Content-Type` from the shared `IMAGE_MIMES` map, `Cache-Control: private, max-age=3600`; 404 `document not found` for missing/malformed id, text docs, NULL `image_path`, lost file
|
||||
- Content endpoint gains `is_image` (always present) + `image_url` (absent for text docs, never null); tree file nodes gain the same affordance, row-driven (text nodes byte-identical to pre-phase)
|
||||
- Viewer (`document.js`, shared page+modal core): `<img>` block from `image_url` (alt = summary), description stays in the plain-content slot, onerror → "Image unavailable" note; duplicate summary panel suppressed when summary === content
|
||||
- Sources page: fixed 48px lazy thumbnail (object-fit cover) before the path link, document-glyph fallback on fetch failure; CSS in theme tokens
|
||||
|
||||
**Tests / gates (all green):**
|
||||
- `uv run pytest --cov=app` → 2702 passed, TOTAL **99%** (>90%)
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
- New: route battery (6 ext Content-Types, exact bytes), 404 matrix, auth pin, content/tree wire tests (integration) + schema wire + frontend source pins + builder image-node tests (unit); spot-checked E2E `test_smoke`/`test_summary_in_viewer` → pass
|
||||
|
||||
**Notable decisions:**
|
||||
- **Flagged deviation:** task says "PUBLIC" citing anonymous content — stale: phase 79 (owner, 2026-08-22) gated the content endpoint `require_user` (A10: only shared chats anonymous). The image route uses the same `require_user` gate ("like the document content endpoint"); anonymous → 401
|
||||
- Thumbnail lives in the Path cell (the phase-97 file table has no icon column); tree affordance is row-driven so surviving image docs keep thumbnails after toggle-off
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/122_image_documents/05_rag_display.md`
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 347 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 242 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 46 0 100%
|
||||
app/schemas.py 366 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4499 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,18 @@
|
||||
Everything is complete and green. Final report:
|
||||
|
||||
**Task 05 (RAG display) — implemented**
|
||||
- `app/schemas.py` — `SourceRef` gains optional `image_url` (bytes-route path); wrap-serializer drops it when `None` → text-doc frames byte-identical, never `null`
|
||||
- `app/rag/retriever.py` — shared helper `source_ref_with_image(doc)` sets `image_url` iff `doc.is_image`; `app/api/chat.py` cited + related tiers both route through it (lockstep)
|
||||
- `app/rag/agent.py` — `IMAGE_DOC_MARKER` constant; `read` of an image doc gets the marker line between the byte-identical header/date lines and the description (plain + truncation paths); text results byte-identical
|
||||
- `frontend/assets/app.js` — `appendSourceImageFigure` + `fetchContentSummary`, gated on `s.image_url` in both `appendSources` and `appendRelated`: compact inline `<img>` (additive — chip text/affordance kept), alt + visible caption = doc summary (fetched from the existing content endpoint — the frame carries no summary per assumption 5; title until it settles), img error removes the figure (collapses to plain chip, never a broken icon)
|
||||
- `frontend/assets/styles.css` — `.source-image*` rules (96px cap, `object-fit: contain`, `--surface`, AA 8.6:1 caption, flat underline hover)
|
||||
- Tests: 9 new unit tests in `tests/unit/test_image_documents.py` (helper present/absent + nested done-frame pin + pre-phase saved-ref parse, marker pins incl. truncation, house-style frontend contracts) + 2 integration tests in `test_chat_api.py` (image doc's ref alone carries `image_url`; text-only grounded turn → raw wire frame contains no `image_url` bytes, refs keep exactly the pre-122 key set)
|
||||
|
||||
**Gates (exact):**
|
||||
- `uv run pytest --cov=app --cov-report=term` → **2714 passed**, TOTAL **99%** (>90%)
|
||||
- `uv run ruff check .` → **All checks passed**
|
||||
- `uv run pyright` → **0 errors, 0 warnings**
|
||||
|
||||
**Notable decisions:** summary caption fetched client-side (frame carries no summary — `image_url` is the only new frame field); the figure also renders in the related-docs row (same ref shape, same helper); E2E rendering scenario is task 06's scope per the task file. No live-infrastructure changes.
|
||||
|
||||
**Next pending task:** `06_image_tests.md` (phase-122 test suite: unit consolidation + integration extensions + isolated E2E `test_image_documents.py`).
|
||||
@@ -0,0 +1,106 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
.................................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 46 0 100%
|
||||
app/schemas.py 373 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4514 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,20 @@
|
||||
All checks complete. The phase 122 task 06 is done — all gates green.
|
||||
|
||||
## Report
|
||||
|
||||
**Implemented (task 06 — image tests):**
|
||||
- `tests/e2e/test_image_documents.py` (new, isolated): module app with `BOR_IMAGES=true` (leak-guard env) + a second default-env app (`BOR_IMAGES=false`); real admin upload flow (single-PNG zip) → UI "Sync sources" → Sources thumbnail, document-viewer image+description, chat sources inline figure, and the default-off negative case (same upload → no image doc, "0 added · 0 unchanged")
|
||||
- `tests/e2e/mock_llm.py`: `IMAGE_DESCRIPTION_MODE` branch + `IMAGE_DESCRIPTION_ANSWER` (deterministic, token-dense so the question grounds at cosine ≈0.31)
|
||||
- `tests/integration/test_docs_api.py`: added the task's failing-vision integration pin (`images_failed == 1`, no row, text doc indexed, warning names the doc)
|
||||
- **Defect fix (task 04 work):** `frontend/assets/sources.js` `renderLevel` built the `makeRow` row without `is_image`/`image_url`/`summary` — the Sources thumbnail never rendered; now passed through (text nodes stay undefined → byte-identical bare link)
|
||||
|
||||
**Test / lint / coverage:**
|
||||
- `uv run pytest` → **2715 passed** (baseline 2714 + 1)
|
||||
- `uv run pytest --cov=app --cov-report=term` → **TOTAL 99%** (>90% gate)
|
||||
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed** (ran 3× incl. consecutive stability; DB up)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||
- Cross-check: `test_chat_rag.py` + `test_document_viewer.py` still pass (11) — mock change is additive; restored an unrelated screenshot regenerated by the cross-check run
|
||||
|
||||
**Decisions:** chat test uses the phase-119 A1 scripted-read convention (zero-read turns chip nothing — the image doc must be read to earn chip+figure); both E2E apps pin `BOR_IMAGES` explicitly so an operator `.env` can't leak the toggle either direction.
|
||||
|
||||
**Next pending task:** none in `122_image_documents` — phase 122 complete (harness moves task 06 + commits). Next phase: `123_chat_image_submission` (todo).
|
||||
@@ -0,0 +1,106 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 226 1 99%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 247 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 46 0 100%
|
||||
app/schemas.py 373 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4514 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,13 @@
|
||||
All gates green. Verification complete.
|
||||
|
||||
**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**
|
||||
|
||||
- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
|
||||
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
|
||||
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors).
|
||||
|
||||
**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).
|
||||
|
||||
No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
............................................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 248 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 255 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 48 0 100%
|
||||
app/schemas.py 390 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4615 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Phase 123 / Task 01 — Vision request: complete**
|
||||
|
||||
- **Implemented (server side):**
|
||||
- `app/config.py` + `.env.example`: `chat_image_dir` (`~/bor-sources/chat-images`) + `chat_image_max_mb` (10, fail-loud `<=0` validator)
|
||||
- New `app/api/chat_images.py` (registered in `main.py`): `POST /api/chat-images` (user-gated; phase-122 extension frozenset, streamed cap → 413 fixed detail, uuid-named store, returns `{"path": …}`) + public `GET /api/chat-images/{filename}` (regex guard → one fixed 404, phase-122 mime map, `private, max-age=3600`)
|
||||
- `schemas.py`: `ChatRequest.image` (stored-path pattern, fixed 422, no echo) + `ChatMessage.image` (≤500, user record only, omitted-when-None serializer → text-only payloads byte-identical)
|
||||
- `app/api/chat.py`: pre-stream gates (toggle-off → hinted phase-114 frame; stale file → same shape, no hint — both before any model call, no record) + `build_user_content` multimodal build at **both** sites; `run_agent` signature widened (`str | list`), flow pinned in docstring; data-URL factored to shared `summarizer.image_data_url`; `HistoryTurn`/history unchanged (A7)
|
||||
- **Tests:** new `tests/unit/test_chat_image_questions.py` (52 tests) + 7 new integration tests in `test_chat_api.py` (multimodal delivery both branches, toggle-off/stale no-model-call, text-only byte-identity, saved + shared round-trip with no base64 in stored JSONB)
|
||||
- **Results:** `uv run pytest` → green; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (touched modules 99–100%); `uv run ruff check . && uv run pyright` → clean; existing E2E `test_image_documents.py` (4) + `test_chat_rag.py` (3) → pass
|
||||
- **Notable decisions:** upload endpoint is `require_user` (matches the chat turn it feeds; anonymous 10 MB disk-fill DoS); GET public per design (uuid = credential); upload extension set = `settings.image_extension_set` (reuses the phase-122 frozenset, matches the schema's six by default); upload is NOT toggle-gated (the gate is at the chat request, per the pinned design)
|
||||
- **Next pending task:** `123_chat_image_questions/02_composer_attach.md` (config-gated attach control, preview, upload-then-send, in-bubble image)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 248 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 255 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 48 0 100%
|
||||
app/schemas.py 390 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4615 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
Task 02 is complete and fully green. Final report:
|
||||
|
||||
**Implemented (phase 123, task 02 — composer attach):**
|
||||
- `frontend/index.html`: paperclip `#attach-btn` (hidden by default, `aria-label`, composer icon style) + hidden `#attach-file` input before the textarea; hidden-by-default `#attach-preview` strip (≤48px thumbnail + filename + `#attach-remove` X) above the input row
|
||||
- `frontend/assets/app.js`: attach reveal gated on the boot `/api/config` `images` flag (brand boot's single fetch — no extra round-trip); six-extension client pre-check (bad pick → out-of-turn banner, no state change); preview show/remove/replace; send flow (LOCKED A8) uploads first via `POST /api/chat-images` with a double-fire guard — failure → banner + blocked send with the question kept; `runTurn` gains the attachment: user bubble renders via the new shared `attachBubbleImage` helper (data URL live), record gains `image: <path>` (A5: never base64), request body carries top-level `image` only when attached (text-only byte-identical), strip cleared after the bubble renders, redo stays text-only (A7); `startNewChat` resets the attachment
|
||||
- `frontend/assets/styles.css`: `.attach-btn` / `.attach-preview` / `.msg-image` (44px targets, AA pairings, focus-visible, ~240px-capped bubble image)
|
||||
- Updated 6 existing source-pinning tests to the new shapes (contracts preserved: save-point-before-fetch, scroll intents, sticky-unit children, action-row gap, turn-local reset order)
|
||||
|
||||
**Gates:**
|
||||
- `uv run pytest` → exit 0, all pass
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings)
|
||||
- Throwaway Playwright smoke: flag on (attach → preview → remove → send: bubble image, path in body + localStorage record, bytes served back, no banner), text-only body omits `image`, flag off (button hidden; image request → exact hinted error frame, no model output) — all verified live
|
||||
|
||||
**Decisions:** preview strip sits above the input (phase design's "strip above the input"); upload double-fire guard added (the upload is now handleSend's first `await` — a double-click could otherwise double-upload/double-turn); frontend test pins are task 04's per the phase split ("tasks ship code; this task ships the full pin").
|
||||
|
||||
**Next pending task:** `03_restore_and_shared.md` (restore + shared-page image render, onerror degradation).
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 248 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 255 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 48 0 100%
|
||||
app/schemas.py 390 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4615 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
All green and verified. Final report:
|
||||
|
||||
**Task 03 — Restore + shared: complete**
|
||||
|
||||
- `app.js` `renderStoredMessage` user branch: `m.image` (stored path) renders through the same `attachBubbleImage` one-renderer (live = data URL, restore = path); alt = `m.text || "attached image"`
|
||||
- `app.js` `attachBubbleImage`: `onerror` degrades the img in place to a small "image unavailable" line (deleted out-of-band file — never a broken icon)
|
||||
- `shared.js`: `renderSharedMessage` user branch + local `addBubbleImage` copy (per-page duplication house style) — identical treatment, alt, and onerror; public image route makes the shared view faithful
|
||||
- `styles.css`: shared page reuses task 02's `.msg-image` as-is (same stylesheet + bubble class, verified); one new rule only for the new "image unavailable" note element
|
||||
- Re-ask (`retryLastTurn`): byte-unchanged per the phase-120 lock — A7 (re-ask sends `prev.text` only, no attachment) is pinned by that byte-pin itself
|
||||
- Restore filters (localStorage / saved-chat / shared) pass records through untouched — optional `image` key needs no migration
|
||||
- Server round-trip verified live: user record with `image` survives POST/PUT `/api/chats` and serves through `GET /api/shared/{token}` (public shape)
|
||||
|
||||
**Gates:** `uv run pytest` → exit 0, all pass · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) · `uv run ruff check .` → clean · `uv run pyright` → 0 errors, 0 warnings
|
||||
|
||||
**Deviation note:** an A7 pin-comment in `retryLastTurn` initially broke the phase-120 whole-function byte-pin; removed the comment and kept the function byte-identical (the pin itself proves the no-attachment re-ask).
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/123_chat_image_questions/04_chat_image_tests.md`
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 248 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 255 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 48 0 100%
|
||||
app/schemas.py 390 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4615 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Phase 123 task 04 — complete.**
|
||||
|
||||
**Implemented (test suite for question-images):**
|
||||
- `tests/unit/test_chat_image_questions.py` (extended): config env overrides, data-URL-helper import-not-copy identity pin, all-six-extension upload acceptance, +12 frontend source pins (hidden-by-default markup, config-gated reveal, A8 upload-before-send ordering/block-on-failure, user record carries path never base64, A7 no-history-replay + text-only redo, one bubble-image renderer + onerror degradation on both pages, single `.msg-image` CSS rule)
|
||||
- `tests/integration/test_chats_api.py` (extended): saved + shared round-trips of the user image path, no base64 in raw JSONB, brain/text-only records stay key-free (task 04's `test_chat_api.py` cases were already shipped by task 01 — verified green)
|
||||
- `tests/e2e/test_chat_image_questions.py` (new, isolated): preview+remove, send→exactly-one upload + live data-URL bubble + mock capture asserts the exact multimodal request (text + decodable data URL), reload restores from stored path (path-fetch counted), fresh anonymous context sees the image on the shared page, flag-off app: control hidden + exact hinted error frame + zero model calls; `tests/e2e/mock_llm.py` gained an additive observational capture (`/v1/e2e/captured` + reset)
|
||||
- **Defect fix (task 02 work):** task-02's data-URL images (preview + live bubble) were blocked by the phase-82 CSP (`default-src 'self'`, no img-src) — the new E2E caught it (bubble degraded to "image unavailable"). Minimal fix: `img-src 'self' data:` appended to the CSP constant (scripts/styles/fetches stay strict; bytes are the user's own local file). Updated the three security-header test pins. **Flagged, not silent: this extends the A20-derived policy string per the owner-confirmed phase-123 data-URL rendering contract.**
|
||||
|
||||
**Gates:** `uv run pytest` → 2796 passed · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) · `uv run pytest tests/e2e/test_chat_image_questions.py --no-cov` → 5 passed in isolation · `uv run ruff check .` clean · `uv run pyright` → 0 errors. CSP-impact checks: e2e security-headers (2) + theme-semantic (8) pass. No live-infra changes.
|
||||
|
||||
**Notable:** E2E runs against an unseeded KB (deterministic deflection); the sync-API request-event pitfall (polls must tick via Playwright calls, not `time.sleep`) is pinned with a comment.
|
||||
|
||||
**Next pending task:** none — task 04 is the last of phase 123; the phase dir is ready for the pipeline gate.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
............................................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 248 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 179 1 99%
|
||||
app/api/git_sources.py 241 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 255 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 131 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 349 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 38 0 100%
|
||||
app/rag/importer.py 316 16 95%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 102 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 48 0 100%
|
||||
app/schemas.py 390 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4615 29 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -112,6 +112,29 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
|
||||
# BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total
|
||||
# extracted bytes (zip-bomb guard); must be > 0
|
||||
|
||||
# --- Image documents (phase 122) ---
|
||||
# Master switch for indexing standalone image files (direct uploads,
|
||||
# uploaded archives, git/local source walks) as first-class documents.
|
||||
# OFF by default — enable only when BOR_LLM_CHAT_MODEL supports vision:
|
||||
# the chat model generates the image's description, and that
|
||||
# description is the ONLY part of the image that gets indexed (the
|
||||
# embedding model never sees pixels). While off, image files are
|
||||
# ignored by the import walks and a sync never prunes existing image
|
||||
# documents.
|
||||
# BOR_IMAGES=0 # 1 = index standalone images
|
||||
# BOR_IMAGE_EXTENSIONS=png,jpg,jpeg,webp,gif,bmp # comma-separated, case-insensitive
|
||||
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
|
||||
# (uploads are replaced, checkouts re-cloned)
|
||||
|
||||
# --- Chat image questions (phase 123: attach an image to a question) ---
|
||||
# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat
|
||||
# model supports vision). The question image is stored on the server —
|
||||
# never base64 in saved/shared chats; the record carries the path.
|
||||
# One image per question; prior turns' images are not replayed to the
|
||||
# model (the question's image applies to the current turn only).
|
||||
# BOR_CHAT_IMAGE_DIR=~/bor-sources/chat-images # where question images are stored
|
||||
# BOR_CHAT_IMAGE_MAX_MB=10 # upload cap, MiB (must be > 0)
|
||||
|
||||
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||
# The git repo chat answers can be committed to — any remote (URL or
|
||||
# local path). While empty, the "Save as doc" action is hidden and the
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""git_sources.token: private-repo credential column (phase 121)
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
Create Date: 2026-09-24
|
||||
|
||||
Phase 121 (private git sources: a token that never reaches the UI or
|
||||
the API — task 01, storage only):
|
||||
|
||||
* ``git_sources.token`` — TEXT NULLABLE, no server default: the private
|
||||
repo credential (LOCKED A2) the owner types into the masked
|
||||
Sources-page field. NULL = public repo (or a legacy row whose
|
||||
credential is still embedded in ``url`` — those rows keep their
|
||||
stored value, which is what authenticates the clone, and are
|
||||
sanitized on OUTPUT only, task 02). Stored plaintext BY NECESSITY:
|
||||
the repo must remain cloneable, so the raw credential must be
|
||||
recoverable at sync time; the Postgres DB is the trusted store and is
|
||||
never served to the UI. The column is injected into the clone URL
|
||||
ONLY at clone time (task 02's ``clone_url_for``) and is NEVER
|
||||
returned by any API shape (the output models gain no token field —
|
||||
the omission is a documented contract).
|
||||
|
||||
One additive, fully reversible migration (A13); no other schema
|
||||
change. Normalization of embedded-token URLs on write and output
|
||||
sanitization are code (tasks 02/03) — this revision only carries the
|
||||
column.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0021"
|
||||
down_revision = "0020"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("git_sources", sa.Column("token", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The token column is the only 0021 artefact — dropping it leaves
|
||||
# 0020's schema byte-identical (A13, fully reversible).
|
||||
op.drop_column("git_sources", "token")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""documents image columns: is_image + image_path (phase 122, task 02)
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
Create Date: 2026-09-24
|
||||
|
||||
Phase 122 (standalone images become first-class documents — task 02,
|
||||
storage only):
|
||||
|
||||
* ``documents.is_image`` — BOOLEAN NOT NULL, server default
|
||||
``false``: True iff the doc is a standalone image (LOCKED A3) whose
|
||||
``content``/``summary`` is the CHAT model's vision description — the
|
||||
ONLY embedded text (the embedding model never sees pixels). The
|
||||
server default makes EVERY pre-phase-122 row a text doc without a
|
||||
backfill.
|
||||
* ``documents.image_path`` — TEXT NULLABLE: the absolute path of the
|
||||
image's persistent copy in ``settings.image_dir`` (``<doc-id>.<ext>``
|
||||
— the copy must outlive the source file: uploads are replaced on
|
||||
every upload, git checkouts are re-cloned). NULL for text docs.
|
||||
|
||||
One additive, fully reversible migration (A13); no other schema
|
||||
change. The walk filter, the binary index path, and the prune guard
|
||||
are importer code (task 02) — this revision only carries the columns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0022"
|
||||
down_revision = "0021"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"documents",
|
||||
sa.Column(
|
||||
"is_image",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column("documents", sa.Column("image_path", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Both columns are the only 0022 artefacts — dropping them leaves
|
||||
# 0021's schema byte-identical (A13, fully reversible).
|
||||
op.drop_column("documents", "image_path")
|
||||
op.drop_column("documents", "is_image")
|
||||
+136
-7
@@ -179,6 +179,27 @@ as ``reasoning_content`` on the assistant message (the preserve-
|
||||
thinking wire convention, A4). The per-turn log line records
|
||||
``history_msgs=N`` after ``kb_chars=N`` (0 when the request carries no
|
||||
history — the two-message request stays byte-identical).
|
||||
|
||||
Question images (phase 123, TODO L6; LOCKED A5/A7): the request may
|
||||
attach ONE image to the CURRENT question — ``ChatRequest.image`` is the
|
||||
STORED path from ``POST /api/chat-images`` (``app.api.chat_images``),
|
||||
never a data URL. The turn validates it BEFORE any model call (the
|
||||
toggle gate — ``settings.images`` false settles the phase-114 hinted
|
||||
error frame; the stale-file gate — the stored bytes deleted out-of-
|
||||
band settles the same frame shape) and, when valid, the user message's
|
||||
content becomes the multimodal list ``[{type: "text", …}, {type:
|
||||
"image_url", image_url: {url: <data URL>}}]`` — built by
|
||||
:func:`build_user_content` at BOTH construction sites (this module's
|
||||
deflected-branch ``messages`` list and the grounded branch's
|
||||
``run_agent`` call, which builds its own ``[system, *history, user]``
|
||||
— the flow is pinned in ``app.rag.agent.run_agent``). The data URL is
|
||||
the shared :func:`app.rag.summarizer.image_data_url` (one
|
||||
construction, both call sites — the phase-122 describe path).
|
||||
``image=None`` keeps the plain-string content byte-identical to
|
||||
pre-phase. A question image is turn-local: it is NEVER indexed as a
|
||||
document, and prior turns' images are never replayed into the model's
|
||||
history (``history_to_messages`` is unchanged — the text of a prior
|
||||
turn that had an image stands alone, LOCKED A7).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -188,6 +209,7 @@ import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -224,10 +246,16 @@ from app.rag.retriever import (
|
||||
retrieve,
|
||||
select_related,
|
||||
select_suggested,
|
||||
source_ref_with_image,
|
||||
weak_hit_titles,
|
||||
)
|
||||
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
|
||||
from app.rag.suggestions import derive_suggestions
|
||||
from app.rag.summarizer import (
|
||||
IMAGE_FALLBACK_MIME,
|
||||
IMAGE_MIMES,
|
||||
image_data_url,
|
||||
) # phase 123: the phase-122 mime map + the shared data-URL helper
|
||||
from app.schemas import (
|
||||
ChatDoneEvent,
|
||||
ChatErrorEvent,
|
||||
@@ -280,6 +308,37 @@ def sse_event(payload: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def build_user_content(
|
||||
message: str,
|
||||
image_path: str | None,
|
||||
settings: Settings,
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""The current turn's user message content (phase 123, task 01).
|
||||
|
||||
*image_path* ``None`` (every text-only question) → the plain
|
||||
question string — byte-identical to pre-phase-123 (the multimodal
|
||||
branch is inert). Set → the OpenAI-compatible multimodal content
|
||||
list: the text part + the image part, a data URL built server-side
|
||||
from the stored bytes (``settings.chat_image_dir`` + the path's
|
||||
filename) and the phase-122 ``IMAGE_MIMES`` map (one map, one
|
||||
truth) through the shared :func:`app.rag.summarizer.image_data_url`
|
||||
helper (one construction, both call sites — the phase-122 describe
|
||||
path). The caller has already validated the path shape (the
|
||||
``ChatRequest.image`` schema guard) and the file's existence (the
|
||||
pre-stream turn gate). The image applies to the CURRENT turn only
|
||||
(LOCKED A7) — prior turns' images are never replayed.
|
||||
"""
|
||||
if image_path is None:
|
||||
return message
|
||||
filename = image_path.rsplit("/", 1)[-1]
|
||||
data = (Path(settings.chat_image_dir).expanduser() / filename).read_bytes()
|
||||
mime = IMAGE_MIMES.get(Path(filename).suffix.lower(), IMAGE_FALLBACK_MIME)
|
||||
return [
|
||||
{"type": "text", "text": message},
|
||||
{"type": "image_url", "image_url": {"url": image_data_url(data, mime)}},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnPlan:
|
||||
"""What one chat turn sends to the LLM and reports on ``done``."""
|
||||
@@ -493,6 +552,59 @@ async def chat(
|
||||
retries_used = 0 # phase 67: LLM requests restarted this turn (log line)
|
||||
try:
|
||||
settings = get_settings()
|
||||
# Phase 123 (TODO L6, task 01): the question's attached
|
||||
# image — validated BEFORE any model call (the embed is
|
||||
# a model call): a rejected turn calls nothing and
|
||||
# settles with the phase-114 error frame (the existing
|
||||
# error-path convention — no ``done``, no ``query_log``
|
||||
# row, no persisted record; the question is not saved).
|
||||
# Two gates, in order:
|
||||
# 1. the ``images`` toggle (``BOR_IMAGES``) — off with
|
||||
# an image set: the HINTED frame (the client's
|
||||
# banner shows the hint in place of its default
|
||||
# reachability copy, phase 114);
|
||||
# 2. the stored file — the schema already pinned the
|
||||
# path shape, but the file may have been deleted
|
||||
# out-of-band (the stale-path edge): the same frame
|
||||
# shape, no hint (the banner's default copy is the
|
||||
# honest fallback — there is nothing to point at).
|
||||
if request.image is not None:
|
||||
if not settings.images:
|
||||
logger.warning(
|
||||
"chat: image question rejected (images toggle "
|
||||
"off) question=%r image=%r",
|
||||
request.message,
|
||||
request.image,
|
||||
)
|
||||
settled = True # terminal: the error frame settles the turn
|
||||
yield sse_event(
|
||||
ChatErrorEvent(
|
||||
detail="Image support is turned off on this server.",
|
||||
hint=(
|
||||
"Enable BOR_IMAGES in the server's .env "
|
||||
"(and restart) to ask with an image."
|
||||
),
|
||||
).model_dump()
|
||||
)
|
||||
return
|
||||
image_file = (
|
||||
Path(settings.chat_image_dir).expanduser()
|
||||
/ request.image.rsplit("/", 1)[-1]
|
||||
)
|
||||
if not image_file.is_file():
|
||||
logger.warning(
|
||||
"chat: image question rejected (stored file "
|
||||
"missing) question=%r image=%r",
|
||||
request.message,
|
||||
request.image,
|
||||
)
|
||||
settled = True # terminal: the error frame settles the turn
|
||||
yield sse_event(
|
||||
ChatErrorEvent(
|
||||
detail="That image is no longer available."
|
||||
).model_dump()
|
||||
)
|
||||
return
|
||||
# Phase 74 (TODO L4): the client's prior turns, mapped ONCE
|
||||
# per turn — trimmed newest-first against the settings
|
||||
# budgets, assistant turns carrying their prior thinking as
|
||||
@@ -639,10 +751,21 @@ async def chat(
|
||||
).model_dump()
|
||||
)
|
||||
return
|
||||
# Phase 123 (task 01): the user message's content — the
|
||||
# plain question string (``image=None`` — byte-identical
|
||||
# to pre-phase) or the multimodal content list (text
|
||||
# part + image_url data URL; the image was validated
|
||||
# above). BOTH construction sites use the same build:
|
||||
# this deflected-branch list and the grounded branch's
|
||||
# ``run_agent`` call below (run_agent builds its own
|
||||
# ``[system, *history, user]`` — pinned there).
|
||||
user_content: str | list[dict[str, Any]] = build_user_content(
|
||||
request.message, request.image, settings
|
||||
)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": plan.system_prompt},
|
||||
*hist, # phase 74: the trimmed prior turns (empty by default)
|
||||
{"role": "user", "content": request.message},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
# 3. Stream the answer (grounded, or an honest deflection).
|
||||
@@ -688,7 +811,11 @@ async def chat(
|
||||
llm,
|
||||
db_factory, # SEC-14-04: session factory, not a long-lived session
|
||||
system_prompt=plan.system_prompt,
|
||||
user_message=request.message,
|
||||
# Phase 123 (task 01): the plain question string
|
||||
# (image=None) or the multimodal content list
|
||||
# (validated above) — run_agent builds its own
|
||||
# user message from this value (see its docstring).
|
||||
user_message=user_content,
|
||||
seed_docs=plan.suggested_docs, # phase 118 (A4): the suggestion tier
|
||||
settings=settings,
|
||||
holder=holder,
|
||||
@@ -984,14 +1111,16 @@ async def chat(
|
||||
# since phase 119). The UI renders the row as the
|
||||
# de-emphasized related-docs row, never a citation chip;
|
||||
# old clients ignore the field.
|
||||
# Phase 122 (task 05): both tiers build their refs
|
||||
# through the shared helper — a ref for an image doc
|
||||
# carries the optional ``image_url`` (the bytes route),
|
||||
# a text doc's stays byte-identical to pre-phase (the
|
||||
# key is omitted, never null).
|
||||
cited_refs: list[SourceRef] = []
|
||||
if not plan.deflected:
|
||||
cited_refs = [
|
||||
SourceRef(source=d.source, path=d.path, title=d.title)
|
||||
for d in cited_docs
|
||||
]
|
||||
cited_refs = [source_ref_with_image(d) for d in cited_docs]
|
||||
related_refs = [
|
||||
SourceRef(source=d.source, path=d.path, title=d.title)
|
||||
source_ref_with_image(d)
|
||||
for d in plan.related_docs
|
||||
if (d.source, d.path) not in cited_seen
|
||||
]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Question-image upload/serve pair (phase 123, TODO L6 — attach an
|
||||
image to a question).
|
||||
|
||||
A user attaches ONE image to a chat question (LOCKED A5): the bytes are
|
||||
stored server-side under ``chat_image_dir/<uuid4().hex>.<ext>`` — NOT
|
||||
base64 in saved/shared chats. ``POST /api/chat-images`` answers
|
||||
``{"path": "/api/chat-images/<uuid>.<ext>"}`` and that path is what
|
||||
``ChatRequest.image`` / ``ChatMessage.image`` carry (a stored PATH,
|
||||
never a data URL — the upload endpoint owns the size/mime
|
||||
enforcement). ``GET /api/chat-images/{filename}`` serves the bytes back
|
||||
so the user bubble, the refreshed page, and the shared chat can render
|
||||
the attachment. A question image is NEVER indexed as a document (no
|
||||
importer call) — it is turn-local storage, not a source.
|
||||
|
||||
Auth posture: the upload is user-gated exactly like the chat turn it
|
||||
feeds (``require_user`` — the question itself is user-gated, and an
|
||||
anonymous 10 MiB disk-fill would be a DoS); the serve route is PUBLIC
|
||||
like saved-chat content (phase 55 A1 — a saved chat's id is already its
|
||||
credential, and the image is part of that content; the filename is an
|
||||
unguessable ``uuid4().hex`` — no enumeration value).
|
||||
|
||||
The extension is the source of truth (the Content-Type header is a
|
||||
hint — the archive-uploader precedent): it must be in the phase-122
|
||||
image set (``settings.image_extension_set`` — the same frozenset the
|
||||
image-document walk uses), lowercased; total bytes are streamed with a
|
||||
``chat_image_max_mb`` cap (413, fixed detail naming the cap — never
|
||||
echoing the filename).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import require_user
|
||||
from app.rag.summarizer import IMAGE_FALLBACK_MIME, IMAGE_MIMES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["chat-images"])
|
||||
|
||||
#: Stream receive chunk (the git-sources upload's 1 MiB pattern).
|
||||
_STREAM_CHUNK = 1 << 20
|
||||
|
||||
#: Stored question-image filename guard: ``<uuid4().hex>.<ext>`` — 32
|
||||
#: hex chars + one of the six image extensions (the ``ChatRequest.image``
|
||||
#: path pattern's filename part, kept in lockstep with it). Anything
|
||||
#: else 404s — no path traversal by construction (the route parameter
|
||||
#: cannot carry a ``/`` and the regex rejects everything but the
|
||||
#: upload endpoint's own naming).
|
||||
_CHAT_IMAGE_FILENAME_RE = re.compile(r"^[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$")
|
||||
|
||||
|
||||
@router.post("/chat-images")
|
||||
async def upload_chat_image(
|
||||
file: UploadFile = File(...), # noqa: B008
|
||||
_user: None = Depends(require_user), # noqa: B008
|
||||
) -> dict[str, str]:
|
||||
"""Store one question image (phase 123, task 01; LOCKED A5).
|
||||
|
||||
Gates, in order:
|
||||
|
||||
1. **extension** — the file name's extension (lowercased) must be in
|
||||
the phase-122 image set (``settings.image_extension_set``); the
|
||||
Content-Type header is a hint, never a source of truth (the
|
||||
archive-uploader precedent). A missing/unknown extension is a
|
||||
422 naming the accepted set — a fixed detail, no filename echo.
|
||||
2. **size** — the bytes are streamed (1 MiB chunks) with the
|
||||
``chat_image_max_mb`` cap; over-cap is a 413 naming the cap
|
||||
(fixed detail — never echoing the filename), the temp file is
|
||||
removed, and nothing is stored.
|
||||
|
||||
The file lands as ``<uuid4().hex>.<ext>`` in ``chat_image_dir``
|
||||
(created on demand; a dotfile temp is renamed into place, so a
|
||||
failed/partial receive never leaves a servable-looking file). The
|
||||
response is the served path — ``{"path":
|
||||
"/api/chat-images/<uuid>.<ext>"}`` — the value ``ChatRequest.image``
|
||||
accepts (never a data URL, never the on-disk location).
|
||||
"""
|
||||
settings = get_settings()
|
||||
filename = file.filename or ""
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in settings.image_extension_set:
|
||||
accepted = ", ".join(sorted(settings.image_extension_set))
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"only {accepted} images are accepted"
|
||||
)
|
||||
|
||||
root = Path(settings.chat_image_dir).expanduser()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
temp = root / f".{name}.upload"
|
||||
max_bytes = settings.chat_image_max_mb * 1024 * 1024
|
||||
total = 0
|
||||
try:
|
||||
# Stream with the cap — the dotfile temp is hidden from any
|
||||
# listing of the store dir (the git-sources upload pattern).
|
||||
with open(temp, "wb") as out:
|
||||
while chunk := await file.read(_STREAM_CHUNK):
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"the image exceeds the "
|
||||
f"{settings.chat_image_max_mb} MB limit"
|
||||
),
|
||||
)
|
||||
out.write(chunk)
|
||||
temp.rename(root / name)
|
||||
except BaseException:
|
||||
# A failed receive (413, broken pipe, cancellation) leaves no
|
||||
# file behind — the final name was never created.
|
||||
temp.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info(
|
||||
"chat-image: uploaded name=%s bytes=%d", name, total
|
||||
)
|
||||
return {"path": f"/api/chat-images/{name}"}
|
||||
|
||||
|
||||
@router.get("/chat-images/{filename}", response_class=FileResponse)
|
||||
def serve_chat_image(filename: str) -> FileResponse:
|
||||
"""Serve one stored question image (phase 123, task 01).
|
||||
|
||||
PUBLIC (no auth dependency) — like saved-chat content: the saved
|
||||
chat's id is already its credential (phase 55 A1), the image is part
|
||||
of that content, and the filename is an unguessable ``uuid4().hex``
|
||||
(no enumeration value).
|
||||
|
||||
Every non-servable case is a 404 with the same fixed detail — a
|
||||
filename that does not match ``<uuid-hex>.<ext>`` (the regex guard:
|
||||
no path traversal by construction, no 422 that would hint at
|
||||
accepted shapes) and a matching name whose file is missing (the
|
||||
stale-path edge — the file was deleted out-of-band). Servable files
|
||||
stream the exact bytes with the phase-122 ``IMAGE_MIMES``
|
||||
``Content-Type`` (one map, one truth with the describe call's
|
||||
data-URL mime; the six-extension guard makes the fallback
|
||||
unreachable) and ``Cache-Control: private, max-age=3600`` (the
|
||||
phase-122 serve-route convention — the bytes are content-hashed
|
||||
uuids, bustable by re-upload).
|
||||
"""
|
||||
if _CHAT_IMAGE_FILENAME_RE.fullmatch(filename) is None:
|
||||
raise HTTPException(status_code=404, detail="chat image not found")
|
||||
path = Path(get_settings().chat_image_dir).expanduser() / filename
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="chat image not found")
|
||||
media_type = IMAGE_MIMES.get(path.suffix.lower(), IMAGE_FALLBACK_MIME)
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "private, max-age=3600"},
|
||||
)
|
||||
+14
-8
@@ -1,6 +1,7 @@
|
||||
"""Public app metadata (display name + version) for the frontend brand
|
||||
layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
|
||||
phase-62 UI customization strings (composer placeholder, footer line).
|
||||
layer, the phase-59 docs-push flag (the "Save as doc" gating), the
|
||||
phase-122 image flag (UI affordance gating), and the phase-62 UI
|
||||
customization strings (composer placeholder, footer line).
|
||||
|
||||
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
|
||||
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
|
||||
@@ -29,17 +30,21 @@ router = APIRouter(tags=["config"])
|
||||
@router.get("/config")
|
||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
||||
"""Public app metadata for the frontend brand layer (phase 39) +
|
||||
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
|
||||
customization keys (``input_placeholder``, ``footer_text``) — all
|
||||
display strings, the SAME boot fetch (no new network surface) and
|
||||
the same public posture as ``app_name`` (no secrets). Phase 91:
|
||||
the phase-59 ``docs_repo_configured`` flag + the phase-122
|
||||
``images`` flag + the phase-62 UI customization keys
|
||||
(``input_placeholder``, ``footer_text``) — all display strings,
|
||||
the SAME boot fetch (no new network surface) and the same public
|
||||
posture as ``app_name`` (no secrets). Phase 91:
|
||||
``app_name`` / ``input_placeholder`` / ``footer_text`` are the
|
||||
EFFECTIVE values (the admin Theme tab's ``ui_settings`` row over
|
||||
the env values — DB-over-env, B1); the frontend brand layer treats
|
||||
an empty string as "keep the template default" (the unset =>
|
||||
byte-identical contract). Phase 91 (task 03): the retired
|
||||
CSS-file theming's ``theme`` key is gone — the five keys are the
|
||||
entire response."""
|
||||
CSS-file theming's ``theme`` key is gone. Phase 122 (task 01):
|
||||
``images`` mirrors ``settings.images`` (the ``BOR_IMAGES`` master
|
||||
switch) — consumed by the chat composer (phase 123) to show/hide
|
||||
the image-attach control, optionally by the Sources page (an
|
||||
"images off" hint). The six keys are the entire response."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
effective = theming.effective_settings(db, settings)
|
||||
@@ -49,6 +54,7 @@ def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bo
|
||||
"app_name": effective["app_name"],
|
||||
"version": settings.app_version,
|
||||
"docs_repo_configured": settings.docs_configured,
|
||||
"images": settings.images,
|
||||
"input_placeholder": effective["input_placeholder"],
|
||||
"footer_text": effective["footer_text"],
|
||||
}
|
||||
|
||||
+150
-9
@@ -23,6 +23,18 @@ GET /api/docs/tree — the admin's full recursive KB tree in one fetch
|
||||
walks, with the file metadata the RAG view's rows and stat cards need
|
||||
(the view drills client-side; ``GET /api/docs`` is untouched).
|
||||
|
||||
GET /api/documents/{doc_id}/image — the phase-122 (task 04) image
|
||||
BYTES route: the persistent copy behind an ``is_image`` document's
|
||||
``image_path``, served with the extension's ``Content-Type`` (the
|
||||
``app.rag.summarizer.IMAGE_MIMES`` map — one map, one truth) and a
|
||||
``Cache-Control: private, max-age=3600`` header (the bytes are
|
||||
content-hashed — long enough, bustable by re-upload). User-gated like
|
||||
the content endpoint (phase 79 — the ONLY anonymous surface is the
|
||||
shared chats): a missing doc, a non-image doc, a doc whose
|
||||
``image_path`` is NULL, or a row whose copy was lost all map to 404
|
||||
``document not found`` (the router's unknown-document shape —
|
||||
traversal/UUID-guessing has no filesystem surface to hit).
|
||||
|
||||
PATCH /api/folders/summary — the admin folder-description editor
|
||||
(phase 97, task 03): update / create / clear a stored
|
||||
``folder_summaries`` row, marking every non-empty save
|
||||
@@ -34,11 +46,13 @@ contrast with the phase-57 ``is_summary`` re-embed above.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -52,6 +66,7 @@ from app.rag.doc_dates import normalize_doc_date
|
||||
from app.rag.folder_summaries import MIN_DOCS_PER_FOLDER, folder_of
|
||||
from app.rag.importer import match_extension
|
||||
from app.rag.llm import EmbeddingError, LLMClient
|
||||
from app.rag.summarizer import IMAGE_FALLBACK_MIME, IMAGE_MIMES
|
||||
from app.schemas import (
|
||||
DateResult,
|
||||
DateUpdate,
|
||||
@@ -171,6 +186,11 @@ def get_document_content(
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
doc, chunks = row
|
||||
# Phase 122 (task 04): the image affordance — ``is_image`` is ALWAYS
|
||||
# present on the wire (text docs: false — the one new key); for an
|
||||
# image doc, ``image_url`` is the bytes route's path (absent for
|
||||
# text docs, and for an image row whose copy path is NULL — never
|
||||
# null, the ``DocContent`` omission rule).
|
||||
return DocContent(
|
||||
source=doc.source,
|
||||
path=doc.path,
|
||||
@@ -181,6 +201,62 @@ def get_document_content(
|
||||
content=doc.content,
|
||||
indexed_at=doc.indexed_at.isoformat(),
|
||||
chunks=chunks,
|
||||
is_image=doc.is_image,
|
||||
image_url=(
|
||||
f"/api/documents/{doc.id}/image" if doc.is_image and doc.image_path else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/{doc_id}/image", response_class=FileResponse)
|
||||
def get_document_image(
|
||||
doc_id: str,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
_user: None = Depends(require_user), # noqa: B008 # phase 79 posture (see docstring)
|
||||
) -> FileResponse:
|
||||
"""The phase-122 (task 04) image BYTES route — the persistent copy
|
||||
behind an ``is_image`` document's ``image_path``.
|
||||
|
||||
Same auth posture as the document content endpoint (phase 79 —
|
||||
``require_user``: admin OR live token holder; the ONLY anonymous
|
||||
surface is the shared chats — the image is part of a document's
|
||||
content, so it travels under the same gate): anonymous callers get
|
||||
401 ``authentication required`` before any row is read.
|
||||
|
||||
404 ``document not found`` (the router's unknown-document shape) in
|
||||
every non-servable case — a missing id (an unparseable string maps
|
||||
here too, not to a 422 — a guessed id is an unknown document), a
|
||||
text doc, an image doc whose ``image_path`` is NULL, or a row whose
|
||||
copy was lost on disk (defensive — the row exists, the bytes
|
||||
don't). There is no path parameter to a filesystem value: the path
|
||||
comes from the ROW (the importer's ``image_dir`` copy), so there is
|
||||
no traversal surface.
|
||||
|
||||
Servable rows stream the exact bytes with the extension's
|
||||
``Content-Type`` (the ``IMAGE_MIMES`` map — one map, one truth with
|
||||
the describe call's data-URL mime; an unexpected extension takes
|
||||
``application/octet-stream``) and ``Cache-Control: private,
|
||||
max-age=3600`` (the bytes are content-hashed — long enough to be
|
||||
useful, bustable by re-upload).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="document not found") from None
|
||||
doc = db.scalar(select(Document).where(Document.id == uid))
|
||||
if doc is None or not doc.is_image or not doc.image_path:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
image_file = Path(doc.image_path)
|
||||
if not image_file.is_file():
|
||||
# Defensive: the row exists but the copy was lost (the owner
|
||||
# cleaned the image dir, the disk was wiped) — the viewer's
|
||||
# onerror fallback renders the "image unavailable" note.
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
media_type = IMAGE_MIMES.get(image_file.suffix.lower(), IMAGE_FALLBACK_MIME)
|
||||
return FileResponse(
|
||||
image_file,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "private, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
@@ -446,6 +522,13 @@ def _folder_counts(
|
||||
return folders, counts
|
||||
|
||||
|
||||
#: One image-docs map value (phase 122, task 04):
|
||||
#: ``(doc_id, summary)`` — the id the bytes route's URL is built from
|
||||
#: and the summary the RAG view's thumbnail uses as ``alt`` (the vision
|
||||
#: description; ``None`` = the fail-soft backfill corner).
|
||||
ImageDocInfo = tuple[str, str]
|
||||
|
||||
|
||||
def _level_children(
|
||||
source: str,
|
||||
folder: str,
|
||||
@@ -453,6 +536,7 @@ def _level_children(
|
||||
counts: dict[str, int],
|
||||
rows: Sequence[TreeFileRow],
|
||||
summaries: Mapping[tuple[str, str], str],
|
||||
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
|
||||
) -> list[KbTreeFolder | KbTreeFile]:
|
||||
"""One level's children (pure): subfolders in path order, then the
|
||||
direct files in input (catalog) order.
|
||||
@@ -477,10 +561,19 @@ def _level_children(
|
||||
= the subtree's MAX document ``created_at``: the max over this
|
||||
folder's direct files' dates and its subfolder children's (already
|
||||
recursive) ``updated_at`` values, via :func:`_subtree_max`.
|
||||
|
||||
Since phase 122 (task 04), a file node whose ``(source, path)`` is
|
||||
in *images* carries the thumbnail affordance (``is_image`` +
|
||||
``image_url`` built from the mapped doc id + the mapped ``summary``
|
||||
— see :class:`app.schemas.KbTreeFile`); every other file node is
|
||||
the pre-phase shape (the omission rule keeps its wire shape
|
||||
byte-identical).
|
||||
"""
|
||||
children: list[KbTreeFolder | KbTreeFile] = []
|
||||
for sub in sorted(g for g in folders if folder_of(g) == folder):
|
||||
sub_children = _level_children(source, sub, folders, counts, rows, summaries)
|
||||
sub_children = _level_children(
|
||||
source, sub, folders, counts, rows, summaries, images
|
||||
)
|
||||
children.append(
|
||||
KbTreeFolder(
|
||||
path=sub,
|
||||
@@ -494,6 +587,25 @@ def _level_children(
|
||||
)
|
||||
for path, title, chunks, indexed_at, created_at in rows:
|
||||
if folder_of(path) == folder:
|
||||
image_info = images.get((source, path)) if images else None
|
||||
if image_info is not None:
|
||||
# Phase 122 (task 04): the image-docs node — the RAG
|
||||
# view's Path cell renders the 48px thumbnail from the
|
||||
# bytes route's URL with ``alt = summary``.
|
||||
doc_id, doc_summary = image_info
|
||||
children.append(
|
||||
KbTreeFile(
|
||||
path=path,
|
||||
title=title,
|
||||
chunks=chunks,
|
||||
created_at=created_at,
|
||||
indexed_at=indexed_at,
|
||||
is_image=True,
|
||||
image_url=f"/api/documents/{doc_id}/image",
|
||||
summary=doc_summary,
|
||||
)
|
||||
)
|
||||
else:
|
||||
children.append(
|
||||
KbTreeFile(
|
||||
path=path,
|
||||
@@ -530,6 +642,7 @@ def build_kb_tree(
|
||||
names: Sequence[str],
|
||||
doc_rows: Sequence[TreeDocRow],
|
||||
summaries: Mapping[tuple[str, str], str],
|
||||
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
|
||||
) -> list[KbTreeSource]:
|
||||
"""The pure tree builder behind ``GET /api/docs/tree`` (phase 97,
|
||||
task 02) — module-level and DB-free so unit tests drive it
|
||||
@@ -543,7 +656,13 @@ def build_kb_tree(
|
||||
``{(source, folder_path): summary}`` over the stored
|
||||
``folder_summaries`` rows (``folder_path = ""`` = the source root;
|
||||
rows for sources the tree does not list are simply never
|
||||
referenced).
|
||||
referenced). *images* (phase 122, task 04) —
|
||||
``{(source, path): (doc_id, summary)}`` over the stored image docs
|
||||
(``is_image`` rows with a servable ``image_path`` — the endpoint
|
||||
composes the bounded select); the default ``None``/empty map keeps
|
||||
EVERY file node the pre-phase shape (byte-identical wire — a
|
||||
pre-phase KB has no image rows, so the endpoint's own map is empty
|
||||
for it).
|
||||
|
||||
Shape, per the phase-97 ``00_phase.md`` "The tree endpoint":
|
||||
|
||||
@@ -614,10 +733,10 @@ def build_kb_tree(
|
||||
if name in listed: # defensive: list_source_names dedupes
|
||||
continue
|
||||
listed.add(name)
|
||||
tree.append(_source_node(name, by_source.get(name, ()), summaries))
|
||||
tree.append(_source_node(name, by_source.get(name, ()), summaries, images))
|
||||
for source in sorted(by_source):
|
||||
if source not in listed:
|
||||
tree.append(_source_node(source, by_source[source], summaries))
|
||||
tree.append(_source_node(source, by_source[source], summaries, images))
|
||||
return tree
|
||||
|
||||
|
||||
@@ -625,6 +744,7 @@ def _source_node(
|
||||
source: str,
|
||||
rows: Sequence[TreeFileRow],
|
||||
summaries: Mapping[tuple[str, str], str],
|
||||
images: Mapping[tuple[str, str], ImageDocInfo] | None = None,
|
||||
) -> KbTreeSource:
|
||||
"""One source node (pure): whole-source count + the source-root
|
||||
summary + the root level's children (direct subfolders + direct
|
||||
@@ -646,7 +766,7 @@ def _source_node(
|
||||
0-document source (no children, no dates).
|
||||
"""
|
||||
folders, counts = _folder_counts(rows)
|
||||
children = _level_children(source, "", folders, counts, rows, summaries)
|
||||
children = _level_children(source, "", folders, counts, rows, summaries, images)
|
||||
return KbTreeSource(
|
||||
name=source,
|
||||
documents=len(rows),
|
||||
@@ -677,9 +797,12 @@ def list_kb_tree(
|
||||
excluded — the tree has no document ids) + ALL stored
|
||||
``folder_summaries`` rows (a bounded select — one row per
|
||||
existing folder at the ≥ 1-doc rule; rows for sources the tree
|
||||
does not list are never referenced by the builder) — through the
|
||||
pure :func:`build_kb_tree`. ``GET /api/docs`` itself is
|
||||
untouched.
|
||||
does not list are never referenced by the builder) + the phase-122
|
||||
(task 04) image-docs map (a second bounded select over the
|
||||
``is_image`` rows with a servable ``image_path`` — empty for every
|
||||
pre-phase KB, so the response stays byte-identical to pre-phase)
|
||||
— through the pure :func:`build_kb_tree`. ``GET /api/docs`` itself
|
||||
is untouched.
|
||||
"""
|
||||
names = list_source_names(db)
|
||||
rows = db.execute(
|
||||
@@ -712,4 +835,22 @@ def list_kb_tree(
|
||||
select(FolderSummary.source, FolderSummary.folder_path, FolderSummary.summary)
|
||||
).all()
|
||||
}
|
||||
return KbTree(sources=build_kb_tree(names, doc_rows, summaries))
|
||||
# Phase 122 (task 04): the image-docs affordance map — a bounded
|
||||
# select over the ``is_image`` rows only (a handful of rows at KB
|
||||
# scale, never the whole catalog; the catalogue query above stays
|
||||
# byte-identical). EMPTY for every pre-phase KB (no image rows), so
|
||||
# the response stays byte-identical to pre-phase — the fields are
|
||||
# row-driven, not toggle-driven (a surviving image doc keeps its
|
||||
# thumbnail through a toggle-off sync, the prune guard's UX side).
|
||||
images: dict[tuple[str, str], tuple[str, str]] = {
|
||||
(source, path): (str(doc_id), summary)
|
||||
for source, path, doc_id, summary in db.execute(
|
||||
select(
|
||||
Document.source,
|
||||
Document.path,
|
||||
Document.id,
|
||||
Document.summary,
|
||||
).where(Document.is_image.is_(True), Document.image_path.is_not(None))
|
||||
).all()
|
||||
}
|
||||
return KbTree(sources=build_kb_tree(names, doc_rows, summaries, images))
|
||||
|
||||
+78
-17
@@ -49,11 +49,16 @@ embeddings) committed first, then the app-managed on-disk dir). The
|
||||
whole router sits behind :func:`app.core.auth.require_admin` —
|
||||
anonymous callers get 403 on every route.
|
||||
|
||||
No credential-echo path: git URLs may embed ``user:pass@`` (phase 32's
|
||||
masking discipline), so every git 409/422 detail is a fixed generic
|
||||
string that never repeats the submitted URL. Local paths are not
|
||||
secrets — the local 422/409 details name the (expanded) path so the
|
||||
owner sees exactly which directory failed.
|
||||
No credential-echo path (phase 32's masking discipline, extended by
|
||||
phase 121 — LOCKED A2): git URLs may embed ``user:pass@``, so (a)
|
||||
every git 409/422 detail is a fixed generic string that never repeats
|
||||
the submitted URL, and (b) every URL that LEAVES the API is masked
|
||||
through :func:`app.rag.git_sources.sanitize_url` before it enters a
|
||||
response (DB rows, env-fallback rows, POST 201, PATCH 200) — a legacy
|
||||
row whose credential is still embedded in the stored ``url`` clones
|
||||
fine (the stored value is untouched) but its API/UI output is
|
||||
bare. Local paths are not secrets — the local 422/409 details name
|
||||
the (expanded) path so the owner sees exactly which directory failed.
|
||||
|
||||
Scope boundary (phase locked decisions): the CRUD routes do NOT
|
||||
clone or import anything — the existing Sync button performs that, and
|
||||
@@ -110,6 +115,7 @@ from app.rag.archive_upload import (
|
||||
swap_in,
|
||||
unpack_archive,
|
||||
)
|
||||
from app.rag.git_sources import normalize_credential, sanitize_url
|
||||
from app.rag.importer import normalize_ignore_path
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.overview import regenerate_overview
|
||||
@@ -220,6 +226,11 @@ def list_git_sources(
|
||||
git-only) with null ``id``/``added_at``, ``ignore_paths: []`` and
|
||||
``include_hidden: False`` (no DB row to store a list or a flag on),
|
||||
and ``from_env: true``.
|
||||
|
||||
Every URL is masked on the way out (phase 121, LOCKED A2 —
|
||||
:func:`sanitize_url`): an env value or a legacy stored URL may
|
||||
embed ``user:pass@`` — the env value and the DB value are
|
||||
untouched, only the response is bare.
|
||||
"""
|
||||
rows = db.scalars(
|
||||
select(GitSource).order_by(GitSource.added_at.asc(), GitSource.id.asc())
|
||||
@@ -232,7 +243,7 @@ def list_git_sources(
|
||||
GitSourceRow(
|
||||
id=row.id,
|
||||
kind=cast(Literal["git", "local"], row.kind),
|
||||
url=row.url,
|
||||
url=sanitize_url(row.url), # phase 121: never echo userinfo
|
||||
path=row.path,
|
||||
added_at=row.added_at,
|
||||
ignore_paths=row.ignore_paths or [],
|
||||
@@ -247,7 +258,7 @@ def list_git_sources(
|
||||
GitSourceRow(
|
||||
id=None,
|
||||
kind="git",
|
||||
url=url,
|
||||
url=sanitize_url(url), # phase 121: an env URL can embed a token
|
||||
path=None,
|
||||
added_at=None,
|
||||
ignore_paths=[],
|
||||
@@ -290,11 +301,20 @@ def create_git_source(
|
||||
``include_hidden`` (phase 105) — optional, both kinds: absent →
|
||||
stored ``False`` (A4), present → stored as sent; the stored flag is
|
||||
what is reported.
|
||||
|
||||
``token`` (phase 121, LOCKED A2) — the masked private-repo
|
||||
credential: write-only, stored in the dedicated column, never
|
||||
echoed (the response has no token field by contract). Git rows
|
||||
are normalized on the way in (``normalize_credential``): an
|
||||
old-style embedded ``user:pass@`` URL is stored bare with the
|
||||
credential in the token column, an explicit ``token`` wins over
|
||||
the embedded one (LOCKED A6), and the duplicate check runs on the
|
||||
bare URL.
|
||||
"""
|
||||
row = _create_git_row(payload, db) if payload.kind == "git" else _create_local_row(payload, db)
|
||||
return GitSourceOut(
|
||||
id=row.id,
|
||||
url=row.url,
|
||||
url=sanitize_url(row.url), # phase 121: the output mask, always
|
||||
added_at=row.added_at,
|
||||
ignore_paths=row.ignore_paths,
|
||||
include_hidden=row.include_hidden,
|
||||
@@ -346,6 +366,13 @@ def _create_git_row(payload: GitSourceIn, db: Session) -> GitSource:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="not a valid git URL (expected https://, ssh:// or git@…)"
|
||||
)
|
||||
# Phase 121 (task 02, LOCKED A6): normalize the credential — an
|
||||
# old-style embedded ``user:pass@`` URL is stored BARE and the
|
||||
# embedded credential moves to the token column; an explicit
|
||||
# ``token`` field wins over the embedded one. The duplicate check
|
||||
# below runs on the BARE URL, so the same repo pasted with a
|
||||
# different credential is the same source (409, not a second row).
|
||||
url, effective_token = normalize_credential(url, payload.token)
|
||||
if db.scalar(select(GitSource).where(GitSource.url == url)) is not None:
|
||||
raise HTTPException(status_code=409, detail="a git source with this URL already exists")
|
||||
return _commit_new(
|
||||
@@ -354,6 +381,7 @@ def _create_git_row(payload: GitSourceIn, db: Session) -> GitSource:
|
||||
kind="git",
|
||||
ignore_paths=_validate_ignore_paths(payload.ignore_paths),
|
||||
include_hidden=bool(payload.include_hidden),
|
||||
token=effective_token or None,
|
||||
),
|
||||
"a git source with this URL already exists",
|
||||
db,
|
||||
@@ -380,7 +408,10 @@ def _create_local_row(payload: GitSourceIn, db: Session) -> GitSource:
|
||||
)
|
||||
# ``url`` is the table's NOT-NULL location column (phase 38: local
|
||||
# rows carry the expanded path there too — git URL shapes and absolute
|
||||
# paths cannot collide).
|
||||
# paths cannot collide). A ``token`` on a local row (phase 121) is
|
||||
# stored inert — local rows are walked, not cloned, so
|
||||
# ``clone_url_for`` never sees it — and, like on git rows, is never
|
||||
# echoed by any output shape.
|
||||
return _commit_new(
|
||||
GitSource(
|
||||
url=path,
|
||||
@@ -388,6 +419,7 @@ def _create_local_row(payload: GitSourceIn, db: Session) -> GitSource:
|
||||
path=path,
|
||||
ignore_paths=_validate_ignore_paths(payload.ignore_paths),
|
||||
include_hidden=bool(payload.include_hidden),
|
||||
token=payload.token or None,
|
||||
),
|
||||
f"a local source with this path already exists: {path}",
|
||||
db,
|
||||
@@ -400,14 +432,26 @@ def patch_git_source(
|
||||
payload: GitSourcePatchIn,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> GitSourceOut:
|
||||
"""Edit one source's ignore list and/or hidden-folders flag.
|
||||
"""Edit one source's ignore list, hidden-folders flag, and/or
|
||||
private-repo token.
|
||||
|
||||
Phase 89 A5 (ignore list) + phase 105 (the flag): 404 unknown
|
||||
id; each PRESENT body field applies independently —
|
||||
``ignore_paths`` REPLACES the list (normalized + A4-validated,
|
||||
fixed 422 details); ``include_hidden`` sets the flag. Both
|
||||
absent → 200 no-op. Returns the updated row's public shape
|
||||
(id, url, added_at, ignore_paths, include_hidden).
|
||||
Phase 89 A5 (ignore list) + phase 105 (the flag) + phase 121
|
||||
(the token): 404 unknown id; each PRESENT body field applies
|
||||
independently — ``ignore_paths`` REPLACES the list (normalized +
|
||||
A4-validated, fixed 422 details); ``include_hidden`` sets the
|
||||
flag; ``token`` is TRI-STATE (LOCKED A2): absent/None = no change
|
||||
(the row's stored credential survives an edit that does not touch
|
||||
the masked field), non-empty = replace, empty string = clear
|
||||
(stored NULL). A PRESENT token also re-normalizes the (current
|
||||
url, new token) pair with the POST write-path rules — a legacy
|
||||
embedded-token URL gets its userinfo stripped (moved to the
|
||||
column) the first time an explicit credential is written; a clean
|
||||
URL comes back untouched. The 409 backstop: re-normalizing can
|
||||
make the stored URL collide with another row's bare URL (a
|
||||
legacy ``user:pass@`` row and a bare row for the same repo) — the
|
||||
unique index yields the generic 409, never a 500. Returns the
|
||||
updated row's public shape (id, url — masked, added_at,
|
||||
ignore_paths, include_hidden); the token is never echoed.
|
||||
"""
|
||||
row = db.get(GitSource, source_id)
|
||||
if row is None:
|
||||
@@ -416,11 +460,28 @@ def patch_git_source(
|
||||
row.ignore_paths = _validate_ignore_paths(payload.ignore_paths)
|
||||
if payload.include_hidden is not None:
|
||||
row.include_hidden = payload.include_hidden
|
||||
if payload.token is not None:
|
||||
# Phase 121 (task 02): the tri-state applies — "" clears
|
||||
# (stored NULL), non-empty replaces. Re-normalize the pair
|
||||
# (see the docstring): a legacy embedded-token URL becomes
|
||||
# bare + column credential.
|
||||
row.url, effective = normalize_credential(row.url, payload.token)
|
||||
row.token = effective or None
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# The re-normalized URL collided with another row's stored URL
|
||||
# (the legacy-embedded + bare sibling case) — the unique index
|
||||
# is the backstop: a generic 409, never a 500 (the phase-35
|
||||
# convention).
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409, detail="a git source with this URL already exists"
|
||||
) from None
|
||||
db.refresh(row)
|
||||
return GitSourceOut(
|
||||
id=row.id,
|
||||
url=row.url,
|
||||
url=sanitize_url(row.url), # phase 121: the output mask, always
|
||||
added_at=row.added_at,
|
||||
ignore_paths=row.ignore_paths,
|
||||
include_hidden=row.include_hidden,
|
||||
|
||||
+14
-4
@@ -27,8 +27,14 @@ decisions):
|
||||
URLs) fails loudly (``no sources configured (git or local)``)
|
||||
instead of silently importing the legacy local directories;
|
||||
3. per resolved row: ``kind=git`` → :func:`scripts.git_sync.clone_or_pull`
|
||||
into ``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
||||
re-implemented); ``kind=local`` → the stored directory, re-verified
|
||||
with the phase-121 clone URL (:func:`app.rag.git_sources.clone_url_for`
|
||||
— the row's ``token`` column injected as
|
||||
``https://x-access-token:<token>@…`` only for https? rows; NULL
|
||||
token → the bare stored URL verbatim, so public repos and legacy
|
||||
embedded-token rows clone exactly as before) into
|
||||
``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
||||
re-implemented; the checkout name stays on the bare URL —
|
||||
credential-free); ``kind=local`` → the stored directory, re-verified
|
||||
``.is_dir()`` **at sync time** (it may have moved/deleted since
|
||||
add-time) — a missing directory raises ``local source missing:
|
||||
<path>``; a failing clone or a missing local dir aborts before any
|
||||
@@ -116,7 +122,7 @@ from app.core.auth import require_admin
|
||||
from app.core.errors import sanitize_error as _sanitize_error
|
||||
from app.db import SessionLocal
|
||||
from app.rag.folder_summaries import generate_folder_summaries, missing_folder_summaries
|
||||
from app.rag.git_sources import effective_sources
|
||||
from app.rag.git_sources import clone_url_for, effective_sources
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient, check_models
|
||||
from app.rag.overview import regenerate_overview
|
||||
@@ -293,7 +299,11 @@ async def _run_sync() -> None:
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] = {}
|
||||
for row in rows:
|
||||
if row.kind == "git":
|
||||
root = clone_or_pull(row.url, sources_root / repo_name(row.url))
|
||||
# Phase 121: the token column is injected into the clone
|
||||
# URL ONLY here (clone_url_for — NULL token → the bare
|
||||
# stored URL verbatim); repo_name stays on the bare URL
|
||||
# so the checkout directory name is credential-free.
|
||||
root = clone_or_pull(clone_url_for(row), sources_root / repo_name(row.url))
|
||||
# Phase 106 (D2): the checkout's per-file last-commit
|
||||
# dates, keyed by the SAME root string the importer
|
||||
# sees (full-history checkouts → true per-file
|
||||
|
||||
@@ -366,6 +366,52 @@ class Settings(BaseSettings):
|
||||
#: pattern).
|
||||
upload_max_mb: int = 512
|
||||
|
||||
# --- Image documents (phase 122: standalone images as documents) ---
|
||||
#: Master switch for image-document indexing (phase 122,
|
||||
#: ``BOR_IMAGES``; ``0``/``false`` = off — the DEFAULT, LOCKED A3).
|
||||
#: Enable only when ``llm_chat_model`` supports vision: image
|
||||
#: descriptions are generated by the chat model, and the description
|
||||
#: is the ONLY part of an image that gets indexed (the embedding
|
||||
#: model never sees pixels). While off, the import walks ignore
|
||||
#: image files and a sync never prunes existing ``is_image``
|
||||
#: documents (the phase-122 prune guard — the image is invisible to
|
||||
#: an images-off walk, not a deleted file).
|
||||
images: bool = False
|
||||
#: Comma-separated, case-insensitive file extensions (no dot) treated
|
||||
#: as standalone images when ``images`` is on (phase 122,
|
||||
#: ``BOR_IMAGE_EXTENSIONS``). Stored as a raw CSV string (the
|
||||
#: ``import_extensions`` house convention) and parsed on demand via
|
||||
#: :py:meth:`image_extension_set`. A SEPARATE set from
|
||||
#: ``import_extension_set`` — images are never user-added via
|
||||
#: ``BOR_IMPORT_EXTENSIONS`` (the ``images`` toggle is the single
|
||||
#: knob). The validator rejects an empty list and malformed tokens,
|
||||
#: exactly like ``import_extensions`` (a typo would otherwise index
|
||||
#: zero images silently).
|
||||
image_extensions: str = "png,jpg,jpeg,webp,gif,bmp"
|
||||
#: Where ingested image bytes are copied for serving (phase 122,
|
||||
#: ``BOR_IMAGE_DIR``). Raw string — ``Path.expanduser()`` is applied
|
||||
#: by the importer, not here (the ``sources_dir``/``upload_dir``
|
||||
#: convention). Deliberately separate from ``sources_dir`` (git
|
||||
#: checkouts, re-cloned) and ``upload_dir`` (replaced on every
|
||||
#: upload): the served copy must outlive the source file.
|
||||
image_dir: str = "~/bor-sources/images"
|
||||
|
||||
# --- Chat image questions (phase 123: attach an image to a question) ---
|
||||
#: Where a user's question image bytes are stored (phase 123,
|
||||
#: ``BOR_CHAT_IMAGE_DIR`` — the ``image_dir`` convention: a sibling of
|
||||
#: phase 122's document-image dir, separate because question-images
|
||||
#: are per-conversation, not per-source). Raw string —
|
||||
#: ``Path.expanduser()`` is applied by the upload/serve routes, not
|
||||
#: here. Files land as ``<uuid4().hex>.<ext>`` — the uuid is the
|
||||
#: credential (no enumeration value; the saved/shared chat record
|
||||
#: carries the served path, never base64, LOCKED A5).
|
||||
chat_image_dir: str = "~/bor-sources/chat-images"
|
||||
#: Cap in MiB for one question-image upload (phase 123, LOCKED A5 —
|
||||
#: the ~10 MB cap; ``BOR_CHAT_IMAGE_MAX_MB``). ``<= 0`` would reject
|
||||
#: every upload — a typo, so the validator fails loudly at startup
|
||||
#: (the ``upload_max_mb`` pattern).
|
||||
chat_image_max_mb: int = 10
|
||||
|
||||
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
||||
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
||||
@@ -471,6 +517,25 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("image_extensions")
|
||||
@classmethod
|
||||
def _image_extensions_known(cls, v: str) -> str:
|
||||
"""Reject an empty list or malformed tokens loudly (the
|
||||
``import_extensions`` precedent, phase 122): a typo like
|
||||
``png,jpeb`` would otherwise index zero images silently."""
|
||||
exts = {part.strip().lstrip(".").lower() for part in v.split(",") if part.strip()}
|
||||
if not exts:
|
||||
raise ValueError("image_extensions must name at least one format")
|
||||
malformed = sorted(
|
||||
ext for ext in exts if re.fullmatch(r"[a-z0-9]{1,16}", ext) is None
|
||||
)
|
||||
if malformed:
|
||||
raise ValueError(
|
||||
f"image_extensions contains malformed token(s): {', '.join(malformed)} — "
|
||||
"each extension must be lowercase letters/digits only, 1-16 chars, no dot"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("agent_max_rounds")
|
||||
@classmethod
|
||||
def _agent_max_rounds_non_negative(cls, v: int) -> int:
|
||||
@@ -525,6 +590,15 @@ class Settings(BaseSettings):
|
||||
raise ValueError("upload_max_mb must be > 0 (MiB)")
|
||||
return v
|
||||
|
||||
@field_validator("chat_image_max_mb")
|
||||
@classmethod
|
||||
def _chat_image_max_mb_positive(cls, v: int) -> int:
|
||||
"""``0``/negative would reject every question-image upload — fail
|
||||
loud at startup (the ``upload_max_mb`` precedent, phase 123)."""
|
||||
if v <= 0:
|
||||
raise ValueError("chat_image_max_mb must be > 0 (MiB)")
|
||||
return v
|
||||
|
||||
@field_validator("history_max_turns")
|
||||
@classmethod
|
||||
def _history_max_turns_non_negative(cls, v: int) -> int:
|
||||
@@ -647,6 +721,19 @@ class Settings(BaseSettings):
|
||||
if part.strip()
|
||||
)
|
||||
|
||||
@property
|
||||
def image_extension_set(self) -> frozenset[str]:
|
||||
"""Lowercased, dotted image-extension set (``.png``) for the
|
||||
phase-122 walk filter — SEPARATE from
|
||||
:py:attr:`import_extension_set` (images are never user-added via
|
||||
``BOR_IMPORT_EXTENSIONS``; the ``images`` toggle is the single
|
||||
knob)."""
|
||||
return frozenset(
|
||||
f".{part.strip().lstrip('.').lower()}"
|
||||
for part in self.image_extensions.split(",")
|
||||
if part.strip()
|
||||
)
|
||||
|
||||
@property
|
||||
def git_source_list(self) -> list[str]:
|
||||
"""Non-empty, stripped git URLs from :py:attr:`git_sources` (phase 28).
|
||||
|
||||
@@ -38,7 +38,8 @@ from __future__ import annotations
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
#: The exact owner-approved policy (phase 82, decision A1).
|
||||
#: The exact owner-approved policy (phase 82, decision A1), extended
|
||||
#: by phase 123's ``img-src`` carve-out (see below).
|
||||
#: ``default-src 'self'`` is inherited by every sub-policy that has no
|
||||
#: explicit entry (``script-src``, ``style-src``, ``connect-src``, …),
|
||||
#: ``base-uri 'none'`` blocks base-tag hijacking, and
|
||||
@@ -46,7 +47,26 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
#: (verified unnecessary — see module docstring), no ``report-uri`` /
|
||||
#: ``report-to`` (no collector in the homelab — a report would just
|
||||
#: vanish).
|
||||
CSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
#
|
||||
#: Phase 123 (chat image questions, owner-confirmed 2026-09-24) added
|
||||
#: the one scoped relaxation the design requires: ``img-src 'self'
|
||||
#: data:``. The question-image composer renders the picked file as a
|
||||
#: ``data:`` URL — the PREVIEW thumbnail (before the send-time upload
|
||||
#: there is no served path yet) and the LIVE user bubble (the data URL
|
||||
#: needs no fetch) — and ``default-src 'self'`` alone blocks ``data:``
|
||||
#: images in every real browser (the phase-123 E2E caught it: the
|
||||
#: bubble degraded to the "image unavailable" line). The carve-out is
|
||||
#: ``img-src`` ONLY: ``data:`` never becomes a source for scripts,
|
||||
#: styles, or fetches (those keep the strict ``default-src 'self'``
|
||||
#: inheritance), and the bytes are the user's OWN locally-picked file
|
||||
#: (no exfiltration vector — an ``<img>`` cannot read them back).
|
||||
#: Restored / shared bubbles render from the served path (``'self'``),
|
||||
#: so the ``data:`` allowance exists for the two pre-upload/first-paint
|
||||
#: surfaces only.
|
||||
CSP = (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||
"img-src 'self' data:"
|
||||
)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
|
||||
@@ -22,6 +22,7 @@ from starlette.responses import FileResponse
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.chat_images import router as chat_images_router
|
||||
from app.api.chats import (
|
||||
public_router as chats_public_router,
|
||||
)
|
||||
@@ -116,6 +117,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(docs_router, prefix="/api")
|
||||
app.include_router(git_sources_router, prefix="/api")
|
||||
app.include_router(chat_router, prefix="/api")
|
||||
# Phase 123: the question-image upload/serve pair (POST is
|
||||
# user-gated like the chat turn; GET is public like saved-chat
|
||||
# content — the uuid filename is the credential).
|
||||
app.include_router(chat_images_router, prefix="/api")
|
||||
app.include_router(steering_router, prefix="/api")
|
||||
app.include_router(sync_router, prefix="/api")
|
||||
app.include_router(chats_router, prefix="/api")
|
||||
|
||||
@@ -139,6 +139,29 @@ class Document(Base):
|
||||
#: failed, and until the phase-118 backfill stores one on the next
|
||||
#: sync.
|
||||
summary: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
#: True iff this document is a standalone image (phase 122,
|
||||
#: LOCKED A3): ``content`` (and ``summary``) is the CHAT model's
|
||||
#: vision description of the image — the ONLY embedded text (the
|
||||
#: embedding model never sees pixels), and the image bytes
|
||||
#: themselves live at :py:attr:`image_path` (served by the document
|
||||
#: image route, task 04). ``False`` for every text document,
|
||||
#: including all pre-phase-122 rows (the server default keeps them
|
||||
#: valid without a backfill). An ``is_image`` doc is INVISIBLE to
|
||||
#: an images-off walk, not a deleted file — the importer's prune
|
||||
#: guard (the phase-122 derived decision) protects it while the
|
||||
#: toggle is off.
|
||||
is_image: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default=text("false"), nullable=False
|
||||
)
|
||||
#: Absolute path of the image's PERSISTENT copy in
|
||||
#: ``settings.image_dir`` (phase 122) — the importer copies each
|
||||
#: ingested image there (``<doc-id>.<ext>``) because the source
|
||||
#: file is disposable: uploads are replaced on every upload, git
|
||||
#: checkouts are re-cloned, local dirs are user-edited. The copy is
|
||||
#: written only when the doc is new or its hash changes, deleted on
|
||||
#: a content change (the stale copy) and on prune. NULL for text
|
||||
#: documents.
|
||||
image_path: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
|
||||
chunks: Mapped[list[Chunk]] = relationship(
|
||||
back_populates="document", cascade="all, delete-orphan"
|
||||
@@ -286,6 +309,16 @@ class GitSource(Base):
|
||||
include_hidden: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default=text("false"), nullable=False
|
||||
)
|
||||
#: Private-repo credential (phase 121, LOCKED A2): the PAT the owner
|
||||
#: types into the masked Sources-page field. NULL = public repo (or a
|
||||
#: legacy row whose credential is still embedded in ``url``). Stored
|
||||
#: plaintext BY NECESSITY — the repo must remain cloneable, so the
|
||||
#: raw credential must be recoverable at sync time; the DB is the
|
||||
#: trusted store and is never served to the UI. Injected into the
|
||||
#: clone URL ONLY at clone time
|
||||
#: (:func:`app.rag.git_sources.clone_url_for`); NEVER returned by
|
||||
#: any API shape (the output models gain no token field).
|
||||
token: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
|
||||
+38
-3
@@ -427,6 +427,18 @@ READ_TRUNCATION_NOTICE = (
|
||||
"document."
|
||||
)
|
||||
|
||||
#: The image-document marker (phase 122, task 05): the line prefixed to
|
||||
#: the vision DESCRIPTION a ``read`` of a standalone-image document
|
||||
#: returns — the model must reason about what it is reading (the text
|
||||
#: below is a description GENERATED from the image, not the image's
|
||||
#: own words). It sits on the result's THIRD line: the ``Document …``
|
||||
#: header and the phase-106 date line stay byte-identical (the E2E
|
||||
#: mock's ``_READ_RESULT_PREFIX`` header contract), and a NON-image
|
||||
#: doc's result carries no marker at all (byte-identical to pre-122).
|
||||
IMAGE_DOC_MARKER = (
|
||||
"Image document — the text below is a description generated from the image:"
|
||||
)
|
||||
|
||||
#: The no-source ``ls`` refusal with the teaching parenthetical
|
||||
#: appended (phase 72): used when a stripped scope has no ``/`` and
|
||||
#: matches no registered source (the incident's ``ls(path='.')``). The
|
||||
@@ -1270,6 +1282,12 @@ def _execute_tool(
|
||||
return _no_document_refusal(db, arg)
|
||||
holder.read_docs.append(doc)
|
||||
holder.tool_calls += 1
|
||||
# Phase 122 (task 05): an image document's content IS the vision
|
||||
# description — the marker line (a third line between the
|
||||
# byte-identical header/date lines and the text) tells the model
|
||||
# what it is reading. A text doc's ``marker`` is "" — the result
|
||||
# stays byte-identical to pre-122.
|
||||
marker = f"{IMAGE_DOC_MARKER}\n" if doc.is_image else ""
|
||||
cap = settings.read_max_chars
|
||||
if len(doc.content) > cap:
|
||||
# Phase 95 (owner permission 2026-09-10, ``TODO.md`` L5): the
|
||||
@@ -1293,17 +1311,20 @@ def _execute_tool(
|
||||
return (
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{marker}"
|
||||
f"{doc.content[:cap]}\n"
|
||||
f"{TRUNCATION_MARKER}\n"
|
||||
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
|
||||
)
|
||||
# At or under the cap: the pre-phase-95 result plus the
|
||||
# phase-106 D5 date line (first line byte-identical — the
|
||||
# mock's header contract; no marker, no notice, no holder
|
||||
# entry, no ToolResultPiece).
|
||||
# mock's header contract; no truncation marker, no notice, no
|
||||
# holder entry, no ToolResultPiece) — and, phase 122, the
|
||||
# image-document marker line for image docs only.
|
||||
return (
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{marker}"
|
||||
f"{doc.content}"
|
||||
)
|
||||
if call.name == "grep":
|
||||
@@ -1372,7 +1393,7 @@ async def run_agent(
|
||||
db_factory: Callable[[], Session],
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_message: str,
|
||||
user_message: str | list[dict[str, Any]],
|
||||
seed_docs: Sequence[Document],
|
||||
settings: Settings,
|
||||
holder: AgentHolder,
|
||||
@@ -1403,6 +1424,20 @@ async def run_agent(
|
||||
unchanged. ``()`` (the default) keeps the pre-phase-74 two-message
|
||||
request byte-identical.
|
||||
|
||||
User message (phase 123, TODO L6): *user_message* is the current
|
||||
turn's user content — the plain question string (every text-only
|
||||
turn, byte-identical to pre-phase) OR the multimodal content list
|
||||
``[{type: "text", …}, {type: "image_url", …}]`` for a question that
|
||||
carried an image. FLOW (pinned): the API layer
|
||||
(``app.api.chat``) builds the content via its ``build_user_content``
|
||||
helper and passes it HERE — ``run_agent`` builds its OWN
|
||||
``[system, *history, user]`` list from this value (it does NOT
|
||||
receive the already-built ``messages``; the deflected branch is the
|
||||
one that consumes chat.py's list directly), so a single value
|
||||
covers both shapes and the tool rounds / recovery / retries operate
|
||||
on it untouched. Prior turns' images are never replayed (LOCKED A7
|
||||
— *history* is text-only by construction).
|
||||
|
||||
Retries (phase 67, owner-locked A2): every model request goes through
|
||||
:func:`chat_stream_retried` — a failed round is retried **before** its
|
||||
first piece (same messages, ``settings.llm_retries`` restarts, a flat
|
||||
|
||||
@@ -21,9 +21,26 @@ CLI: the legacy ``DEFAULT_SOURCES`` fallback).
|
||||
(repo URLs of the effective git rows) so existing importers of the old
|
||||
name keep working; new code calls :func:`effective_sources` and
|
||||
branches on ``row.kind``.
|
||||
|
||||
Phase 121 (private git sources) adds the token mechanics next to the
|
||||
resolver — three pure helpers, no DB of their own:
|
||||
|
||||
* :func:`sanitize_url` — the OUTPUT mask: strips the ``user:pass@``
|
||||
userinfo of ``https?://`` URLs so no API/UI surface ever shows an
|
||||
embedded credential (legacy rows included; the stored value is
|
||||
untouched — LOCKED A2);
|
||||
* :func:`clone_url_for` — the CLONE-time credential: a row's
|
||||
``token`` column is injected into the URL handed to git, and only
|
||||
there (NULL token → the bare stored URL verbatim);
|
||||
* :func:`normalize_credential` — the WRITE-path normalizer: an
|
||||
old-style ``https://user:pass@host/repo.git`` URL pasted into the
|
||||
API is stored bare and the embedded credential is moved into the
|
||||
``token`` column (an explicit ``token`` field wins — LOCKED A6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -32,6 +49,109 @@ from sqlalchemy.orm import Session
|
||||
from app.config import get_settings
|
||||
from app.models import GitSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Phase 121 — the userinfo component of an ``https?://`` URL: the
|
||||
#: scheme, a run of one-or-more characters that are neither ``@`` nor
|
||||
#: ``/`` (the ``user`` or ``user:pass`` part), and the terminating
|
||||
#: ``@``. Deliberately a small anchored regex — never a URL parser
|
||||
#: re-serialization: for a credential-free URL there is no match and
|
||||
#: the input is returned byte-identical (the phase-50/35 contract that
|
||||
#: stored URLs surface verbatim when they carry no credential).
|
||||
_USERINFO_RE = re.compile(r"^(https?://)([^/@]+)@")
|
||||
|
||||
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""Phase 121, LOCKED A2 — the token-free form of a source URL,
|
||||
for API/UI output only.
|
||||
|
||||
Strips the userinfo component of ``https?://`` URLs
|
||||
(``https://user:pass@host/path`` → ``https://host/path``);
|
||||
``ssh://``, ``git@`` (scp-style), and local paths are left
|
||||
untouched. Idempotent — and byte-identical for URLs that carry no
|
||||
userinfo (no match → the input unchanged, including a ``@`` inside
|
||||
the *path*, which is not userinfo). The stored row value is NOT
|
||||
modified: a legacy row whose credential is still embedded in
|
||||
``url`` keeps cloning with its original stored URL; this is the
|
||||
output mask that keeps that credential out of every response and
|
||||
the UI (the env-fallback rows get the same treatment — the env
|
||||
*value* itself is untouched, only the response is masked).
|
||||
"""
|
||||
return _USERINFO_RE.sub(r"\1", url, count=1)
|
||||
|
||||
|
||||
def clone_url_for(row: GitSource) -> str:
|
||||
"""Phase 121, LOCKED A2 — the URL git actually clones, with the
|
||||
row's credential injected ONLY here.
|
||||
|
||||
* ``token`` NULL/falsy → ``row.url`` verbatim: public repos and
|
||||
local rows behave byte-identically to pre-phase-121, and a
|
||||
legacy embedded-token row (``token`` NULL, credential in the
|
||||
stored URL) keeps cloning with its ORIGINAL stored URL — the
|
||||
credential keeps working;
|
||||
* an ``https?://`` row with a token →
|
||||
``https://x-access-token:<token>@<host>/<path>`` — any existing
|
||||
userinfo in the stored URL is replaced by the column credential
|
||||
(``x-access-token`` as the username: GitHub-agnostic, any host
|
||||
that accepts ``https://user:token@`` treats the first component
|
||||
opaquely — the task-02 assumption, task 02 step 4);
|
||||
* a non-https row with a token (``ssh://``/``git@``/local path)
|
||||
→ ``row.url`` unchanged + a WARNING log (a token cannot
|
||||
authenticate ssh — the owner must use a deploy key/agent there;
|
||||
the log names the repo via its sanitized URL, never the token).
|
||||
|
||||
``repo_name`` (and every other checkout-path derivation) keeps
|
||||
operating on the bare ``row.url`` — the checkout directory name is
|
||||
credential-free.
|
||||
"""
|
||||
token = row.token
|
||||
if not token:
|
||||
return row.url
|
||||
if not row.url.startswith(("https://", "http://")):
|
||||
logger.warning(
|
||||
"git source %s has a stored token but a non-https? URL — "
|
||||
"a token cannot authenticate ssh/git@ clones; the stored "
|
||||
"URL is used as-is (configure a deploy key or SSH agent "
|
||||
"for private ssh repos)",
|
||||
sanitize_url(row.url),
|
||||
)
|
||||
return row.url
|
||||
bare = sanitize_url(row.url)
|
||||
return bare.replace("://", f"://x-access-token:{token}@", 1)
|
||||
|
||||
|
||||
def normalize_credential(url: str, token: str | None) -> tuple[str, str | None]:
|
||||
"""Phase 121, LOCKED A6 — the write-path credential normalizer.
|
||||
|
||||
If the (``https?://``-only) URL carries userinfo, it is stripped
|
||||
for storage and the EMBEDDED CREDENTIAL becomes the effective
|
||||
token — UNLESS the caller also sent an explicit ``token``
|
||||
(non-None), which WINS (explicit beats embedded — a blank masked
|
||||
field, i.e. an explicit "", is a deliberate "no credential").
|
||||
Pasting the old-style ``https://user:ghp_…@host/repo.git`` URL
|
||||
still works and lands token-column-clean; the caller stores the
|
||||
bare URL + ``effective_token or None`` (an empty explicit token
|
||||
stores NULL) and runs its duplicate check on the BARE URL, so the
|
||||
same repo with a different token is still the same source
|
||||
(409, not a second row).
|
||||
|
||||
The embedded credential is the *password* part of a
|
||||
``user:pass`` userinfo (after the first colon — the password may
|
||||
contain further colons), or the whole userinfo run for the
|
||||
username-as-token form (``https://<token>@host/…``, the documented
|
||||
GitHub shape, no colon). Clean URLs and ``ssh://``/``git@``/local
|
||||
paths return ``(url, token)`` untouched — byte-identical
|
||||
pre-phase behavior.
|
||||
"""
|
||||
match = _USERINFO_RE.match(url)
|
||||
if match is None:
|
||||
return url, token
|
||||
userinfo = match.group(2)
|
||||
user, sep, password = userinfo.partition(":")
|
||||
embedded = password if sep else userinfo
|
||||
effective = token if token is not None else embedded
|
||||
return sanitize_url(url), effective
|
||||
|
||||
|
||||
def effective_sources(db: Session) -> tuple[list[GitSource], Literal["db", "env"]]:
|
||||
"""``(rows, origin)`` — the effective source rows (both kinds) and
|
||||
|
||||
+373
-12
@@ -30,7 +30,12 @@ by their exact lowercased full filename (``Dockerfile`` under the
|
||||
no longer exist **or no longer match the format filter** — this is how
|
||||
previously-imported junk (e.g. dot-dir READMEs) leaves the index. Per-file
|
||||
logging uses the verbs ``added | updated | unchanged | pruned`` plus a
|
||||
summary line with per-format counts (PLAN §9).
|
||||
summary line with per-format counts (PLAN §9). Phase 122 prune guard
|
||||
(LOCKED, derived from A3/A4): while the ``images`` toggle is OFF, an
|
||||
``is_image`` doc is INVISIBLE to the walk, not a deleted file — prune
|
||||
skips it (turning the toggle off and syncing must never destroy image
|
||||
documents); a toggle-ON run prunes a deleted image file normally and
|
||||
deletes its ``image_dir`` copy with the row.
|
||||
|
||||
Document dates (phase 106, D2/D4): every import sources
|
||||
``documents.created_at`` from the file's source — the per-file git
|
||||
@@ -54,6 +59,32 @@ backfill runs BEFORE the ``created_at_manual`` early-return (the manual
|
||||
flag protects the DATE only, D1) and the strict ``is None`` check leaves
|
||||
owner-set summaries (even empty strings, phase 57) alone.
|
||||
|
||||
Standalone images (phase 122, LOCKED A3): with the ``images`` toggle
|
||||
(``BOR_IMAGES``) ON, the walk also admits the image extension set
|
||||
(``BOR_IMAGE_EXTENSIONS`` — a SEPARATE set from ``import_extension_set``;
|
||||
images are never user-added via ``BOR_IMPORT_EXTENSIONS``, the toggle is
|
||||
the single knob). Such a file takes the binary index path
|
||||
(:func:`_index_image_file`): the sha256 digest is over the raw BYTES
|
||||
(content identity — the digest rule is unchanged), the bytes are copied
|
||||
to the persistent home ``settings.image_dir/<doc-id>.<ext>`` (dir created
|
||||
on demand; the copy is written ONLY after a successful description, so a
|
||||
failure never leaves an orphan; a changed image deletes the stale copy
|
||||
first; a pruned image doc deletes its copy), the row carries
|
||||
``is_image=True`` + ``image_path``, and ``content`` is the vision
|
||||
description — the ONLY embedded text of the document (the embedding model
|
||||
never sees pixels; ``read_text`` is never called for an image). The
|
||||
description comes through the single seam :func:`_describe_or_skip`
|
||||
(task 03: :func:`app.rag.summarizer.describe_image` — ONE CHAT-model
|
||||
(vision) call with the image bytes as a base64 data URL; the ``lite``
|
||||
summary model is NOT assumed vision-capable, LOCKED A3); a failed/empty
|
||||
description SKIPS the doc entirely (no row, no copy) — counted in
|
||||
``images_failed`` + a warning, the sync continues (fail-soft). The normal
|
||||
chunk pipeline then embeds ``content`` and the phase-30 summary path runs
|
||||
on it — image-aware (task 03): for an image doc the description IS the
|
||||
summary (stored verbatim, no ``lite`` call, no pointer line), so the
|
||||
``is_summary`` position −1 chunk mirrors ``Document.summary``, which
|
||||
equals ``Document.content``.
|
||||
|
||||
``import_sources`` accepts an optional per-file ``progress`` callback
|
||||
(phase 64, task 01) reporting the file being processed right now.
|
||||
"""
|
||||
@@ -61,11 +92,12 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -76,7 +108,12 @@ from app.models import Chunk, Document
|
||||
from app.rag.chunker import chunk_document, extract_title
|
||||
from app.rag.doc_dates import file_mtime_datetime, normalize_doc_date
|
||||
from app.rag.llm import EmbeddingError, LLMError
|
||||
from app.rag.summarizer import generate_summary
|
||||
from app.rag.summarizer import (
|
||||
IMAGE_FALLBACK_MIME,
|
||||
IMAGE_MIMES,
|
||||
describe_image,
|
||||
generate_summary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("app.importer")
|
||||
|
||||
@@ -94,9 +131,12 @@ class Embedder(Protocol):
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
|
||||
async def chat(self, messages: list[dict[str, str]], model: str | None = None) -> str: ...
|
||||
# ^ the one-shot completion the summarizer uses for the ``lite`` model
|
||||
# (phase 30, task 01); :class:`app.rag.llm.LLMClient` satisfies it.
|
||||
async def chat(self, messages: list[dict[str, Any]], model: str | None = None) -> str: ...
|
||||
# ^ the one-shot completion the summarizer uses — the ``lite`` model
|
||||
# for text summaries (phase 30, task 01) and the CHAT (vision)
|
||||
# model for the phase-122 image description (multimodal content:
|
||||
# a string or a list of OpenAI-compatible parts); :class:`app.rag.
|
||||
# llm.LLMClient` satisfies it.
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -129,6 +169,12 @@ class ImportSummary:
|
||||
#: so no ``sources_meta`` bump, no overview/folder-summary
|
||||
#: regeneration).
|
||||
dates_updated: int = 0
|
||||
#: Image docs (phase 122, LOCKED A3) whose vision description failed
|
||||
#: or came back empty — the doc is SKIPPED entirely (no row, no
|
||||
#: ``image_dir`` copy): an undescribed image is unsearchable noise.
|
||||
#: Fail-soft: the sync continues, this counter + the warning line
|
||||
#: are the signal.
|
||||
images_failed: int = 0
|
||||
#: Files walked, keyed by lowercased extension (``md``, ``yaml``, …).
|
||||
formats: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
@@ -143,7 +189,7 @@ class ImportSummary:
|
||||
logger.info(
|
||||
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
|
||||
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
|
||||
"summary_backfilled=%d dates_updated=%d formats=%s",
|
||||
"summary_backfilled=%d dates_updated=%d images_failed=%d formats=%s",
|
||||
self.files,
|
||||
self.added,
|
||||
self.updated,
|
||||
@@ -156,6 +202,7 @@ class ImportSummary:
|
||||
self.summary_errors,
|
||||
self.summary_backfilled,
|
||||
self.dates_updated,
|
||||
self.images_failed,
|
||||
self.format_counts(),
|
||||
)
|
||||
|
||||
@@ -238,6 +285,7 @@ def iter_importable_files(
|
||||
excluded: frozenset[str] = EXCLUDED_DIRS,
|
||||
ignore: tuple[str, ...] = (),
|
||||
include_hidden: bool = False,
|
||||
image_extensions: frozenset[str] = frozenset(),
|
||||
) -> list[Path]:
|
||||
"""All importable files under *root* (sorted), per the A9 scope rules.
|
||||
|
||||
@@ -255,6 +303,13 @@ def iter_importable_files(
|
||||
source-relative POSIX path starts with any entry; the default ``()``
|
||||
keeps every existing caller byte-identical. The *ignore* tuple
|
||||
composes additively in both states.
|
||||
|
||||
*image_extensions* (phase 122) is the lowercased dotted
|
||||
image-extension set admitted IN ADDITION to *extensions* — passed by
|
||||
:func:`import_sources` only while the ``images`` toggle is on (it
|
||||
reads ``llm.settings``; the image set is never merged into
|
||||
*extensions*). The empty default admits nothing: every existing caller
|
||||
(and the toggle-off walk) stays byte-identical to pre-phase.
|
||||
"""
|
||||
if not root.is_dir():
|
||||
return []
|
||||
@@ -270,7 +325,12 @@ def iter_importable_files(
|
||||
continue
|
||||
if ignore and is_ignored(rel.as_posix(), ignore):
|
||||
continue
|
||||
if match_extension(path, extensions) is None:
|
||||
matched = match_extension(path, extensions)
|
||||
if matched is None and image_extensions:
|
||||
# Phase 122: the image set is admitted IN ADDITION to the
|
||||
# import set (toggle on only — the caller passes it in).
|
||||
matched = match_extension(path, image_extensions)
|
||||
if matched is None:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
@@ -340,10 +400,23 @@ async def import_sources(
|
||||
mtime fallback applies to every file — which IS the behavior
|
||||
change, D4: an unchanged file now refreshes its stored date from
|
||||
its source on every run (the backfill-correction case).
|
||||
|
||||
Images (phase 122): when ``llm.settings.images`` is on, BOTH walks
|
||||
(the progress pre-walk and the processing loop — same rules, so
|
||||
``total`` counts images) also admit ``llm.settings.image_extension_set``
|
||||
files, each indexed through the binary image path (see the module
|
||||
docstring). ``prune=True`` with the toggle ON prunes a deleted image
|
||||
file normally (row + ``image_dir`` copy); with the toggle OFF the
|
||||
prune skips ``is_image`` docs (the prune guard — the image is
|
||||
invisible to the walk, not a deleted file).
|
||||
"""
|
||||
if limit is not None and limit <= 0:
|
||||
raise ValueError("limit must be >= 1")
|
||||
summary = ImportSummary()
|
||||
# Phase 122: the image set is admitted by the walks ONLY while the
|
||||
# toggle is on — the empty set admits nothing, so the toggle-off run
|
||||
# (walk, counts, prune) stays byte-identical to pre-phase.
|
||||
image_exts = llm.settings.image_extension_set if llm.settings.images else frozenset()
|
||||
owns_session = session is None
|
||||
if session is None:
|
||||
session = SessionLocal()
|
||||
@@ -364,6 +437,7 @@ async def import_sources(
|
||||
include_hidden=_include_hidden_for_root(
|
||||
root, include_hidden_by_root
|
||||
),
|
||||
image_extensions=image_exts,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -386,6 +460,7 @@ async def import_sources(
|
||||
llm.settings.import_extension_set,
|
||||
ignore=ignore,
|
||||
include_hidden=include_hidden,
|
||||
image_extensions=image_exts,
|
||||
):
|
||||
if limit is not None and summary.files >= limit:
|
||||
break
|
||||
@@ -394,9 +469,11 @@ async def import_sources(
|
||||
summary.files += 1
|
||||
# Phase 102: the matched bare token (``dockerfile`` for an
|
||||
# extensionless ``Dockerfile``), never ``unknown`` — the
|
||||
# file is in scope, so the walk matched it.
|
||||
# file is in scope, so the walk matched it. Phase 122: an
|
||||
# image file matches the image set, not the import set.
|
||||
ext = (
|
||||
match_extension(path, llm.settings.import_extension_set)
|
||||
or match_extension(path, image_exts)
|
||||
or "unknown"
|
||||
)
|
||||
summary.formats[ext] = summary.formats.get(ext, 0) + 1
|
||||
@@ -423,7 +500,9 @@ async def import_sources(
|
||||
if limit is not None:
|
||||
logger.warning("import: --prune ignored because --limit was given")
|
||||
else:
|
||||
summary.pruned = _prune(session, source_names, seen)
|
||||
summary.pruned = _prune(
|
||||
session, source_names, seen, images=llm.settings.images
|
||||
)
|
||||
summary.embed_batches = llm.embed_batches
|
||||
summary.log()
|
||||
return summary
|
||||
@@ -448,8 +527,24 @@ async def _index_file(
|
||||
git last-commit datetime from the caller's ``doc_dates_by_root``
|
||||
map, or ``None`` (every non-git case): the file's mtime is read
|
||||
here, once, and becomes the source date (the D2 fallback).
|
||||
|
||||
Image files (phase 122, toggle on) delegate to
|
||||
:func:`_index_image_file` — the binary path (bytes digest,
|
||||
persistent copy, ``content`` = the vision description) — BEFORE
|
||||
any text read: ``read_text`` is never called for an image.
|
||||
"""
|
||||
settings = llm.settings
|
||||
# Phase 122 (task 02): the image branch FIRST. Only reachable while
|
||||
# the ``images`` toggle is on — the walk never admits image files
|
||||
# while it is off, and with it off this check is a no-op (the text
|
||||
# path below stays byte-identical to pre-phase).
|
||||
if settings.images:
|
||||
image_set = settings.image_extension_set
|
||||
if match_extension(full_path, image_set) is not None:
|
||||
return await _index_image_file(
|
||||
session, source=source, rel=rel, full_path=full_path, llm=llm,
|
||||
summary=summary, raw_date=raw_date,
|
||||
)
|
||||
content = full_path.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
@@ -609,8 +704,23 @@ async def _store_summary(
|
||||
a success counts ``summary_backfilled`` instead of ``summaries``
|
||||
(the doc content is untouched, so the import's KB-change signal must
|
||||
not move); the rest of the mechanics are identical.
|
||||
|
||||
Image docs (phase 122, task 03, LOCKED A3): for an ``is_image`` doc
|
||||
the vision description — ``content`` (which equals ``doc.content``
|
||||
on the backfill path) — IS the summary: no ``lite`` call, no
|
||||
pointer line (the summary mirrors the description verbatim, so
|
||||
``doc.summary`` == ``doc.content``). The phase-30 chunk mechanics
|
||||
(one ``is_summary`` position −1 chunk, replacement, best-effort
|
||||
rollback) are unchanged; the only remaining failure class is the
|
||||
summary chunk's embed (the ``doc`` row + content chunks survive —
|
||||
fail-soft, same as the text path).
|
||||
"""
|
||||
try:
|
||||
if doc.is_image:
|
||||
# Phase 122 (task 03): the description IS the summary —
|
||||
# stored verbatim (no ``lite`` call, no pointer line).
|
||||
text = content
|
||||
else:
|
||||
text = await generate_summary(llm, source=source, path=rel, content=content)
|
||||
# Replacement: at most one summary chunk per document at a time.
|
||||
# Removing from the collection is what the ``delete-orphan``
|
||||
@@ -646,14 +756,265 @@ async def _store_summary(
|
||||
logger.error("import: summary failed source=%s path=%s — %s", source, rel, e)
|
||||
|
||||
|
||||
def _prune(session: Session, source_names: set[str], seen: set[tuple[str, str]]) -> int:
|
||||
"""Delete documents of *source_names* whose file is no longer in *seen*."""
|
||||
async def _describe_or_skip(
|
||||
llm: Embedder, *, data: bytes, source: str, rel: str, full_path: Path
|
||||
) -> str | None:
|
||||
"""Phase 122 — the SINGLE seam for the image description (task 03).
|
||||
|
||||
Returns the vision description that becomes the image document's
|
||||
``content`` (and ``summary`` — the ONLY embedded text of the doc),
|
||||
or ``None`` when the description failed or came back empty — the
|
||||
caller then SKIPS the doc entirely (no row, no copy) and counts
|
||||
``summary.images_failed`` (LOCKED A3, fail-soft: the sync continues,
|
||||
the warning + counter are the signal).
|
||||
|
||||
The seam is one line by design (the importer's tests patch exactly
|
||||
this function): :func:`app.rag.summarizer.describe_image` — ONE
|
||||
CHAT-model (vision) call (LOCKED A3) with the bytes as a data URL
|
||||
whose mime comes from :data:`app.rag.summarizer.IMAGE_MIMES`
|
||||
(dotted extension; an unlisted ``BOR_IMAGE_EXTENSIONS`` token takes
|
||||
the generic fallback — a rejection there fails soft like any other
|
||||
description error). ``source``/``rel`` stay on the signature so the
|
||||
caller (and the patch) reads like the document being described;
|
||||
the failure's doc identity is logged by the caller's warning.
|
||||
"""
|
||||
mime = IMAGE_MIMES.get(full_path.suffix.lower(), IMAGE_FALLBACK_MIME)
|
||||
return await describe_image(llm, data=data, mime=mime)
|
||||
|
||||
|
||||
def _delete_image_copy(image_path: str | None) -> None:
|
||||
"""Best-effort removal of a stale image copy (phase 122).
|
||||
|
||||
A missing path is a no-op (already gone — e.g. the owner cleaned
|
||||
the image dir); an unreadable one is logged, never raised — copy
|
||||
cleanup must not break the sync (the doc row's fate is decided by
|
||||
the upsert/prune logic, not by filesystem hygiene).
|
||||
"""
|
||||
if not image_path:
|
||||
return
|
||||
try:
|
||||
Path(image_path).unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("import: could not delete image copy %s — %s", image_path, e)
|
||||
|
||||
|
||||
async def _index_image_file(
|
||||
session: Session,
|
||||
*,
|
||||
source: str,
|
||||
rel: str,
|
||||
full_path: Path,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime | None = None,
|
||||
) -> None:
|
||||
"""The phase-122 image branch of :func:`_index_file` — a standalone
|
||||
image is indexed from its BYTES, never its text:
|
||||
|
||||
* the sha256 digest is over the raw bytes (the digest rule is
|
||||
content identity — the same bytes are the same document);
|
||||
* the PERSISTENT copy lands in ``settings.image_dir`` as
|
||||
``<doc-id>.<ext>`` (the dir is created on demand; the copy is
|
||||
written only AFTER a successful description, so a failure never
|
||||
leaves an orphan; a changed image deletes the stale copy before
|
||||
replacing it);
|
||||
* ``content`` is the vision description — the ONLY embedded text of
|
||||
the document (the embedding model never sees pixels) — and the
|
||||
normal chunk pipeline then embeds it, with the phase-30 summary
|
||||
path running on it (the ``is_summary`` position −1 chunk mirrors
|
||||
``Document.summary``).
|
||||
|
||||
``raw_date`` follows the text path exactly (the phase-106 D2
|
||||
fallback: no source date in the map → the file's mtime, read before
|
||||
the unchanged early-return because the unchanged path refreshes the
|
||||
stored date from the same source; the D1 manual-date lock and the
|
||||
D4 refresh apply unmodified).
|
||||
|
||||
Fail-soft (LOCKED A3): a failed/empty description SKIPS the doc
|
||||
entirely (no row, no copy) — ``summary.images_failed`` + a warning,
|
||||
the sync continues.
|
||||
"""
|
||||
settings = llm.settings
|
||||
data = full_path.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
if raw_date is None:
|
||||
# D2 fallback (same as the text path): no source date in the map
|
||||
# → the file's mtime (one stat).
|
||||
raw_date = file_mtime_datetime(full_path)
|
||||
|
||||
if doc is not None and doc.content_hash == digest:
|
||||
# Unchanged image (byte digest) — the text path's unchanged
|
||||
# branch, unmodified in shape.
|
||||
summary.unchanged += 1
|
||||
logger.info("import: unchanged source=%s path=%s", source, rel)
|
||||
# Phase 118 (A2) backfill, image flavour: an unchanged image doc
|
||||
# whose summary is still NULL (an earlier fail-soft summary miss
|
||||
# — for an image, the one remaining failure class: the summary
|
||||
# chunk's embed) gets the same best-effort summary pass. For an
|
||||
# image the summary IS the stored description (``doc.content``),
|
||||
# so the image-aware ``_store_summary`` (task 03) re-stores it
|
||||
# verbatim with one ``is_summary`` chunk; a failure (the
|
||||
# embed) keeps the doc as-is (no row mutation) — fail-soft,
|
||||
# same as the text path.
|
||||
if doc.summary is None:
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=rel, content=doc.content,
|
||||
llm=llm, summary=summary, backfill=True,
|
||||
)
|
||||
if doc.created_at_manual:
|
||||
# D1/D4: the owner's correction survives the sync — no write
|
||||
# at all (the text path's manual-date early-return).
|
||||
return
|
||||
# D4: the date refreshes on every sync, including unchanged
|
||||
# files, and may go OLDER (no monotonic guard).
|
||||
target = normalize_doc_date(raw_date)
|
||||
if target != doc.created_at:
|
||||
doc.created_at = target
|
||||
session.commit()
|
||||
summary.dates_updated += 1
|
||||
logger.info(
|
||||
"import: date-refreshed source=%s path=%s date=%s",
|
||||
source, rel, doc.created_at.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
verb = "updated" if doc is not None else "added"
|
||||
# The description is the doc's content (task 03: and its summary) —
|
||||
# it is generated BEFORE anything is written, so a failure skips the
|
||||
# doc with no row and no copy (the copy is only made after a
|
||||
# successful description — a failure never leaves an orphan).
|
||||
content = await _describe_or_skip(
|
||||
llm, data=data, source=source, rel=rel, full_path=full_path
|
||||
)
|
||||
if content is None:
|
||||
# LOCKED A3 fail-soft: an undescribed image is unsearchable
|
||||
# noise — skip the doc entirely (no row, no copy).
|
||||
summary.images_failed += 1
|
||||
logger.warning("import: image description failed source=%s path=%s", source, rel)
|
||||
return
|
||||
|
||||
# The persistent copy: uploads are replaced on every upload, git
|
||||
# checkouts are re-cloned, local dirs are user-edited — the served
|
||||
# bytes must outlive the source file. Named by the doc id: a new
|
||||
# doc's id is the uuid4 chosen here (row and copy agree); a changed
|
||||
# doc keeps its id (the copy path is stable).
|
||||
doc_id = doc.id if doc is not None else uuid.uuid4()
|
||||
image_dir = Path(settings.image_dir).expanduser()
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
if doc is not None:
|
||||
# A CHANGED image (hash differs): the stale copy is deleted
|
||||
# before replacement.
|
||||
_delete_image_copy(doc.image_path)
|
||||
copy_path = image_dir / f"{doc_id}{full_path.suffix.lower()}"
|
||||
copy_path.write_bytes(data)
|
||||
|
||||
# The non-markdown title rule (the image's content is prose, but the
|
||||
# doc IS the image — the file stem is the title).
|
||||
title = full_path.stem
|
||||
if doc is None:
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
source=source,
|
||||
path=rel,
|
||||
full_path=str(full_path),
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash=digest,
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=normalize_doc_date(raw_date),
|
||||
is_image=True,
|
||||
image_path=str(copy_path),
|
||||
)
|
||||
session.add(doc)
|
||||
else:
|
||||
doc.full_path = str(full_path)
|
||||
doc.title = title
|
||||
doc.content = content
|
||||
doc.content_hash = digest
|
||||
doc.indexed_at = datetime.now(UTC)
|
||||
# Phase 106 (D4): a content change is a new document version —
|
||||
# the date is re-sourced and a previous manual correction is
|
||||
# reset (it referred to the old content).
|
||||
doc.created_at = normalize_doc_date(raw_date)
|
||||
doc.created_at_manual = False
|
||||
doc.is_image = True
|
||||
doc.image_path = str(copy_path)
|
||||
|
||||
session.flush() # guarantees doc.id even for brand-new rows
|
||||
|
||||
# Phase 1+2 — the UNCHANGED pipeline on the description: chunk,
|
||||
# replace the chunk rows (embeddings NULL), embed, and commit the
|
||||
# whole file atomically (one transaction per file). The token-cap
|
||||
# retry loop is copied from the text path; a description is short,
|
||||
# so it never fires in practice.
|
||||
target = max(400, settings.chunk_target_chars)
|
||||
while True:
|
||||
chunks_text = chunk_document(content, rel, target, settings.chunk_overlap_chars)
|
||||
doc.chunks = [
|
||||
Chunk(document_id=doc.id, position=i, content=c) for i, c in enumerate(chunks_text)
|
||||
]
|
||||
session.flush() # delete-orphan cascade drops the previous rows
|
||||
if not doc.chunks:
|
||||
break
|
||||
try:
|
||||
vectors = await llm.embed([c.content for c in doc.chunks])
|
||||
for row, vec in zip(doc.chunks, vectors, strict=True):
|
||||
row.embedding = vec
|
||||
break
|
||||
except EmbeddingError as e:
|
||||
if "token cap" not in str(e) or target <= 400:
|
||||
raise
|
||||
logger.info(
|
||||
"import: re-chunking at %d chars after endpoint token cap: %s",
|
||||
target // 2,
|
||||
rel,
|
||||
)
|
||||
target //= 2
|
||||
session.commit()
|
||||
|
||||
if verb == "added":
|
||||
summary.added += 1
|
||||
else:
|
||||
summary.updated += 1
|
||||
summary.chunks += len(chunks_text)
|
||||
logger.info("import: %s source=%s path=%s chunks=%d", verb, source, rel, len(chunks_text))
|
||||
|
||||
# Phase 30 shape on the description — image-aware (task 03, LOCKED
|
||||
# A3): the description IS the summary (stored verbatim, no
|
||||
# ``lite`` call), so ``doc.summary`` == ``doc.content`` and the
|
||||
# ``is_summary`` position −1 chunk mirrors it.
|
||||
await _store_summary(
|
||||
session, doc=doc, source=source, rel=rel, content=content, llm=llm, summary=summary
|
||||
)
|
||||
|
||||
|
||||
def _prune(
|
||||
session: Session,
|
||||
source_names: set[str],
|
||||
seen: set[tuple[str, str]],
|
||||
images: bool = False,
|
||||
) -> int:
|
||||
"""Delete documents of *source_names* whose file is no longer in *seen*.
|
||||
|
||||
*images* (phase 122 prune guard, LOCKED, derived from A3/A4): while
|
||||
the image toggle is OFF (``images`` False), every ``is_image`` doc is
|
||||
SKIPPED — the image is invisible to an images-off walk, not a deleted
|
||||
file, so pruning it would silently destroy image documents on the
|
||||
first images-off sync. Toggle ON → normal semantics: a deleted image
|
||||
file prunes its doc, and the pruned image's ``image_dir`` copy is
|
||||
deleted with it.
|
||||
"""
|
||||
if not source_names:
|
||||
return 0
|
||||
pruned = 0
|
||||
docs = session.scalars(select(Document).where(Document.source.in_(source_names))).all()
|
||||
for doc in docs:
|
||||
if (doc.source, doc.path) not in seen:
|
||||
if doc.is_image and not images:
|
||||
# Prune guard: invisible to the walk, not deleted.
|
||||
continue
|
||||
_delete_image_copy(doc.image_path)
|
||||
session.delete(doc)
|
||||
pruned += 1
|
||||
logger.info("import: pruned source=%s path=%s", doc.source, doc.path)
|
||||
|
||||
+7
-3
@@ -487,11 +487,15 @@ class LLMClient:
|
||||
|
||||
Messages are passed to the request body VERBATIM: string-only
|
||||
``{role, content}`` dicts are byte-identical on the wire to the
|
||||
pre-phase-74 requests, and an assistant message may additionally
|
||||
pre-phase-74 requests, an assistant message may additionally
|
||||
carry ``reasoning_content`` (the client's prior thinking, phase
|
||||
74 — the same wire field the model uses for its OWN reasoning on
|
||||
the response side; the ``openai`` SDK passes message dicts
|
||||
through untouched, so no transport change).
|
||||
the response side), and a user message's content may be the
|
||||
multimodal parts list of a question that carried an image
|
||||
(phase 123 — ``[{type: "text", …}, {type: "image_url", …}]``,
|
||||
built by ``app.api.chat.build_user_content``); the ``openai``
|
||||
SDK passes message dicts through untouched, so no transport
|
||||
change covers all three shapes.
|
||||
|
||||
``stream=True`` against the OpenAI-compatible endpoint, yielding
|
||||
typed :class:`StreamPiece` values. Wire convention (verified live
|
||||
|
||||
@@ -92,6 +92,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Chunk, Document
|
||||
from app.schemas import SourceRef
|
||||
|
||||
#: Shared overflow marker (phase 15; imported by ``app.rag.prompts``)
|
||||
#: — used by the steering (<tuning>) section, the phase-118 NULL-summary
|
||||
@@ -939,3 +940,27 @@ def select_related(
|
||||
continue
|
||||
out.append(doc)
|
||||
return out
|
||||
|
||||
|
||||
def source_ref_with_image(doc: Document) -> SourceRef:
|
||||
"""One :class:`~app.schemas.SourceRef` wire frame for *doc* — the
|
||||
phase-122 (task 05) SHARED frame builder: the chat API's cited
|
||||
tier (the agent's read docs) and the related tier both run through
|
||||
it, so the per-doc ref shape has exactly one construction site.
|
||||
|
||||
The ref carries the chip identity (``source`` / ``path`` /
|
||||
``title``) and, ONLY for a standalone-image document (``is_image``),
|
||||
the optional ``image_url`` — the image BYTES route
|
||||
``/api/documents/<id>/image`` (the chat's sources block renders the
|
||||
compact inline image from it, the summary as alt + caption —
|
||||
"shown in the chat nicely", TODO L6). For a TEXT document the field
|
||||
stays ``None`` and is DROPPED by the model's serializer (never
|
||||
``null`` — the omission rule): a text-doc frame is byte-identical
|
||||
to pre-phase. The frame's doc id rides the path — the same way the
|
||||
document content endpoint's ``(source, path)`` lookup does (no new
|
||||
id leak beyond what the frame already carries).
|
||||
"""
|
||||
ref = SourceRef(source=doc.source, path=doc.path, title=doc.title)
|
||||
if doc.is_image:
|
||||
ref.image_url = f"/api/documents/{doc.id}/image"
|
||||
return ref
|
||||
|
||||
+143
-6
@@ -1,4 +1,5 @@
|
||||
"""Document summarizer (phase 30, task 03).
|
||||
"""Document summarizer (phase 30, task 03) + image descriptions
|
||||
(phase 122, task 03).
|
||||
|
||||
Builds the ``SUMMARY_MODE`` prompt for one document, calls the aipi
|
||||
``lite`` model through the one-shot ``LLMClient.chat`` (phase 30,
|
||||
@@ -22,18 +23,33 @@ Quality contracts enforced here:
|
||||
summarizer re-asserts defensively and never hands the importer a
|
||||
pointer-only row).
|
||||
|
||||
The ``SUMMARY_MODE`` marker follows the ``DEFLECT_MODE`` convention:
|
||||
the deterministic E2E mock LLM keys on it in the system prompt
|
||||
(``tests/e2e/mock_llm.py`` — wired in task 06).
|
||||
Image descriptions (phase 122, LOCKED A3): :func:`describe_image` is
|
||||
this module's second one-shot generation path — a SINGLE CHAT-model
|
||||
(vision) call describing one image's bytes as a base64 data URL. The
|
||||
description becomes the image document's ``content`` AND ``summary``
|
||||
(it is the ONLY embedded text of the doc — the embedding model never
|
||||
sees pixels, and the ``lite`` summary model is deliberately NOT used:
|
||||
it is not assumed vision-capable). Fail-soft by contract: any client
|
||||
error, empty reply, or non-2xx yields ``None`` — the importer skips
|
||||
the doc, counts ``images_failed``, and the sync continues.
|
||||
|
||||
The ``SUMMARY_MODE`` / ``IMAGE_DESCRIPTION_MODE`` markers follow the
|
||||
``DEFLECT_MODE`` convention: the deterministic E2E mock LLM keys on
|
||||
them (``tests/e2e/mock_llm.py`` — the image branch is wired by task
|
||||
06's story suite).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.rag.llm import LLMError
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
logger = logging.getLogger("app.summarizer")
|
||||
|
||||
#: System-prompt marker for summary generation — the E2E mock LLM keys on
|
||||
#: it (same convention as ``DEFLECT_MODE``, PLAN §6).
|
||||
SUMMARY_MODE = "SUMMARY_MODE"
|
||||
@@ -51,6 +67,67 @@ SUMMARY_INSTRUCTION = (
|
||||
#: Full system prompt: marker first (the mock's key), then the instruction.
|
||||
SYSTEM_PROMPT = f"{SUMMARY_MODE}: {SUMMARY_INSTRUCTION}"
|
||||
|
||||
#: Image-description marker (phase 122, task 03) — the deterministic
|
||||
#: E2E mock LLM keys on it (same convention as ``SUMMARY_MODE`` /
|
||||
#: ``DEFLECT_MODE``, PLAN §6; the story suite wires the mock's branch
|
||||
#: in task 06). It heads the text part of the multimodal describe
|
||||
#: message, so it rides the user message, not a system prompt.
|
||||
IMAGE_DESCRIPTION_MODE = "IMAGE_DESCRIPTION_MODE"
|
||||
|
||||
#: Locked instruction for the CHAT (vision) model (phase 122, LOCKED
|
||||
#: A3): the description is the ONLY retrievable text of the image
|
||||
#: document (the embedding model never sees pixels), so it must be a
|
||||
#: faithful, retrieval-oriented account that carries the image's full
|
||||
#: meaning — what is depicted, any visible text/labels/titles,
|
||||
#: diagram/table structure, salient details.
|
||||
DESCRIBE_INSTRUCTION = (
|
||||
"Describe this image faithfully, in plain text, for a search index. "
|
||||
"State what is depicted, transcribe any visible text, labels, or "
|
||||
"titles, describe the structure of any diagram, table, or layout, and "
|
||||
"call out the most salient details. Write 2-4 sentences of substance. "
|
||||
"Do not use markdown. Do not invent anything that is not visible in "
|
||||
"the image. Your description is the ONLY text that will ever be "
|
||||
"retrieved for this image — it must carry the image's full meaning."
|
||||
)
|
||||
|
||||
#: The full describe prompt: marker first (the mock's key), then the
|
||||
#: instruction — the single text part of the multimodal user message.
|
||||
DESCRIBE_PROMPT = f"{IMAGE_DESCRIPTION_MODE}: {DESCRIBE_INSTRUCTION}"
|
||||
|
||||
#: Extension → MIME type for the image family (phase 122). Dotted,
|
||||
#: lowercase keys — the ``image_extension_set`` shape. Task 03 uses it
|
||||
#: for the describe call's data-URL mime; task 04's serve route reuses
|
||||
#: it for the image bytes' ``Content-Type`` (one map, one truth). A
|
||||
#: ``BOR_IMAGE_EXTENSIONS`` token outside this map (a custom format)
|
||||
#: takes the :data:`IMAGE_FALLBACK_MIME` data-URL mime in the describe
|
||||
#: call — the vision endpoint may reject it, and the fail-soft skip
|
||||
#: (``images_failed``) is the honest outcome.
|
||||
IMAGE_MIMES: dict[str, str] = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
}
|
||||
|
||||
#: The data-URL mime for an image extension :data:`IMAGE_MIMES` does not
|
||||
#: name (phase 122) — the best-effort generic, never a guess at a
|
||||
#: specific type.
|
||||
IMAGE_FALLBACK_MIME = "application/octet-stream"
|
||||
|
||||
|
||||
def image_data_url(data: bytes, mime: str) -> str:
|
||||
"""One image's bytes as a data URL for a multimodal message (phase
|
||||
122; factored for phase 123, task 01).
|
||||
|
||||
``data:<mime>;base64,<ascii>`` — the OpenAI-compatible ``image_url``
|
||||
payload's ``url``. ONE helper, both call sites: :func:`describe_image`
|
||||
(document-image descriptions) and the chat question-image path
|
||||
(``app.api.chat``'s multimodal user message, phase 123).
|
||||
"""
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}"
|
||||
|
||||
|
||||
class SummaryLLM(Protocol):
|
||||
"""The one-shot chat surface the summarizer needs.
|
||||
@@ -58,12 +135,17 @@ class SummaryLLM(Protocol):
|
||||
:class:`app.rag.llm.LLMClient` satisfies it; unit tests pass a
|
||||
duck-typed fake (``chat`` + ``settings``) instead — same pattern as
|
||||
the importer's ``Embedder`` protocol.
|
||||
|
||||
``content`` may be a string (text calls — ``generate_summary``) or
|
||||
a list of OpenAI-compatible parts (phase 122 multimodal image
|
||||
descriptions — ``{type: "text", …}`` + ``{type: "image_url", …}``);
|
||||
the client passes message dicts through untouched.
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict[str, str]], model: str | None = None
|
||||
self, messages: list[dict[str, Any]], model: str | None = None
|
||||
) -> str: ...
|
||||
|
||||
|
||||
@@ -121,3 +203,58 @@ async def generate_summary(
|
||||
"refusing to store a silent summary"
|
||||
)
|
||||
return f"{summary}\nSource: {source}/{path}"
|
||||
|
||||
|
||||
async def describe_image(
|
||||
llm: SummaryLLM,
|
||||
*,
|
||||
data: bytes,
|
||||
mime: str,
|
||||
settings: Settings | None = None,
|
||||
) -> str | None:
|
||||
"""One-shot CHAT-model (vision) description of one image (phase 122,
|
||||
LOCKED A3) — the text that becomes the image document's ``content``
|
||||
AND ``summary`` (the ONLY embedded text of the doc; the embedding
|
||||
model never sees pixels).
|
||||
|
||||
ONE chat-model call against ``settings.llm_chat_model`` (the vision
|
||||
model — the ``lite`` summary model is NOT assumed vision-capable)
|
||||
with the multimodal user message the OpenAI-compatible API expects:
|
||||
``[{type: "text", text: DESCRIBE_PROMPT}, {type: "image_url",
|
||||
image_url: {url: <data URL from *data* + *mime*>}}]`` — no system
|
||||
prompt, no tools, no app-level retries beyond the client's own
|
||||
(SDK-level + the house one-shot empty-content policy) — a
|
||||
description failure must not stall a sync.
|
||||
|
||||
Returns the stripped reply cut exactly at
|
||||
``(settings or llm.settings).summary_max_chars`` (the phase-30 cap —
|
||||
the description IS the summary, so it keeps the same uniform
|
||||
ceiling). Returns ``None`` on any client error, empty reply, or
|
||||
non-2xx (the client raises :class:`LLMError` for all three classes)
|
||||
— the caller (the importer's ``_describe_or_skip`` seam) fails soft:
|
||||
the doc is skipped, counted in ``images_failed``, and the sync
|
||||
continues (LOCKED A3).
|
||||
"""
|
||||
data_url = image_data_url(data, mime)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": DESCRIBE_PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
model = llm.settings.llm_chat_model
|
||||
try:
|
||||
raw = await llm.chat(messages, model=model)
|
||||
except LLMError as e:
|
||||
logger.warning("image description failed (model=%s): %s", model, e)
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
# The client already rejects empty content; this is the
|
||||
# defensive re-assert (the duck-typed fakes may return it).
|
||||
return None
|
||||
limit = (settings or llm.settings).summary_max_chars
|
||||
return text[:limit]
|
||||
|
||||
+204
-5
@@ -1,6 +1,7 @@
|
||||
"""Pydantic request/response schemas (API contract)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
@@ -60,7 +61,8 @@ class HistoryTurn(BaseModel):
|
||||
class ChatRequest(BaseModel):
|
||||
"""``POST /api/chat`` body: the current question plus the optional
|
||||
prior turns (phase 74 — the client-provided history, stateless per
|
||||
A10).
|
||||
A10) and, since phase 123, the question's attached image (the
|
||||
stored path — :attr:`image`).
|
||||
|
||||
``history`` is the client's earlier turns, oldest first (the
|
||||
``bor.chat.v1`` record minus the current question); the mapper
|
||||
@@ -74,6 +76,30 @@ class ChatRequest(BaseModel):
|
||||
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
|
||||
#: Phase 123 (TODO L6, LOCKED A5): the STORED PATH of the question's
|
||||
#: attached image — ``/api/chat-images/<uuid4-hex>.<ext>`` exactly as
|
||||
#: ``POST /api/chat-images`` returns it. NEVER a raw data URL: the
|
||||
#: upload endpoint already did the size/mime enforcement, and
|
||||
#: re-validating a 10 MB base64 string at the schema would be the
|
||||
#: anti-pattern. ``None`` (the default, every text-only question)
|
||||
#: keeps the turn byte-identical to pre-phase-123.
|
||||
image: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("image")
|
||||
@classmethod
|
||||
def _image_is_stored_path(cls, v: str | None) -> str | None:
|
||||
"""A set ``image`` must be the upload endpoint's stored-path
|
||||
shape — ``/api/chat-images/<uuid4().hex>.<ext>`` (32 hex chars,
|
||||
the six image extensions; the pattern is pinned in the
|
||||
phase-123 design and mirrored by the serve route's filename
|
||||
guard, ``app.api.chat_images``). A data URL, a bare filename, a
|
||||
traversal, a wrong extension, or a mistyped uuid all 422 with
|
||||
ONE fixed detail — no echo of the input."""
|
||||
if v is None:
|
||||
return v
|
||||
if re.fullmatch(r"/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)", v) is None:
|
||||
raise ValueError("image must be an uploaded chat image path")
|
||||
return v
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""``POST /api/login`` body (phase 16): the single admin's password.
|
||||
@@ -106,11 +132,36 @@ class SourceRef(BaseModel):
|
||||
rows server-side, so every server-built SSE ref fits by construction
|
||||
(A3: the SSE path is provably unaffected); the cap binds only
|
||||
client-saved refs — bounded at the boundary with a 422.
|
||||
|
||||
Phase 122 (task 05): ``image_url`` — the image BYTES route
|
||||
(``/api/documents/<id>/image``) for a ref whose document is a
|
||||
standalone image: the chat's sources block renders the compact
|
||||
inline image from it (the "shown in the chat nicely" contract, TODO
|
||||
L6). It is the ONLY new frame field (the doc id rides the path —
|
||||
the same way the document content endpoint's ``(source, path)``
|
||||
lookup does). For a TEXT document the field stays ``None`` and is
|
||||
DROPPED on serialization (never ``null`` — the :class:`DocContent`
|
||||
omission precedent), so a text-doc frame is byte-identical to
|
||||
pre-phase. Server-built refs go through the shared
|
||||
:func:`app.rag.retriever.source_ref_with_image` (one shape, both
|
||||
frame tiers); a client-saved ref without the field parses with the
|
||||
``None`` default (pre-phase saved chats restore unchanged).
|
||||
"""
|
||||
|
||||
source: str = Field(max_length=120)
|
||||
path: str = Field(max_length=1000)
|
||||
title: str = Field(max_length=500)
|
||||
#: Phase 122 (task 05) — see the class docstring. ``None`` (every
|
||||
#: text doc, and every pre-phase client-saved ref) is omitted on
|
||||
#: serialization — the key is ABSENT, never ``null``.
|
||||
image_url: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
data = handler(self)
|
||||
if self.image_url is None:
|
||||
data.pop("image_url", None)
|
||||
return data
|
||||
|
||||
|
||||
class ChatThinkingEvent(BaseModel):
|
||||
@@ -275,6 +326,20 @@ class KbTreeFile(BaseModel):
|
||||
``GET /api/docs`` returns) / ``created_at`` (phase 106, D8 — the
|
||||
document's creation date) / ``indexed_at`` (ISO-8601) are verbatim
|
||||
from the catalogue row the endpoint reads.
|
||||
|
||||
Image affordance (phase 122, task 04): for an ``is_image`` file node
|
||||
the three ``is_image`` / ``image_url`` / ``summary`` keys ride the
|
||||
node (the RAG view's Path cell renders the 48px thumbnail from
|
||||
``image_url`` with ``alt = summary``). For a TEXT file node all
|
||||
three are OMITTED from the wire shape (the
|
||||
:func:`_drop_image_fields` omission rule — a pre-phase KB, which has
|
||||
no image rows, serializes byte-identically to pre-phase, and the
|
||||
RAG view reads ``is_image === true`` — it never expects the keys on
|
||||
a text node). ``image_url`` is ``None``-omitted even on an image
|
||||
node (a row whose ``image_path`` was lost renders the glyph
|
||||
fallback); ``summary`` stays ``null`` on an image node (the alt
|
||||
falls back to the title client-side — the fail-soft backfill
|
||||
corner).
|
||||
"""
|
||||
|
||||
kind: Literal["file"] = "file"
|
||||
@@ -286,6 +351,23 @@ class KbTreeFile(BaseModel):
|
||||
#: the ``Created`` column (before ``Indexed``).
|
||||
created_at: str
|
||||
indexed_at: str
|
||||
#: Phase 122 (task 04) — true iff the file is an image document
|
||||
#: (LOCKED A3). Omitted from a text node's wire shape (see the class
|
||||
#: docstring); the builder sets it only for a node whose
|
||||
#: ``(source, path)`` is in the endpoint's image-docs map.
|
||||
is_image: bool = False
|
||||
#: Phase 122 (task 04) — the image bytes route
|
||||
#: (``/api/documents/<id>/image``) for the RAG view's thumbnail;
|
||||
#: ``None`` (→ absent) when the row has no servable copy.
|
||||
image_url: str | None = None
|
||||
#: Phase 122 (task 04) — the document's summary (for an image doc,
|
||||
#: the vision description — the thumbnail's ``alt``); ``None`` for a
|
||||
#: fail-soft row still awaiting the backfill.
|
||||
summary: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
return _drop_image_fields(self, handler)
|
||||
|
||||
|
||||
class KbTreeFolder(BaseModel):
|
||||
@@ -374,8 +456,11 @@ class KbTree(BaseModel):
|
||||
98, D3): true iff its recursive document count ≥
|
||||
``MIN_DOCS_PER_FOLDER`` (1) AND it has no stored ``folder_summaries``
|
||||
row — exactly ``missing_folder_summaries``'s candidate set (the
|
||||
marker never drifts from the gap-fill); FILE nodes carry no flag
|
||||
(the file table has no description column).
|
||||
marker never drifts from the gap-fill). FILE nodes carry no pending
|
||||
flag (the file table has no description column) — but, since phase
|
||||
122 (task 04), an image FILE node carries the thumbnail affordance
|
||||
keys (``is_image`` / ``image_url`` / ``summary`` — omitted on text
|
||||
nodes, see :class:`KbTreeFile`).
|
||||
"""
|
||||
|
||||
sources: list[KbTreeSource]
|
||||
@@ -400,6 +485,27 @@ class DocContent(BaseModel):
|
||||
content: str
|
||||
indexed_at: str
|
||||
chunks: int
|
||||
#: Phase 122 (task 04) — true iff the document is a standalone
|
||||
#: image (LOCKED A3: ``content`` is the vision description, the
|
||||
#: bytes live behind :attr:`image_url`). ALWAYS present on the wire
|
||||
#: (text docs: ``false`` — the wire-additive key, the phase-106
|
||||
#: ``created_at`` pattern); the viewer renders the ``<img>`` block
|
||||
#: only when true.
|
||||
is_image: bool = False
|
||||
#: Phase 122 (task 04) — the image bytes route
|
||||
#: (``/api/documents/<id>/image``) for the viewer's ``<img>``.
|
||||
#: ABSENT from the wire for text docs (``None`` → dropped by the
|
||||
#: serializer — never ``null``, the :func:`_drop_absent_share_url`
|
||||
#: omission precedent); also absent for an image row whose
|
||||
#: ``image_path`` is NULL (the viewer's onerror fallback covers it).
|
||||
image_url: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
data = handler(self)
|
||||
if self.image_url is None:
|
||||
data.pop("image_url", None)
|
||||
return data
|
||||
|
||||
|
||||
class SummaryUpdate(BaseModel):
|
||||
@@ -583,6 +689,14 @@ class GitSourceIn(BaseModel):
|
||||
|
||||
``include_hidden`` (phase 105) is optional at create time (absent →
|
||||
stored ``False`` — A4).
|
||||
|
||||
``token`` (phase 121, LOCKED A2) is the masked private-repo
|
||||
credential from the Sources page: optional at create time (absent/
|
||||
None = no credential — public repo), trimmed *before* the length
|
||||
constraints run (the ``_trim_url`` precedent), max 500. It is a
|
||||
WRITE-ONLY field — stored in the dedicated ``git_sources.token``
|
||||
column and NEVER echoed back by any output shape (``GitSourceOut``
|
||||
/ ``GitSourceRow`` carry no token field by contract).
|
||||
"""
|
||||
|
||||
kind: Literal["git", "local"] = "git"
|
||||
@@ -590,6 +704,7 @@ class GitSourceIn(BaseModel):
|
||||
path: str | None = Field(default=None, min_length=1, max_length=2000)
|
||||
ignore_paths: list[str] | None = Field(default=None)
|
||||
include_hidden: bool | None = Field(default=None)
|
||||
token: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("url", mode="before")
|
||||
@classmethod
|
||||
@@ -601,6 +716,11 @@ class GitSourceIn(BaseModel):
|
||||
def _trim_path(cls, v: object) -> object:
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
@field_validator("token", mode="before")
|
||||
@classmethod
|
||||
def _trim_token(cls, v: object) -> object:
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
|
||||
class GitSourceOut(BaseModel):
|
||||
"""One created git source as returned by ``POST`` (phase 35, task 02).
|
||||
@@ -614,8 +734,18 @@ class GitSourceOut(BaseModel):
|
||||
list — non-null (a row created without it reports ``[]``).
|
||||
``include_hidden`` (phase 105) is the stored flag — a row created
|
||||
without it reports ``False`` (A4).
|
||||
|
||||
There is deliberately NO ``token`` field (phase 121, LOCKED A2):
|
||||
the private-repo credential is stored in the dedicated
|
||||
``git_sources.token`` column and is NEVER a response field — it
|
||||
never reaches the UI or any API output. ``extra="forbid"`` makes
|
||||
the omission a structural contract, not an accident: constructing
|
||||
this model with a ``token`` key raises, so a regression that tries
|
||||
to echo the credential back cannot even build the shape.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: uuid.UUID | None
|
||||
url: str
|
||||
added_at: datetime | None
|
||||
@@ -637,8 +767,16 @@ class GitSourceRow(BaseModel):
|
||||
store a list on) report ``[]``. ``include_hidden`` (phase 105) is
|
||||
the row's stored flag — env-fallback rows (no DB row to store a flag
|
||||
on) report ``False`` (the ``ignore_paths: []`` precedent).
|
||||
|
||||
There is deliberately NO ``token`` field (phase 121, LOCKED A2):
|
||||
same contract as :class:`GitSourceOut` — the credential never
|
||||
reaches the UI or any API output, and ``extra="forbid"`` makes the
|
||||
omission structural (constructing a row with a ``token`` key
|
||||
raises).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: uuid.UUID | None
|
||||
kind: Literal["git", "local"]
|
||||
url: str
|
||||
@@ -658,12 +796,24 @@ class GitSourcePatchIn(BaseModel):
|
||||
normalized + A4-validated, becomes the row's whole list — empty
|
||||
list clears all; every pre-phase-105 client always sends the
|
||||
list, so their behavior is byte-identical). ``include_hidden``
|
||||
(phase 105) when present sets the stored flag. Both absent →
|
||||
200 no-op (the row is untouched).
|
||||
(phase 105) when present sets the stored flag. ``token`` (phase
|
||||
121, LOCKED A2) is TRI-STATE — the three-way semantics the masked
|
||||
edit field depends on: **absent/None = no change** (keep the row's
|
||||
stored credential), **non-empty = replace**, **empty string =
|
||||
clear** (the UI offers replace; clear exists for API completeness).
|
||||
Trimmed *before* the length constraints run (the ``GitSourceIn``
|
||||
``_trim_token`` precedent — whitespace-only counts as a clear),
|
||||
max 500. All absent → 200 no-op (the row is untouched).
|
||||
"""
|
||||
|
||||
ignore_paths: list[str] | None = Field(default=None)
|
||||
include_hidden: bool | None = Field(default=None)
|
||||
token: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("token", mode="before")
|
||||
@classmethod
|
||||
def _trim_token(cls, v: object) -> object:
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
|
||||
class GitSourceList(BaseModel):
|
||||
@@ -804,6 +954,36 @@ class ChatMessage(BaseModel):
|
||||
# persisted error detail (the phase-48 ``stopped`` precedent).
|
||||
failed: bool | None = None
|
||||
error: str | None = Field(default=None, max_length=500)
|
||||
# Phase 123 (task 01, LOCKED A5): the question's attached image —
|
||||
# the STORED PATH (``/api/chat-images/<uuid>.<ext>``, from
|
||||
# ``POST /api/chat-images``), on the USER record only: the
|
||||
# attachment belongs to the question, so a brain record never
|
||||
# carries it (the answer may cite the image doc's sources, but the
|
||||
# attachment itself is the user's). The saved/shared shape gains
|
||||
# this one optional key — omitted when ``None`` (the
|
||||
# :meth:`_drop_image_when_absent` serializer below), so a text-only
|
||||
# chat round-trips byte-identically to pre-phase-123 (the
|
||||
# phase-50 contract; the phase-122 ``SourceRef.image_url``
|
||||
# omission precedent). The path is ≤ 500 chars — no phase-83
|
||||
# cap pressure (it is never a data URL, LOCKED A5).
|
||||
image: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The phase-123 image omission rule: ``image: None`` (every
|
||||
text-only record, and every pre-phase-123 record) serializes
|
||||
WITHOUT the key — ABSENT, never ``null`` — so the stored
|
||||
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
|
||||
byte-identical to pre-phase for text-only chats (the phase-50
|
||||
round-trip contract). A record WITH an image keeps the path —
|
||||
the user bubble, the refreshed page, and the shared chat all
|
||||
render it from the served route (the image is part of the
|
||||
chat's content, so it rides the same public/credential-
|
||||
is-the-id trust model)."""
|
||||
data = handler(self)
|
||||
if self.image is None:
|
||||
data.pop("image", None)
|
||||
return data
|
||||
|
||||
|
||||
class SavedChatCreate(BaseModel):
|
||||
@@ -847,6 +1027,25 @@ class SavedChatUpdate(BaseModel):
|
||||
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
def _drop_image_fields(model: KbTreeFile, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The phase-122 (task 04) image-affordance omission rule for
|
||||
:class:`KbTreeFile` file nodes: a TEXT node (``is_image`` false) drops
|
||||
ALL three image keys — a pre-phase KB (no image rows) serializes
|
||||
byte-identically to pre-phase, and the RAG view's file row stays the
|
||||
pre-phase bare-link cell. An IMAGE node keeps ``is_image`` +
|
||||
``summary`` (a ``null`` summary is meaningful — the alt falls back
|
||||
client-side) and drops ``image_url`` only when ``None`` (the
|
||||
row-without-a-copy corner — never a ``null`` on the wire, the
|
||||
:func:`_drop_absent_share_url` precedent)."""
|
||||
data = handler(model)
|
||||
if not model.is_image:
|
||||
for key in ("is_image", "image_url", "summary"):
|
||||
data.pop(key, None)
|
||||
elif data.get("image_url") is None:
|
||||
data.pop("image_url", None)
|
||||
return data
|
||||
|
||||
|
||||
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
|
||||
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
|
||||
|
||||
+338
-10
@@ -306,6 +306,14 @@ const sendBtn = document.querySelector("#send-btn");
|
||||
const sendLabel = document.querySelector("#send-label");
|
||||
const sendStatus = document.querySelector("#send-status");
|
||||
const turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner
|
||||
// Phase 123 (task 02, TODO L6): the attach control family — the
|
||||
// paperclip button (hidden until the boot /api/config says
|
||||
// `images: true`), its hidden file-input backend, the preview strip
|
||||
// (hidden until a pick), and the strip's remove button.
|
||||
const attachBtn = document.querySelector("#attach-btn");
|
||||
const attachFile = document.querySelector("#attach-file");
|
||||
const attachPreview = document.querySelector("#attach-preview");
|
||||
const attachRemove = document.querySelector("#attach-remove");
|
||||
const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
@@ -927,8 +935,17 @@ const USER_AVATAR =
|
||||
* scrolls only when the caller passes `scroll = true` — the user submit
|
||||
* (reveal my message) and the phase-14 restore landing. The streaming
|
||||
* path (thinking / tool / delta) creates bubbles with the default
|
||||
* (scroll = false): the page never follows a turn. */
|
||||
function addMessage(who, html, scroll = false) {
|
||||
* (scroll = false): the page never follows a turn.
|
||||
*
|
||||
* Phase 123 (task 02, TODO L6): the optional `image` argument —
|
||||
* { src, alt } for a USER bubble carrying the question's attached
|
||||
* image (attachedImage → the live data URL; task 03's restore → the
|
||||
* stored path). The attachment is part of the question, so the img
|
||||
* lands at the TOP of the bubble (above the text) through
|
||||
* attachBubbleImage — ONE renderer for live + restore + shared. A
|
||||
* null image (every text-only message, every brain message) leaves
|
||||
* the bubble byte-identical to pre-phase. */
|
||||
function addMessage(who, html, scroll = false, image = null) {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${who}`;
|
||||
@@ -937,11 +954,45 @@ function addMessage(who, html, scroll = false) {
|
||||
<div class="msg-body">
|
||||
<div class="bubble">${html}</div>
|
||||
</div>`;
|
||||
if (who === "user" && image) {
|
||||
attachBubbleImage(wrap.querySelector(".bubble"), image.src, image.alt);
|
||||
}
|
||||
messagesEl.appendChild(wrap);
|
||||
if (scroll) scrollReveal(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the question's image in a user bubble
|
||||
* — the ONE renderer (task 03 reuses it for the restore and the shared
|
||||
* page): the img is built createElement-style (no HTML strings, the
|
||||
* house rule), capped height + full-width safe (a tall portrait must
|
||||
* not blow the chat column — .msg-image in styles.css), lazy-loaded
|
||||
* (the restore's stored paths re-fetch on demand), alt = the
|
||||
* accessible name (the filename live, task 03's restore choice). The
|
||||
* image PREPENDS the text: the attachment is part of the question. */
|
||||
function attachBubbleImage(bubble, src, alt) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "msg-image";
|
||||
img.src = src;
|
||||
img.alt = alt || "attached image";
|
||||
img.loading = "lazy";
|
||||
// Phase 123 (task 03, TODO L6): the load failure — the STORED file
|
||||
// was deleted out-of-band (the record keeps its path, the render
|
||||
// degrades): the img is replaced IN PLACE by the small "image
|
||||
// unavailable" line (never a broken-image icon). In practice only
|
||||
// the restore's stored path can 404 (a live data URL is inline); the
|
||||
// shared page carries its own copy of the same degradation (the
|
||||
// per-page duplication house style).
|
||||
img.onerror = () => {
|
||||
const note = document.createElement("span");
|
||||
note.className = "msg-image-unavailable";
|
||||
note.textContent = "image unavailable";
|
||||
img.replaceWith(note);
|
||||
};
|
||||
bubble.prepend(img);
|
||||
return img;
|
||||
}
|
||||
|
||||
function addTyping() {
|
||||
removeTyping(); // idempotent: at most one indicator at a time
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
@@ -1268,6 +1319,26 @@ let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
|
||||
let turnAbort = null; // AbortController of the in-flight turn (null idle)
|
||||
let stoppedByUser = false; // the Stop button took this turn (not the guard)
|
||||
|
||||
/* Phase 123 (task 02, TODO L6; locked A5): the composer's ATTACHED
|
||||
* image — the { file, name, dataUrl } triple, held until send. The
|
||||
* bytes NEVER touch the chat payload: the send flow uploads the File
|
||||
* to POST /api/chat-images and the record + the request body carry the
|
||||
* returned STORED PATH (A5: never base64). The data URL feeds two
|
||||
* local things only — the preview thumbnail and the LIVE user bubble
|
||||
* (no fetch needed); the restore (task 03) re-renders from the stored
|
||||
* path instead. null = no attachment — the text-only path, byte-
|
||||
* identical to pre-phase (request body, record, bubble). */
|
||||
let attachedImage = null;
|
||||
let attachUpload = false; // phase 123: one upload at a time (double-fire guard — the upload is the first await in handleSend; a second submit mid-upload is a no-op, the first owns the send)
|
||||
|
||||
/* The six extensions the upload endpoint accepts (phase 122's image
|
||||
* set, one list — the server re-validates on the upload; this pre-check
|
||||
* only keeps a bad pick from opening a state change + a wasted
|
||||
* round-trip, and it checks the file NAME's extension: the accept
|
||||
* attribute is advisory, and a drag-pasted or renamed file can carry
|
||||
* any extension the server will 422 anyway). */
|
||||
const ATTACHABLE_IMAGE_EXTENSIONS = ["bmp", "gif", "jpeg", "jpg", "png", "webp"];
|
||||
|
||||
function stopThinkingClock() {
|
||||
if (thinkingClock) {
|
||||
clearInterval(thinkingClock);
|
||||
@@ -1526,6 +1597,10 @@ function appendSources(wrap, sources) {
|
||||
chip.textContent = label;
|
||||
chip.title = label;
|
||||
meta.appendChild(chip);
|
||||
// Phase 122 (task 05): a ref for an IMAGE document carries
|
||||
// `image_url` (the frame's only new field — omitted on text refs):
|
||||
// its chip gains the compact inline figure right after it.
|
||||
if (s.image_url) appendSourceImageFigure(meta, s);
|
||||
}
|
||||
body.appendChild(meta);
|
||||
// Accessible full path whenever the pill visually truncates.
|
||||
@@ -1575,10 +1650,82 @@ function appendRelated(wrap, related) {
|
||||
link.title = docLabel; // full path as the native tooltip (chip pattern)
|
||||
link.setAttribute("aria-label", docLabel); // the accessible name is the full path
|
||||
row.appendChild(link);
|
||||
// Phase 122 (task 05): the related tier rides the same ref shape —
|
||||
// an image doc's ref carries image_url and gets the same figure.
|
||||
if (s.image_url) appendSourceImageFigure(row, s);
|
||||
}
|
||||
body.appendChild(row);
|
||||
}
|
||||
|
||||
/* Phase 122 (task 05): the chat's sources block shows a retrieved
|
||||
* IMAGE document "nicely" (TODO L6): the ref's chip gains a COMPACT
|
||||
* INLINE FIGURE right after it. The chip keeps its text and its
|
||||
* affordance — the figure is ADDITIVE, never a replacement — and the
|
||||
* figure reuses the chip's navigation (the same documentUrl href, the
|
||||
* /document.html escape hatch; the same left-click → same-page modal,
|
||||
* phase 26). alt + the VISIBLE caption = the document's SUMMARY (the
|
||||
* vision description — the WCAG alt contract): the frame carries no
|
||||
* summary (image_url is the only new frame field), so the figure
|
||||
* fetches the content endpoint the chip's modal already uses (the
|
||||
* (source, path) pair is the same lookup key) and swaps the summary
|
||||
* in; until it settles — and when it fails — the title stands in (a
|
||||
* caption is ALWAYS visible). A FAILED IMAGE LOAD collapses the figure
|
||||
* to the plain chip (the figure is removed — never a broken-image
|
||||
* icon: the row's bytes route 404s when the copy was lost). */
|
||||
function appendSourceImageFigure(meta, s) {
|
||||
const label = `${s.source}/${s.path}`;
|
||||
const fig = document.createElement("a");
|
||||
fig.className = "source-image";
|
||||
fig.setAttribute("role", "listitem");
|
||||
fig.href = documentUrl(s.source, s.path, "/"); // back → the chat page
|
||||
fig.title = label; // full path as the native tooltip (chip pattern)
|
||||
fig.setAttribute("aria-label", label); // the accessible name is the full path
|
||||
fig.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(s.source, s.path, fig);
|
||||
});
|
||||
const img = document.createElement("img");
|
||||
img.className = "source-image-img";
|
||||
img.src = s.image_url;
|
||||
img.alt = s.title || label; // the summary arrives via the fetch below
|
||||
img.addEventListener("error", () => {
|
||||
// The plain chip stays (it is the collapse target) — the figure,
|
||||
// with its not-yet-resolved alt, goes.
|
||||
fig.remove();
|
||||
});
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "source-image-caption";
|
||||
caption.textContent = s.title || label; // visible caption: the title first…
|
||||
fig.append(img, caption);
|
||||
meta.appendChild(fig);
|
||||
// …and the document's summary once the content fetch settles.
|
||||
fetchContentSummary(s.source, s.path).then((summary) => {
|
||||
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
|
||||
if (summary && summary.trim() !== "") {
|
||||
img.alt = summary;
|
||||
caption.textContent = summary;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* The document content endpoint the chip's modal already boots
|
||||
* against (the (source, path) lookup key the ref carries) — phase 122
|
||||
* (task 05) asks it ONLY for the image figure's summary (alt +
|
||||
* caption). Any failure (404, network, bad body) resolves to null —
|
||||
* the title fallback stands and the figure is unaffected. */
|
||||
function fetchContentSummary(source, path) {
|
||||
const url =
|
||||
"/api/documents/content?source=" +
|
||||
encodeURIComponent(source) +
|
||||
"&path=" +
|
||||
encodeURIComponent(path);
|
||||
return fetch(url)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null)
|
||||
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
|
||||
}
|
||||
|
||||
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
|
||||
shared component + one-tap submit, phase 05). The group is accessible
|
||||
(role=list + aria-label) and wraps cleanly at every width. */
|
||||
@@ -1712,7 +1859,22 @@ function renderStoredMessage(m) {
|
||||
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
|
||||
// instead of the old forced "auto" — noted per the phase-42 task.
|
||||
if (m.who === "user") {
|
||||
addMessage("user", renderMarkdown(m.text), true);
|
||||
const wrap = addMessage("user", renderMarkdown(m.text), true);
|
||||
// Phase 123 (task 03, TODO L6): the restored record may carry the
|
||||
// question's attached image — `m.image`, the STORED PATH (A5:
|
||||
// never base64; a pre-phase / text-only record has no key at all,
|
||||
// so it renders byte-identically — no img). It lands through the
|
||||
// SAME one bubble-image renderer the live send uses
|
||||
// (attachBubbleImage — the live bubble passed the data URL, the
|
||||
// restore passes the stored path; the helper takes any src): the
|
||||
// img at the top of the user bubble, above the text. A load
|
||||
// failure degrades inside the helper (deleted out-of-band file →
|
||||
// the small "image unavailable" line, never a broken icon). A
|
||||
// re-ask (retryLastTurn) re-sends prev.text only (locked A7) —
|
||||
// this bubble's restored attachment is untouched by the redo.
|
||||
if (typeof m.image === "string" && m.image) {
|
||||
attachBubbleImage(wrap.querySelector(".bubble"), m.image, m.text || "attached image");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||
@@ -2279,6 +2441,7 @@ function startNewChat() {
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
updateCharCount(); // phase 104: the cleared composer hides the counter again
|
||||
clearAttachedImage(); // phase 123: the attachment is composer draft state — it resets with the conversation
|
||||
input.focus();
|
||||
sendStatus.textContent = "New chat started — previous conversation cleared.";
|
||||
}
|
||||
@@ -2384,6 +2547,70 @@ function retryLastTurn(wrap) {
|
||||
return runTurn(text, { reask: true });
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the send flow's UPLOAD STEP (locked
|
||||
* A8) — the attached File goes to POST /api/chat-images as multipart
|
||||
* (the session cookie rides the browser; the endpoint is user-gated
|
||||
* like the turn it feeds) and the returned STORED path is what the
|
||||
* record + the /api/chat body carry (A5: never base64). ANY failure —
|
||||
* 413 over the cap, 422 a bad extension (a renamed file the client
|
||||
* pre-check missed), 5xx, or a network drop — settles the
|
||||
* phase-114-style OUT-OF-TURN banner with the server's detail and
|
||||
* returns null: the send is BLOCKED (the question is never sent without
|
||||
* the image the user attached — the typed text stays, the attachment
|
||||
* stays for the retry). */
|
||||
async function uploadAttachedImage(file) {
|
||||
let res;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
res = await fetch("/api/chat-images", { method: "POST", body: form });
|
||||
} catch {
|
||||
// Network drop before the server answered — no detail to show.
|
||||
showErrorBanner("Couldn't attach the image — try again.");
|
||||
return null;
|
||||
}
|
||||
let detail = "";
|
||||
let path = null;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (typeof body?.detail === "string") detail = body.detail;
|
||||
if (typeof body?.path === "string") path = body.path;
|
||||
} catch { /* non-JSON error body — the status line stands in */ }
|
||||
if (!res.ok || !path) {
|
||||
showErrorBanner(
|
||||
detail
|
||||
? `Couldn't attach the image — ${detail}.`
|
||||
: "Couldn't attach the image — try again."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the preview strip — revealed with the
|
||||
* attached image's data-URL thumbnail + filename (the thumbnail is
|
||||
* decorative, alt="" in the static markup — the filename beside it is
|
||||
* the readable label), and cleared with the attachment (the remove button,
|
||||
* the send, or a New chat). The strip's markup is static (hidden by
|
||||
* default); only the thumbnail's src + the name's textContent move
|
||||
* here (the createElement/textContent house rule — no HTML strings).
|
||||
* Idempotent: a fresh pick re-renders the same strip in place. */
|
||||
function showAttachPreview() {
|
||||
if (!attachedImage || !attachPreview) return;
|
||||
attachPreview.querySelector("img").src = attachedImage.dataUrl;
|
||||
attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;
|
||||
attachPreview.hidden = false;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): clear the attachment + hide the strip
|
||||
* (idempotent — calling it with nothing attached is a no-op). The
|
||||
* strip must not linger into a turn, and a New chat resets the
|
||||
* composer's draft (the question text + its attachment) together. */
|
||||
function clearAttachedImage() {
|
||||
attachedImage = null;
|
||||
if (attachPreview) attachPreview.hidden = true;
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
// Phase 48: while a turn is in flight the Send button IS the Stop
|
||||
@@ -2403,6 +2630,26 @@ async function handleSend(e) {
|
||||
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
|
||||
return;
|
||||
}
|
||||
// Phase 123 (task 02, TODO L6; locked A8): an attached image uploads
|
||||
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
|
||||
// send and the typed question stays exactly where the user left it
|
||||
// (the question is never sent without the image the user attached;
|
||||
// the banner says what failed, the attachment stays for the retry).
|
||||
// The returned STORED path (never the bytes, A5) then rides runTurn
|
||||
// into the record + the request body.
|
||||
let image = null; // { path, src, alt } | null — null = text-only send
|
||||
if (attachedImage) {
|
||||
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
|
||||
attachUpload = true;
|
||||
const path = await uploadAttachedImage(attachedImage.file);
|
||||
attachUpload = false; // the helper never throws (every failure path is a banner + null)
|
||||
if (path === null) return; // A8: the send is blocked — the question stays
|
||||
image = {
|
||||
path, // the record + the /api/chat body (A5: the path, never base64)
|
||||
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
|
||||
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
|
||||
};
|
||||
}
|
||||
// Phase 49: the user append + persistence save point 1 moved into
|
||||
// runTurn with the rest of the turn — the `reask` flag skips them on
|
||||
// the redo-in-place retry path (the question is already in the DOM +
|
||||
@@ -2411,7 +2658,7 @@ async function handleSend(e) {
|
||||
autoGrow();
|
||||
updateCharCount(); // phase 104: the sent question clears the counter with the input
|
||||
clearErrorBanner();
|
||||
await runTurn(text, { reask: false });
|
||||
await runTurn(text, { reask: false, image });
|
||||
}
|
||||
|
||||
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
|
||||
@@ -2485,17 +2732,38 @@ function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIn
|
||||
* finally settle moved here verbatim, and the turn-local resets (acc,
|
||||
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
|
||||
* stay turn-scoped exactly as phase 48 left them. */
|
||||
async function runTurn(text, { reask = false } = {}) {
|
||||
async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
if (!reask) {
|
||||
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
|
||||
// Phase 123 (task 02, TODO L6): the attached image rides the user
|
||||
// bubble (the data URL live — the stored path is the fallback, so
|
||||
// any future caller passing only a path still renders) and the
|
||||
// STORED RECORD (the path, A5: never base64). A null image (every
|
||||
// text-only send, every re-ask — A7: a redo re-sends the text
|
||||
// only) leaves the bubble and the record byte-identical to
|
||||
// pre-phase.
|
||||
addMessage(
|
||||
"user",
|
||||
renderMarkdown(text),
|
||||
true, // reveal my message (owner-kept)
|
||||
image ? { src: image.src || image.path, alt: image.alt } : null
|
||||
);
|
||||
// Persistence save point 1: the question is stored the moment it is
|
||||
// sent, so a failed/interrupted turn never loses it.
|
||||
conversation.push({ who: "user", text });
|
||||
// sent, so a failed/interrupted turn never loses it. The `image`
|
||||
// key (the stored path) joins the `bor.chat.v1` record only when an
|
||||
// attachment exists (A5 — the phase-14 shape gains one optional key;
|
||||
// a text-only record is byte-identical to pre-phase).
|
||||
conversation.push(
|
||||
image ? { who: "user", text, image: image.path } : { who: "user", text }
|
||||
);
|
||||
saveConversation();
|
||||
// Phase 55 (A2): the auto-save rides the save point — an unlinked
|
||||
// conversation creates its row here (auto-title, server-side), a
|
||||
// linked one refreshes. Fire-and-forget: it never blocks the turn.
|
||||
persistConversation();
|
||||
// Phase 123 (task 02): the strip must not linger into the turn —
|
||||
// cleared AFTER the bubble is rendered (the bubble already holds
|
||||
// the image; the record holds the path; a failed turn keeps both).
|
||||
clearAttachedImage();
|
||||
}
|
||||
|
||||
let wrap = null;
|
||||
@@ -2556,10 +2824,19 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
text: m.text,
|
||||
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
|
||||
}));
|
||||
// Phase 123 (task 02, TODO L6): the attached image's STORED path
|
||||
// rides the body top-level (A5: the path, never base64; the server
|
||||
// builds the multimodal content from the stored bytes). Added only
|
||||
// when present — a text-only body omits the `image` key entirely
|
||||
// (byte-identical to pre-phase). HISTORY entries stay {who, text,
|
||||
// thinking}: prior turns' images are never replayed (locked A7 —
|
||||
// the image is turn-local to the original send).
|
||||
const payload = { message: text, history };
|
||||
if (image) payload.image = image.path;
|
||||
res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
body: JSON.stringify(payload),
|
||||
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
@@ -2932,6 +3209,48 @@ input.addEventListener("keydown", (e) => {
|
||||
});
|
||||
composer.addEventListener("submit", handleSend);
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the attach flow. The button (revealed
|
||||
* at boot ONLY when the config flag says images on) opens the hidden
|
||||
* file input; a pick is validated CLIENT-side against the six
|
||||
* extensions (the server re-validates on upload — a bad pick gets the
|
||||
* out-of-turn banner and NO state change: a previous attachment, if
|
||||
* any, survives), then the { file, name, dataUrl } triple lives in
|
||||
* attachedImage until the send flow uploads it. The remove button clears
|
||||
* the state + hides the strip; a fresh pick replaces the triple in
|
||||
* place (the strip re-renders through showAttachPreview). */
|
||||
attachBtn?.addEventListener("click", () => attachFile?.click());
|
||||
attachRemove?.addEventListener("click", () => {
|
||||
clearAttachedImage();
|
||||
attachBtn?.focus(); // back to the trigger (reachable only while the strip is visible — which means the button is too)
|
||||
});
|
||||
attachFile?.addEventListener("change", () => {
|
||||
const file = attachFile.files && attachFile.files[0];
|
||||
attachFile.value = ""; // the same file re-picked must fire change again
|
||||
if (!file) return;
|
||||
// The file NAME's extension (lowercased) is the client pre-check —
|
||||
// the accept attribute is advisory (a renamed file can carry any
|
||||
// extension); the upload endpoint is the authority (422).
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : "";
|
||||
if (!ATTACHABLE_IMAGE_EXTENSIONS.includes(ext)) {
|
||||
showErrorBanner(
|
||||
"Only PNG, JPEG, WebP, GIF, and BMP images can be attached."
|
||||
);
|
||||
return; // no state change — a previous attachment survives
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
attachedImage = { file, name: file.name, dataUrl: String(reader.result) };
|
||||
showAttachPreview();
|
||||
};
|
||||
reader.onerror = () => {
|
||||
// The bytes never became readable (the file evicted mid-pick) —
|
||||
// the same copy as an upload failure; no state change.
|
||||
showErrorBanner("Couldn't attach the image — try again.");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
|
||||
* GONE with the pill — there is no Save control; persistConversation()
|
||||
* auto-saves headless at the save points (fire-and-forget, quiet on
|
||||
@@ -3010,8 +3329,17 @@ window.addEventListener("pagehide", () => {
|
||||
// proven), so a restored conversation of a configured admin gets the
|
||||
// "Save as doc" button exactly once: no flash, no re-render, no
|
||||
// second fetch (the brand fetch IS the config fetch).
|
||||
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
|
||||
const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
|
||||
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
|
||||
// Phase 123 (task 02, TODO L6): the attach control reveals ONLY when
|
||||
// the SAME settled config says images: true (the brand boot's ONE
|
||||
// /api/config request — no second round-trip). Off (the default) or
|
||||
// an unanswered fetch → the button stays hidden for good: the
|
||||
// flag-off DOM is byte-identical to pre-phase (A5's default-off
|
||||
// contract), and a degraded boot degrades quietly (the loadHealth
|
||||
// house style — the page never breaks, the affordance is simply
|
||||
// absent; the API contract still enforces the toggle server-side).
|
||||
if (attachBtn) attachBtn.hidden = bootConfig?.images !== true;
|
||||
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||
// Phase 55 (task 03): no Share-reveal step — the pill is static,
|
||||
// always-visible markup (visible to every visitor, phase 51 contract).
|
||||
|
||||
@@ -102,6 +102,22 @@
|
||||
* call — the phase-57 split). ONE shared core — the modal and
|
||||
* /document.html both get it (document-modal.js imports
|
||||
* renderDocument from this module — no per-surface copy).
|
||||
*
|
||||
* Phase 122 (task 04): image documents — when doc.is_image is true,
|
||||
* #doc-content renders the persistent bytes FIRST (the <img> block
|
||||
* from doc.image_url — the /api/documents/{id}/image route — alt =
|
||||
* the summary, the WCAG alt contract; a NULL summary falls back to
|
||||
* the title), and the description (doc.content — the ONLY readable
|
||||
* text of the doc) follows in the EXISTING plain-content slot below
|
||||
* (the .doc-raw path; a description is prose, not markdown). The
|
||||
* labeled Summary panel is suppressed for the verbatim-description
|
||||
* case (summary === content — the importer invariant; the panel
|
||||
* would duplicate the text right below the image); an admin-edited
|
||||
* summary (different text) still renders in its panel with the
|
||||
* phase-57 edit affordance. An <img> load failure (the route's 404 —
|
||||
* the row exists but the copy was lost) swaps the block for a small
|
||||
* "Image unavailable" note (role=status): the page still shows the
|
||||
* description. ONE shared core — page + modal both get it.
|
||||
*/
|
||||
|
||||
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
@@ -189,12 +205,25 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
});
|
||||
|
||||
contentEl.replaceChildren();
|
||||
// Phase 122 (task 04): an image document — the persistent bytes
|
||||
// render as the <img> block FIRST; the description (= doc.content)
|
||||
// follows in the normal content slot below (the plain-content path
|
||||
// — the last branch in this function).
|
||||
if (doc.is_image) {
|
||||
contentEl.appendChild(docImageBlock(doc));
|
||||
}
|
||||
// Phase 36: the summary panel — labeled section ABOVE the original
|
||||
// content, on BOTH surfaces (page + modal) through this one core.
|
||||
// Only a non-empty summary renders: markdown docs carry none (phase
|
||||
// 30) and the fail-soft path leaves summary NULL, so both are
|
||||
// byte-for-byte unchanged here.
|
||||
if (doc.summary && doc.summary.trim() !== "") {
|
||||
// byte-for-byte unchanged here. Phase 122 (task 04): for an IMAGE
|
||||
// doc whose summary IS the verbatim description (summary ===
|
||||
// content — the importer invariant), the panel would duplicate the
|
||||
// text right below the image, so it is suppressed; an admin-edited
|
||||
// summary (different text) still renders with the phase-57 edit
|
||||
// affordance.
|
||||
const imageSummaryIsContent = doc.is_image && doc.summary === doc.content;
|
||||
if (doc.summary && doc.summary.trim() !== "" && !imageSummaryIsContent) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "doc-summary";
|
||||
section.setAttribute("aria-label", "Summary");
|
||||
@@ -228,6 +257,40 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- image block (phase 122, task 04) ----------
|
||||
* The viewer's image block for an ``is_image`` document: the
|
||||
* document's PERSISTENT bytes (doc.image_url — the
|
||||
* /api/documents/{id}/image route) as a block <img> (max-width 100%;
|
||||
* the theme's surface treatment lives in .doc-image). alt = the
|
||||
* summary (the vision description — the WCAG alt contract everywhere);
|
||||
* a NULL summary (the fail-soft backfill corner) falls back to the
|
||||
* title. On a load failure (the route's 404 — the row exists but the
|
||||
* copy was lost) the block shows a small "Image unavailable."
|
||||
* note (role=status) in its place; the description in the content
|
||||
* slot below still renders (the doc is still readable). Properties
|
||||
* only (src, alt) — every document-derived value is a text node /
|
||||
* property, never innerHTML (the XSS contract, unchanged). */
|
||||
function docImageBlock(doc) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "doc-image";
|
||||
const img = document.createElement("img");
|
||||
img.className = "doc-image-img";
|
||||
img.src = doc.image_url;
|
||||
img.alt =
|
||||
typeof doc.summary === "string" && doc.summary.trim() !== ""
|
||||
? doc.summary
|
||||
: doc.title;
|
||||
img.addEventListener("error", () => {
|
||||
const note = document.createElement("p");
|
||||
note.className = "doc-image-unavailable";
|
||||
note.setAttribute("role", "status");
|
||||
note.textContent = "Image unavailable — the file is missing.";
|
||||
wrap.replaceChildren(note);
|
||||
});
|
||||
wrap.appendChild(img);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
|
||||
* The .doc-summary panel is the ONE place the stored summary is edited
|
||||
* (page + modal through this core). Only an admin (docAdminReady) ever
|
||||
|
||||
@@ -203,6 +203,25 @@
|
||||
* calls hideHiddenError — the phase-89 "happy path heals the error
|
||||
* state" precedent).
|
||||
*
|
||||
* Phase 121 (task 03) — the masked token field (LOCKED A2): the add
|
||||
* form gains the optional `#git-source-token` (type=password,
|
||||
* autocomplete=off — a PAT is not a site credential) with the visible
|
||||
* "optional — private repos" hint; the submit body is
|
||||
* `(url, token) => ({ url, ...(token ? { token } : {}) })` — a blank
|
||||
* token OMITS the key (None = no credential), and 201 clears BOTH
|
||||
* inputs (the credential is stored — write-only: the API shapes
|
||||
* carry no token field, so nothing round-trips). The per-row editor
|
||||
* (the ignore-paths dialog) mirrors it: `#ignore-editor-token` with
|
||||
* the placeholder "leave blank to keep the current token" — it
|
||||
* always opens BLANK (there is no token field to prefill from) and
|
||||
* the PATCH body includes `token` ONLY when non-blank (the tri-state:
|
||||
* absent = no change, the row's stored credential is kept). Every
|
||||
* display site keeps rendering `s.url` UNCHANGED — the server now
|
||||
* returns bare URLs (sanitize_url), so the list cell, its title
|
||||
* attribute, the remove modal, and the editor's source line are
|
||||
* token-free with no per-site change; the UI must never re-embed a
|
||||
* credential.
|
||||
*
|
||||
* Scope boundary (phase locked decisions): adding a git repo does
|
||||
* NOT clone — the sync service (server-side) does that. Removing a
|
||||
* source, however, performs the FULL cleanup server-side (phase 69):
|
||||
@@ -239,6 +258,9 @@ export async function mount(root) {
|
||||
const contentEl = root.querySelector("#git-sources-content");
|
||||
const formEl = root.querySelector("#git-source-form");
|
||||
const urlInput = root.querySelector("#git-source-url");
|
||||
/* Phase 121: the add form's optional masked token field — blank =
|
||||
no credential (the key is omitted from the POST body). */
|
||||
const tokenInput = root.querySelector("#git-source-token");
|
||||
const addBtn = root.querySelector("#git-source-add");
|
||||
const addError = root.querySelector("#git-source-error");
|
||||
/* Phase 49: the archive upload form (replaces the phase-38 local
|
||||
@@ -275,6 +297,9 @@ export async function mount(root) {
|
||||
const ignoreBackdrop = root.querySelector(".ignore-editor-backdrop");
|
||||
const ignoreSourceEl = root.querySelector("#ignore-editor-source");
|
||||
const ignoreTextarea = root.querySelector("#ignore-editor-textarea");
|
||||
/* Phase 121: the editor's masked token field — blank = keep the
|
||||
current token (the PATCH omits the key, the tri-state no-change). */
|
||||
const ignoreTokenInput = root.querySelector("#ignore-editor-token");
|
||||
const ignoreErrorEl = root.querySelector("#ignore-editor-error");
|
||||
const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel");
|
||||
const ignoreSaveBtn = root.querySelector("#ignore-editor-save");
|
||||
@@ -392,6 +417,7 @@ export async function mount(root) {
|
||||
if (s.id) tr.dataset.id = s.id;
|
||||
|
||||
const isLocal = s.kind === "local";
|
||||
// Phase 121: URLs arrive sanitized server-side — the UI must never re-embed a credential.
|
||||
const value = isLocal ? (s.path ?? s.url) : s.url;
|
||||
const kindLabel = isLocal ? "local" : "git";
|
||||
|
||||
@@ -696,6 +722,10 @@ export async function mount(root) {
|
||||
// The same `value` expression makeRow uses — textContent ONLY.
|
||||
if (ignoreSourceEl) ignoreSourceEl.textContent = isLocal ? s.path ?? s.url : s.url;
|
||||
ignoreTextarea.value = (s.ignore_paths || []).join("\n");
|
||||
// Phase 121: the token field always opens BLANK — the API has no
|
||||
// token field to prefill from (LOCKED A2); blank = the PATCH
|
||||
// omits the key (no change — the stored token is kept).
|
||||
if (ignoreTokenInput) ignoreTokenInput.value = "";
|
||||
if (ignoreErrorEl) {
|
||||
ignoreErrorEl.textContent = "";
|
||||
ignoreErrorEl.hidden = true; // a new attempt starts clean
|
||||
@@ -719,6 +749,7 @@ export async function mount(root) {
|
||||
ignoreDialog.hidden = true;
|
||||
ignoreInFlight = false;
|
||||
if (ignoreTextarea) ignoreTextarea.value = "";
|
||||
if (ignoreTokenInput) ignoreTokenInput.value = ""; // phase 121: re-opens blank
|
||||
if (ignoreErrorEl) {
|
||||
ignoreErrorEl.textContent = "";
|
||||
ignoreErrorEl.hidden = true;
|
||||
@@ -759,6 +790,10 @@ export async function mount(root) {
|
||||
// separator, not an entry (trim + drop empty; the server still
|
||||
// rejects empties defensively, A4).
|
||||
const lines = ignoreTextarea.value.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
// Phase 121: the masked token — BLANK = the key is omitted from
|
||||
// the PATCH (the tri-state: absent = no change, the row's stored
|
||||
// credential is kept); non-blank replaces it.
|
||||
const token = ignoreTokenInput ? ignoreTokenInput.value.trim() : "";
|
||||
const t = ignoreTarget;
|
||||
const value = t.kind === "local" ? (t.path ?? t.url) : t.url;
|
||||
ignoreInFlight = true;
|
||||
@@ -775,7 +810,7 @@ export async function mount(root) {
|
||||
const r = await fetch(`/api/git-sources/${encodeURIComponent(t.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ignore_paths: lines }),
|
||||
body: JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) }),
|
||||
});
|
||||
if (r.ok) {
|
||||
// 200: the server replaced the row's list (A5).
|
||||
@@ -882,7 +917,7 @@ export async function mount(root) {
|
||||
* 409/422 details are fixed generic strings (credential safety — the
|
||||
* URL is never echoed). */
|
||||
function wireAddForm(opts) {
|
||||
const { form, input, btn, error } = opts;
|
||||
const { form, input, btn, error, tokenInput } = opts;
|
||||
if (!form || !input || !btn) return;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -900,10 +935,13 @@ export async function mount(root) {
|
||||
btn.disabled = true; // §7.4: one POST per click
|
||||
btn.textContent = "Adding…";
|
||||
try {
|
||||
// Phase 121: the masked token rides the same POST — blank =
|
||||
// the key is omitted (None = no credential; LOCKED A2).
|
||||
const token = tokenInput ? tokenInput.value.trim() : "";
|
||||
const r = await fetch("/api/git-sources", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(opts.body(value)),
|
||||
body: JSON.stringify(opts.body(value, token)),
|
||||
});
|
||||
if (r.ok) {
|
||||
let createdId = null;
|
||||
@@ -913,6 +951,7 @@ export async function mount(root) {
|
||||
/* the 201 body is advisory — the reload is the truth */
|
||||
}
|
||||
input.value = ""; // 201: the source is stored
|
||||
if (tokenInput) tokenInput.value = ""; // the credential is stored (write-only)
|
||||
announce(opts.addedMessage);
|
||||
await loadSources(); // the new row lands in the table
|
||||
focusNewRow(createdId); // a11y: land the caret on the new row
|
||||
@@ -940,9 +979,12 @@ export async function mount(root) {
|
||||
wireAddForm({
|
||||
form: formEl,
|
||||
input: urlInput,
|
||||
tokenInput,
|
||||
btn: addBtn,
|
||||
error: addError,
|
||||
body: (url) => ({ url }),
|
||||
// Phase 121: the masked token is included ONLY when non-blank
|
||||
// (blank = key omitted = no credential — the API's tri-state).
|
||||
body: (url, token) => ({ url, ...(token ? { token } : {}) }),
|
||||
emptyMessage: "Enter a git URL to add.",
|
||||
failMessage: "Could not add the git source — try again.",
|
||||
networkMessage: "Could not add the git source — is the app reachable?",
|
||||
|
||||
@@ -318,9 +318,42 @@ function addStoppedNote(wrap) {
|
||||
meta.appendChild(note);
|
||||
}
|
||||
|
||||
/* The question's attached image (phase 123, task 03, TODO L6) — the
|
||||
* local copy of the chat page's attachBubbleImage (the per-page
|
||||
* duplication house style: this file keeps its own small copies of
|
||||
* the chat page's message-fragment builders). The SAME .msg-image
|
||||
* treatment the chat page uses — styles.css is shared by both pages,
|
||||
* so the rule needs no second copy: the img at the TOP of the user
|
||||
* bubble (the attachment is part of the question), lazy, alt = the
|
||||
* record's text or the fallback. The load failure degrades IDENTICALLY
|
||||
* to the chat page: the img is replaced by the small "image
|
||||
* unavailable" line (the stored file was deleted out-of-band — the
|
||||
* record keeps its path, the render degrades; never a broken icon).
|
||||
* The serve route is public (the token is the shared chat's
|
||||
* credential, like saved-chat content), so the img loads for guests
|
||||
* exactly as it does for the owner. */
|
||||
function addBubbleImage(wrap, src, alt) {
|
||||
const bubble = wrap?.querySelector?.(".bubble");
|
||||
if (!bubble) return;
|
||||
const img = document.createElement("img");
|
||||
img.className = "msg-image";
|
||||
img.src = src;
|
||||
img.alt = alt || "attached image";
|
||||
img.loading = "lazy";
|
||||
img.onerror = () => {
|
||||
const note = document.createElement("span");
|
||||
note.className = "msg-image-unavailable";
|
||||
note.textContent = "image unavailable";
|
||||
img.replaceWith(note);
|
||||
};
|
||||
bubble.prepend(img);
|
||||
}
|
||||
|
||||
/* One stored record through the SAME .msg structure the chat page
|
||||
* uses (pixel-parity with the chat page's restore path): user → the
|
||||
* .msg.user bubble; brain → the .msg.brain bubble with the optional
|
||||
* .msg.user bubble (with the question's attached image when the
|
||||
* record carries the stored path — phase 123, task 03); brain → the
|
||||
* .msg.brain bubble with the optional
|
||||
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||
* plain-text source chips, and the stopped note. NO interactive
|
||||
@@ -331,7 +364,16 @@ function addStoppedNote(wrap) {
|
||||
* unchanged. */
|
||||
function renderSharedMessage(m) {
|
||||
if (m.who === "user") {
|
||||
addSharedMessage("user", renderMarkdown(m.text));
|
||||
const wrap = addSharedMessage("user", renderMarkdown(m.text));
|
||||
// Phase 123 (task 03, TODO L6): the question's attached image —
|
||||
// the record's `m.image` carries the STORED PATH (A5: never
|
||||
// base64; a pre-phase record has no key at all, so it renders
|
||||
// byte-identically — no img). The public image route makes the
|
||||
// shared view faithful: the SAME bubble treatment, alt, and
|
||||
// load-failure degradation as the chat page's restore.
|
||||
if (typeof m.image === "string" && m.image) {
|
||||
addBubbleImage(wrap, m.image, m.text || "attached image");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
|
||||
@@ -285,6 +285,21 @@
|
||||
* • the stat cards are UNTOUCHED — they keep their indexed_at
|
||||
* "last indexed" semantics (the owner asked for the column, not
|
||||
* the cards).
|
||||
*
|
||||
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
|
||||
* tree carries the image affordance (is_image / image_url / summary —
|
||||
* OMITTED on text nodes, the wire-additive rule) when it is an image
|
||||
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
|
||||
* box (object-fit: cover, loading="lazy", alt = the summary — the
|
||||
* vision description; a NULL summary falls back to the title) BEFORE
|
||||
* the path link (the .kb-doc-path flex wrapper — the link keeps its
|
||||
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
|
||||
* first paint) swaps in the document glyph INSIDE the same fixed box
|
||||
* (no layout shift beyond the box, no broken-image placeholder). Text
|
||||
* rows never get a box (the pre-phase bare-link cell, byte-identical).
|
||||
* The glyph is static SVG (aria-hidden — the alt text is the
|
||||
* accessible content); the box + img are properties only, never
|
||||
* innerHTML with document-derived data (the house rule).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -1391,12 +1406,78 @@ export async function mount(root) {
|
||||
chunks: f.chunks,
|
||||
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
|
||||
indexed_at: f.indexed_at,
|
||||
// Phase 122 (task 04): the image affordance — the tree's
|
||||
// file node carries is_image / image_url / summary on an
|
||||
// image doc (the omission rule: a TEXT node's wire shape
|
||||
// carries none, so these stay undefined there and makeRow
|
||||
// keeps the bare-link cell, byte-identical to pre-phase).
|
||||
is_image: f.is_image,
|
||||
image_url: f.image_url,
|
||||
summary: f.summary,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (tableWrap) tableWrap.hidden = files.length === 0;
|
||||
}
|
||||
|
||||
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
|
||||
* fixed thumbnail box (a failed fetch, or a node with no servable
|
||||
* image_url). Static SVG, aria-hidden (the img's alt is the
|
||||
* accessible content; this is decoration for the box). Built with
|
||||
* createElementNS — the module keeps its ONE innerHTML (the static
|
||||
* sync-modal skeleton, the test_kb_tree_ui pin). */
|
||||
function docThumbGlyph() {
|
||||
const span = document.createElement("span");
|
||||
span.className = "kb-doc-thumb-glyph";
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 48 48");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2.4");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
const sheet = document.createElementNS(NS, "path");
|
||||
sheet.setAttribute(
|
||||
"d",
|
||||
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
|
||||
);
|
||||
const fold = document.createElementNS(NS, "path");
|
||||
fold.setAttribute("d", "M28 4v8h8");
|
||||
svg.append(sheet, fold);
|
||||
span.appendChild(svg);
|
||||
return span;
|
||||
}
|
||||
|
||||
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
|
||||
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
|
||||
* the vision description; a NULL/blank summary falls back to the
|
||||
* title, then the path). A failed fetch swaps in the document
|
||||
* glyph in the SAME box (progressive enhancement — no layout shift
|
||||
* beyond the fixed box, no broken-image placeholder). Called only
|
||||
* for image rows (makeRow gates on d.is_image). */
|
||||
function docThumb(d) {
|
||||
const box = document.createElement("span");
|
||||
box.className = "kb-doc-thumb";
|
||||
const alt =
|
||||
typeof d.summary === "string" && d.summary.trim() !== ""
|
||||
? d.summary
|
||||
: d.title || d.path;
|
||||
if (d.image_url) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "kb-doc-thumb-img";
|
||||
img.loading = "lazy";
|
||||
img.src = d.image_url;
|
||||
img.alt = alt;
|
||||
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
|
||||
box.appendChild(img);
|
||||
} else {
|
||||
box.appendChild(docThumbGlyph());
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/* The no-data state (phase 97): zero sources (nothing registered,
|
||||
* nothing indexed) OR a failed tree fetch (the former showEmpty
|
||||
* failure behavior, unchanged in kind) — every catalog surface
|
||||
@@ -1440,7 +1521,18 @@ export async function mount(root) {
|
||||
});
|
||||
link.title = d.path; // full path as the link's hover/accessible name
|
||||
link.textContent = d.path;
|
||||
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
|
||||
// box before the path link (the .kb-doc-path flex wrapper — the
|
||||
// link keeps its ellipsis). Text rows keep the bare-link cell,
|
||||
// byte-identical to pre-phase (no box at all).
|
||||
if (d.is_image) {
|
||||
const pathWrap = document.createElement("div");
|
||||
pathWrap.className = "kb-doc-path";
|
||||
pathWrap.append(docThumb(d), link);
|
||||
pathTd.appendChild(pathWrap);
|
||||
} else {
|
||||
pathTd.appendChild(link);
|
||||
}
|
||||
tr.appendChild(pathTd);
|
||||
|
||||
// Phase 106 (task 08, D8): the cell order is [title, chunks,
|
||||
|
||||
+286
-3
@@ -551,6 +551,42 @@ body::before {
|
||||
}
|
||||
.msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); }
|
||||
|
||||
/* Phase 123 (task 02, TODO L6): the question's IMAGE in the user
|
||||
bubble — the attachment is PART of the question, so it sits at the
|
||||
TOP of the bubble (above the text). Capped height (a tall portrait
|
||||
must not blow the chat column) + full-width safe; the theme's
|
||||
bordered-image treatment (the .source-image-img language) — the
|
||||
border gives the bytes a boundary against the brand bubble fill. */
|
||||
.msg-image {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
/* Phase 123 (task 03, TODO L6): the question's image is UNAVAILABLE —
|
||||
the stored file was deleted out-of-band (the record keeps its
|
||||
path, the render degrades): the small muted line takes the img's
|
||||
place at the top of the user bubble on BOTH pages (the chat
|
||||
restore and the shared view render the same record shape through
|
||||
the same .msg/.bubble structure, and share this stylesheet). It
|
||||
inherits the bubble's text color — the user bubble's text already
|
||||
passes 4.5:1 (PLAN §7.2), so the note does too; the smaller size +
|
||||
italic make it read as a note, never a broken-image icon. The
|
||||
margin-bottom mirrors .msg-image's, so the text below keeps its
|
||||
spacing either way. */
|
||||
.msg-image-unavailable {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg.brain .bubble { border-bottom-left-radius: 4px; }
|
||||
.msg.brain.is-deflected .bubble {
|
||||
background: var(--accent-bg);
|
||||
@@ -737,6 +773,47 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.source-chip:hover { background: var(--brand-soft); text-decoration: underline; }
|
||||
|
||||
/* Phase 122 (task 05): the sources block's COMPACT INLINE IMAGE for a
|
||||
retrieved IMAGE document ("shown in the chat nicely", TODO L6) — the
|
||||
.source-image figure sits beside its chip in the .msg-meta flex row
|
||||
(additive: the chip's text and affordance stay). A capped (96px,
|
||||
contain) img on the theme's surface, the document's summary as the
|
||||
visible caption in the AA-safe muted ink (8.6:1 on --bg — the
|
||||
row's page background). A failed image load removes the whole
|
||||
figure in JS (the plain chip stays — never a broken-image icon). */
|
||||
.source-image {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.source-image-img {
|
||||
display: block;
|
||||
max-height: 96px;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.source-image-caption {
|
||||
max-width: 14rem;
|
||||
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||
|
||||
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
|
||||
scored docs (phase 113 task 01's usefulness bar demotes the
|
||||
sub-floor hits out of the citation surface; on a deflected turn the
|
||||
@@ -1540,6 +1617,62 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
color, never color alone (B3). */
|
||||
.char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); }
|
||||
.char-count.is-max { color: var(--err-ink); }
|
||||
/* Phase 123 (task 02, TODO L6): the ATTACH PREVIEW STRIP — the
|
||||
selected image ABOVE the input row (thumbnail ≤48px + the filename
|
||||
+ the remove button): a surface card in the .chat-bottom stack, between
|
||||
the char-count line and the composer. The name is ellipsized
|
||||
(AA-safe --ink on --surface = 13.8:1) — a long filename never widens
|
||||
the strip; the thumbnail is a fixed 48px cover box (the preview
|
||||
crops, the bubble shows the whole image); the remove button keeps the
|
||||
44px touch
|
||||
target (PLAN §7.1) with a destructive hover (--err-ink on --err-bg =
|
||||
9.3:1 — text + color, never color alone, B3). Hidden by default
|
||||
(the global [hidden] rule) — revealed only while a file is attached,
|
||||
cleared with the send so the strip never lingers into a turn. */
|
||||
.attach-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.45rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.attach-preview-img {
|
||||
flex: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.attach-preview-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
.attach-preview-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.attach-preview-remove svg { width: 18px; height: 18px; }
|
||||
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -1564,6 +1697,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
background: transparent;
|
||||
}
|
||||
.composer textarea::placeholder { color: var(--ink-soft); }
|
||||
/* Phase 123 (task 02, TODO L6): the composer's ATTACH CONTROL — the
|
||||
paperclip glyph button LEFT of the input. Hidden in the markup until
|
||||
app.js reveals it from the config's images flag (the global [hidden]
|
||||
rule keeps it out of the flag-off DOM — A5's default-off contract).
|
||||
A neutral cut of the .send-btn family: the same 44px hit target +
|
||||
radius + the global :focus-visible ring (PLAN §7.2); surface fill
|
||||
with a muted hover step (the icon: --ink-soft on --surface = 5.1:1,
|
||||
hover --brand-ink on --brand-soft = 12.4:1 — both past the 3:1
|
||||
non-text floor). */
|
||||
.attach-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.attach-btn svg { width: 20px; height: 20px; }
|
||||
.attach-btn:hover { background: var(--brand-soft); border-color: var(--brand-soft); color: var(--brand-ink); }
|
||||
.send-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2246,6 +2404,61 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Image-document rows (phase 122, task 04): the file table's Path cell
|
||||
carries a FIXED 48px thumbnail box before the path link (image rows
|
||||
only — text rows keep the pre-phase bare-link cell, no box). The
|
||||
.kb-doc-path flex wrapper gives the link its own ellipsis budget
|
||||
(min-width: 0 — the .sync-label pattern) now that the box shares
|
||||
the cell; object-fit: cover keeps every aspect ratio inside the
|
||||
square (CSS on .kb-doc-thumb-img). The glyph fallback (a failed
|
||||
fetch, or a node without a servable image_url) swaps INSIDE the
|
||||
same fixed box — no layout shift beyond the box, no broken-image
|
||||
placeholder. The img's alt (the vision description) carries the
|
||||
accessibility; the glyph is aria-hidden. Phase-08 tokens only. */
|
||||
.kb-doc-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.kb-doc-path .doc-link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* lets the link shrink — what engages the ellipsis */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.kb-doc-thumb {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--ink-soft); /* the glyph's stroke color (decorative) */
|
||||
overflow: hidden;
|
||||
}
|
||||
.kb-doc-thumb-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.kb-doc-thumb-glyph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.kb-doc-thumb-glyph svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- KB folder-description editor (phase 97, task 05) ----------
|
||||
The phase-57 edit affordance on the RAG view's folder descriptions
|
||||
(the .kb-summary-* family, mirroring the viewer's .doc-summary-*):
|
||||
@@ -2386,7 +2599,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#archive-upload-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#git-source-form > label,
|
||||
#archive-upload-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||
#git-source-url {
|
||||
/* Phase 121 (task 03): the form-label "optional" hint — the muted
|
||||
ink-soft pair (5.1:1 on the label's surface, AA) at the label's
|
||||
600 weight relaxed to 400 so the hint reads as secondary (it
|
||||
qualifies the name, it is not the name); small, inline. */
|
||||
.field-hint { color: var(--ink-soft); font-weight: 400; font-size: 0.8rem; }
|
||||
/* #git-source-token (phase 121): the optional masked private-repo
|
||||
credential — the URL input's treatment VERBATIM (mono, >=44px
|
||||
target, brand focus); type=password masks the value in display. */
|
||||
#git-source-url,
|
||||
#git-source-token {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
min-height: 44px;
|
||||
@@ -2398,8 +2620,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
#git-source-url::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-url::placeholder,
|
||||
#git-source-token::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible,
|
||||
#git-source-token:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-add,
|
||||
#archive-upload-btn {
|
||||
display: inline-flex;
|
||||
@@ -2802,6 +3026,26 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Phase 121 (task 03): the editor's masked token field — the
|
||||
textarea's box language (full panel width, --bg fill, the line
|
||||
border, mono) at the 44px touch floor; the label's top margin keeps
|
||||
the field pair off the textarea. type=password + autocomplete=off
|
||||
live in the markup (a PAT is not a site credential — no browser
|
||||
save offer); the focus ring is the global 3px outline rule. */
|
||||
.ignore-editor-token-label { margin-top: 0.9rem; }
|
||||
.ignore-editor-token {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* The in-dialog failure line (role=alert): the err pair (err-ink on
|
||||
err-bg 9.3:1, the err-line border) — the .remove-confirm-error
|
||||
language. */
|
||||
@@ -4236,6 +4480,44 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Image documents (phase 122, task 04): the viewer's image block —
|
||||
the document's persistent bytes (the /api/documents/{id}/image
|
||||
route) above its description (the .doc-raw content slot below). The
|
||||
theme's surface card treatment (the .doc-md language): the image
|
||||
sits on --surface with the line border + radius; max-width 100% +
|
||||
max-height 70vh keep any aspect ratio inside the page (height: auto
|
||||
keeps the ratio). Static content — no animation; the alt text (the
|
||||
vision description) carries the accessibility (WCAG). Phase-08
|
||||
tokens only (no new hue — the monochrome invariant). */
|
||||
.doc-image {
|
||||
width: 100%;
|
||||
max-width: var(--chat-column);
|
||||
margin: 0 auto 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.doc-image-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* The onerror fallback (the route's 404 — the row exists but the
|
||||
copy was lost): a muted note in the card's place-of-image; the
|
||||
description below still renders, so the doc stays readable. */
|
||||
.doc-image-unavailable {
|
||||
margin: 1.25rem 1rem;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */
|
||||
.doc-not-found {
|
||||
width: 100%;
|
||||
@@ -4943,6 +5225,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#git-source-form > label,
|
||||
#archive-upload-form > label { white-space: normal; }
|
||||
#git-source-url,
|
||||
#git-source-token,
|
||||
#archive-upload-file { min-width: 0; }
|
||||
#git-source-add,
|
||||
#archive-upload-btn { width: 100%; }
|
||||
|
||||
+73
-5
@@ -267,6 +267,24 @@
|
||||
(role=alert). -->
|
||||
<p class="char-count" id="char-count" hidden></p>
|
||||
|
||||
<!-- Phase 123 (task 02, TODO L6): the attach PREVIEW STRIP —
|
||||
the selected image (thumbnail ≤48px + the filename + a
|
||||
remove X) ABOVE the input row: the attachment is part of
|
||||
the question being typed. `hidden` by default — app.js
|
||||
reveals it only while a file is attached (attachedImage)
|
||||
and clears it with the send (the strip must not linger
|
||||
into the turn). With no pick — and with the images flag
|
||||
off — it is never visible (A5's default-off contract).
|
||||
The thumbnail is decorative (alt="" — the filename is
|
||||
the visible, readable label beside it). -->
|
||||
<div class="attach-preview" id="attach-preview" hidden>
|
||||
<img class="attach-preview-img" alt="">
|
||||
<span class="attach-preview-name"></span>
|
||||
<button type="button" class="attach-preview-remove" id="attach-remove" aria-label="Remove the attached image">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
||||
roles — #send-btn reads "Send" when idle and morphs into the
|
||||
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
||||
@@ -284,6 +302,24 @@
|
||||
`!text` guard in app.js is the real empty-input check (same
|
||||
precedent as the tuning form's noValidate). -->
|
||||
<form class="composer" id="composer" novalidate>
|
||||
<!-- Phase 123 (task 02, TODO L6): the attach control — the
|
||||
paperclip glyph button LEFT of the input (the icon style
|
||||
of the other composer glyphs; aria-label is its
|
||||
accessible name, the SVG is decorative). HIDDEN BY
|
||||
DEFAULT — app.js reveals it only when the boot
|
||||
/api/config says `images: true` (the brand boot's ONE
|
||||
config request — no second round-trip); flag off (the
|
||||
default) keeps the button hidden for good, so the
|
||||
flag-off DOM is byte-identical to pre-phase (A5's
|
||||
default-off contract). The hidden #attach-file input is
|
||||
its backend (a programmatic .click() — the native
|
||||
picker); accept mirrors the server's six-extension gate
|
||||
(the client re-checks the name's extension before any
|
||||
state change; the upload endpoint is the authority). -->
|
||||
<button type="button" class="attach-btn" id="attach-btn" hidden aria-label="Attach an image">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<input type="file" id="attach-file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>
|
||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
|
||||
(app/schemas.py) — the server 422s beyond; the #char-count
|
||||
@@ -625,10 +661,11 @@
|
||||
to the database.
|
||||
</p>
|
||||
|
||||
<!-- Add form: visible label + mono URL input + brand button
|
||||
(dark ink on brand 5.2:1). §7.4 never-stale: the button
|
||||
disables + relabels "Adding…" while the POST is in flight
|
||||
and re-enables on success AND failure (the input is kept
|
||||
<!-- Add form: visible label + mono URL input + the optional
|
||||
masked token field (phase 121) + brand button (dark ink
|
||||
on brand 5.2:1). §7.4 never-stale: the button disables +
|
||||
relabels "Adding…" while the POST is in flight and
|
||||
re-enables on success AND failure (the inputs are kept
|
||||
on failure, same as the tuning forms). -->
|
||||
<form id="git-source-form">
|
||||
<label for="git-source-url">Add a git source</label>
|
||||
@@ -641,6 +678,22 @@
|
||||
placeholder="https://github.com/you/your-repo.git"
|
||||
required
|
||||
>
|
||||
<!-- Phase 121 (task 03, LOCKED A2): the optional masked
|
||||
private-repo credential — type=password +
|
||||
autocomplete=off (a PAT is not a site credential: no
|
||||
browser save offer). Blank = the POST omits the key
|
||||
(None = no credential); the token is write-only — it
|
||||
never round-trips (the API shapes carry no token
|
||||
field). -->
|
||||
<label for="git-source-token">Token <span class="field-hint">optional — private repos</span></label>
|
||||
<input
|
||||
id="git-source-token"
|
||||
name="token"
|
||||
type="password"
|
||||
maxlength="500"
|
||||
autocomplete="off"
|
||||
placeholder="ghp_… or another PAT"
|
||||
>
|
||||
<button type="submit" id="git-source-add">Add source</button>
|
||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
@@ -784,12 +837,16 @@
|
||||
while the PATCH is out). Stored rows only (A3) — the
|
||||
per-row button lives in git-sources.js's makeRow; this
|
||||
is the page-local dialog it opens. -->
|
||||
<!-- Phase 121 (task 03): the per-row editor gains the
|
||||
optional masked token field (LOCKED A2) — the title
|
||||
widens from "Ignored files and folders" to the dialog's
|
||||
actual scope (the row's settings). -->
|
||||
<div class="ignore-editor" id="ignore-editor-dialog" role="alertdialog"
|
||||
aria-modal="true" aria-labelledby="ignore-editor-title"
|
||||
aria-describedby="ignore-editor-copy" hidden>
|
||||
<div class="ignore-editor-backdrop" aria-hidden="true"></div>
|
||||
<div class="ignore-editor-panel">
|
||||
<h2 class="ignore-editor-title" id="ignore-editor-title">Ignored files and folders</h2>
|
||||
<h2 class="ignore-editor-title" id="ignore-editor-title">Source settings</h2>
|
||||
<code class="ignore-editor-source" id="ignore-editor-source"></code>
|
||||
<p class="ignore-editor-copy" id="ignore-editor-copy">
|
||||
One path per line. A file is ignored when its path in the
|
||||
@@ -801,6 +858,17 @@
|
||||
<textarea class="ignore-editor-textarea" id="ignore-editor-textarea"
|
||||
rows="6" spellcheck="false"
|
||||
placeholder="my/files/"></textarea>
|
||||
<!-- Phase 121 (task 03, LOCKED A2): the masked
|
||||
private-repo credential — type=password +
|
||||
autocomplete=off (a PAT is not a site credential).
|
||||
BLANK = the PATCH omits the key (the tri-state: no
|
||||
change — the row's stored token is kept). The field
|
||||
is NEVER prefilled (the API has no token field to
|
||||
read it from). -->
|
||||
<label class="ignore-editor-label ignore-editor-token-label" for="ignore-editor-token">Token <span class="field-hint">optional — private repos</span></label>
|
||||
<input class="ignore-editor-token" id="ignore-editor-token"
|
||||
type="password" maxlength="500" autocomplete="off"
|
||||
placeholder="leave blank to keep the current token">
|
||||
<p class="ignore-editor-error" id="ignore-editor-error" role="alert" hidden></p>
|
||||
<div class="ignore-editor-actions">
|
||||
<button type="button" class="ignore-editor-btn ignore-editor-cancel"
|
||||
|
||||
+15
-6
@@ -19,10 +19,15 @@ precedence order:
|
||||
(first run, full history — no ``--depth``; an existing shallow
|
||||
checkout is unshallowed first, phase 107) or fast-forwarded
|
||||
(``git pull --ff-only``) into ``BOR_SOURCES_DIR/<repo-name>/``
|
||||
(default ``~/bor-sources``); local rows are the existing directories
|
||||
themselves, walked directly. A failing clone/pull — or a local
|
||||
directory that is missing at run time — aborts the whole run
|
||||
*before* anything is imported.
|
||||
(default ``~/bor-sources``) — with the phase-121 clone URL
|
||||
(:func:`app.rag.git_sources.clone_url_for`): the row's ``token``
|
||||
column injected as ``https://x-access-token:<token>@…`` only for
|
||||
https? rows, NULL token → the bare stored URL verbatim (public
|
||||
repos and legacy embedded-token rows clone exactly as before);
|
||||
the checkout name stays on the bare URL (credential-free); local
|
||||
rows are the existing directories themselves, walked directly. A
|
||||
failing clone/pull — or a local directory that is missing at run
|
||||
time — aborts the whole run *before* anything is imported.
|
||||
3. Fallback — the legacy ``DEFAULT_SOURCES`` (``~/Homelab`` +
|
||||
``~/Deployments``), kept for backwards compatibility (reached only
|
||||
while both the table and ``BOR_GIT_SOURCES`` are empty).
|
||||
@@ -113,7 +118,7 @@ from app.core.logging import configure_logging
|
||||
from app.db import SessionLocal
|
||||
from app.models import KbOverview
|
||||
from app.rag.folder_summaries import generate_folder_summaries, missing_folder_summaries
|
||||
from app.rag.git_sources import effective_sources
|
||||
from app.rag.git_sources import clone_url_for, effective_sources
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.overview import regenerate_overview
|
||||
@@ -236,7 +241,11 @@ def _resolve_sources(
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] = {}
|
||||
for row in rows:
|
||||
if row.kind == "git":
|
||||
root = clone_or_pull(row.url, sources_root / repo_name(row.url))
|
||||
# Phase 121: the token column is injected into the clone
|
||||
# URL ONLY here (clone_url_for — NULL token → the bare
|
||||
# stored URL verbatim); repo_name stays on the bare URL
|
||||
# so the checkout directory name is credential-free.
|
||||
root = clone_or_pull(clone_url_for(row), sources_root / repo_name(row.url))
|
||||
# Phase 106 (D2): the checkout's per-file last-commit
|
||||
# dates, keyed by the SAME root string the importer
|
||||
# sees; local rows contribute nothing (mtime fallback).
|
||||
|
||||
@@ -69,6 +69,15 @@ os.environ["BOR_RECENCY_HALF_LIFE_DAYS"] = str(
|
||||
_Settings.model_fields["recency_half_life_days"].default
|
||||
)
|
||||
|
||||
# Phase 122 (task 01): the same leak class for the image-document
|
||||
# toggle — an operator's local ``.env`` may legitimately carry
|
||||
# ``BOR_IMAGES``/``BOR_IMAGE_EXTENSIONS``/``BOR_IMAGE_DIR``, and the
|
||||
# "off by default" pins (the ``/api/config`` ``images`` flag, the
|
||||
# walk's images-off behavior) must see the code defaults.
|
||||
os.environ["BOR_IMAGES"] = str(_Settings.model_fields["images"].default)
|
||||
os.environ["BOR_IMAGE_EXTENSIONS"] = str(_Settings.model_fields["image_extensions"].default)
|
||||
os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
|
||||
|
||||
from app.db import SessionLocal, db_available # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
|
||||
+106
-5
@@ -26,6 +26,14 @@ Implements just enough of the aipi surface:
|
||||
``SUMMARY_MODE`` branch: the folder marker CONTAINS the summary
|
||||
marker as a substring, so the summary branch would otherwise
|
||||
shadow every folder-summary call
|
||||
- ``IMAGE_DESCRIPTION_MODE`` (phase 122, image documents) -> the
|
||||
fixed ``IMAGE_DESCRIPTION_ANSWER`` description: the CHAT model's
|
||||
(vision) reply to ``summarizer.describe_image``'s multimodal user
|
||||
message (the marker is its first text part; the call is
|
||||
non-streaming, so only this answer path serves it). The reply is
|
||||
the image document's whole content + summary, so it is byte-
|
||||
stable and token-dense (its cosine against the story suite's
|
||||
question clears the mock-calibrated gate)
|
||||
- ``DEFLECT_MODE`` -> honest "I haven't done anything like that" answer
|
||||
- otherwise -> upbeat answer quoting the provided document context
|
||||
- user message containing ``pretend to think slowly`` -> 3s warm-up delay
|
||||
@@ -592,19 +600,46 @@ def _messages(body: dict[str, Any]) -> list[dict[str, str]]:
|
||||
return body.get("messages", [])
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
"""The TEXT of one message's content (phase 122 list safety).
|
||||
|
||||
String content passes through byte-identical (as does an absent
|
||||
value or an explicit ``None`` — the ``or ""`` semantics the
|
||||
:func:`_context` docstring pins). A multimodal part LIST — the
|
||||
phase-122 image description's ``[{type: "text", …},
|
||||
{type: "image_url", …}]``, the only list-content message the app
|
||||
produces (``app.rag.summarizer.describe_image``) — contributes its
|
||||
text parts joined (the ``image_url`` part carries no text): the
|
||||
trigger checks and marker branches read the text, and no existing
|
||||
string-content request is affected.
|
||||
"""
|
||||
if isinstance(content, list):
|
||||
return " ".join(
|
||||
part.get("text", "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
return content or ""
|
||||
|
||||
|
||||
def _system(body: dict[str, Any]) -> str:
|
||||
return " ".join(m.get("content", "") for m in _messages(body) if m.get("role") == "system")
|
||||
return " ".join(
|
||||
_content_text(m.get("content"))
|
||||
for m in _messages(body)
|
||||
if m.get("role") == "system"
|
||||
)
|
||||
|
||||
|
||||
def _user(body: dict[str, Any]) -> str:
|
||||
parts = [m.get("content", "") for m in _messages(body) if m.get("role") == "user"]
|
||||
parts = [_content_text(m.get("content")) for m in _messages(body) if m.get("role") == "user"]
|
||||
return parts[-1] if parts else ""
|
||||
|
||||
|
||||
def _context(body: dict[str, Any]) -> str:
|
||||
"""The document context is the longest system/user message in practice.
|
||||
|
||||
``m.get("content") or ""`` (NOT ``m.get("content", "")``): a well-formed
|
||||
``_content_text(m.get("content"))`` (the ``or ""`` semantics, NOT
|
||||
``m.get("content", "")``): a well-formed
|
||||
OpenAI tool-call message carries ``content: None`` EXPLICITLY (the app's
|
||||
agent loop appends exactly that — ``app/rag/agent.py``), and a forced
|
||||
final answer after a tool round (the round-cap path) reaches this helper
|
||||
@@ -612,9 +647,12 @@ def _context(body: dict[str, Any]) -> str:
|
||||
an explicit ``None`` and crashes ``len()`` with a 500 (phase 93 task 04
|
||||
caught it via the deterministic single-read flow's ALREADY_IN_CONTEXT
|
||||
loop); ``or ""`` treats absent and explicit-None alike, so the fallback
|
||||
composes deterministically instead of traceback-ing."""
|
||||
composes deterministically instead of traceback-ing. Phase 122: a
|
||||
multimodal part list maps to its text parts (see
|
||||
:func:`_content_text`), so ``len()``/slicing never meet a list.
|
||||
"""
|
||||
msgs = _messages(body)
|
||||
return max((m.get("content") or "" for m in msgs), key=len)
|
||||
return max((_content_text(m.get("content")) for m in msgs), key=len)
|
||||
|
||||
|
||||
LONG_ANSWER_TRIGGER = "write a long answer"
|
||||
@@ -739,6 +777,25 @@ HISTORY_TRIGGER = "echo my history"
|
||||
#: phrase, so every other suite is unaffected.
|
||||
FOLDER_MAP_TRIGGER = "repeat your folder map"
|
||||
|
||||
#: Phase 122 (image documents, LOCKED A3): the fixed description the
|
||||
#: mock's vision (CHAT) model returns for an ``IMAGE_DESCRIPTION_MODE``
|
||||
#: request — the multimodal user message
|
||||
#: ``[{type: "text", …IMAGE_DESCRIPTION_MODE…}, {type: "image_url",
|
||||
#: …}]`` (``app.rag.summarizer.describe_image``, the app's only
|
||||
#: list-content message). It becomes the image document's WHOLE
|
||||
#: ``content`` AND ``summary`` (the only embedded text — the embedding
|
||||
#: model never sees pixels), so the text is byte-stable across runs and
|
||||
#: token-dense: the story suite's question ("What is shown in the
|
||||
#: homelab network diagram?", cosine ≈0.38 against the mock's
|
||||
#: token-overlap embeddings) clears the mock-calibrated gate (0.30)
|
||||
#: and the citation usefulness floor (0.15).
|
||||
IMAGE_DESCRIPTION_ANSWER = (
|
||||
"A network diagram of the homelab server room: a core router on top, "
|
||||
"a core switch in the middle, and three labeled subnets at the bottom — "
|
||||
"VLAN 10 office, VLAN 20 lab, and VLAN 30 storage — with a legend of "
|
||||
"cable runs. Title: Homelab Network Map."
|
||||
)
|
||||
|
||||
TABLE_ANSWER = (
|
||||
"Here's the shape, in a table:\n"
|
||||
"\n"
|
||||
@@ -1026,6 +1083,16 @@ _HTTPS_PER_DEAD_ATTEMPT = 3
|
||||
#: re-drives the failure sequence from zero.
|
||||
_fail_posts: dict[str, int] = {}
|
||||
|
||||
#: Phase 123 (task 04 — question images): a small ring buffer of the
|
||||
#: recent ``/v1/chat/completions`` request bodies, exposed on the
|
||||
#: ``/v1/e2e/captured`` pair below. Purely OBSERVATIONAL (the buffer
|
||||
#: never influences an answer — determinism is untouched): it lets a
|
||||
#: story suite assert on the EXACT request the app built — e.g. that a
|
||||
#: question-image turn delivered the multimodal user content list
|
||||
#: (text part + ``image_url`` data URL) to the model.
|
||||
_CAPTURED: list[dict[str, Any]] = []
|
||||
_CAPTURE_MAX = 100
|
||||
|
||||
|
||||
def _llm_500(why: str) -> JSONResponse:
|
||||
"""A dead-proxy 500 with a JSON error body (phase 67 injection)."""
|
||||
@@ -2178,6 +2245,17 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
answer = "Knowledge base outline:\n- " + " ".join(
|
||||
TOKEN_RE.findall(user.lower())[:8]
|
||||
)
|
||||
elif "IMAGE_DESCRIPTION_MODE" in user:
|
||||
# Phase 122 (image documents, LOCKED A3): the CHAT model's
|
||||
# (vision) image description — a NON-STREAMING multimodal user
|
||||
# message, so only this path ever sees it. ``_content_text``
|
||||
# joins the text parts, so the marker (the first text part)
|
||||
# lands in ``user``. Checked before the user-trigger branches:
|
||||
# the marker is a fixed app constant, and a real question is
|
||||
# never expected to type it (the other user triggers are owner
|
||||
# phrasings a question might legitimately contain — this one
|
||||
# is an app-to-app marker).
|
||||
answer = IMAGE_DESCRIPTION_ANSWER
|
||||
elif TABLE_TRIGGER in user.lower():
|
||||
# Markdown tables (phase 44, TODO.md L6): the story E2E's
|
||||
# deterministic table answer — a 3-column table, the
|
||||
@@ -2348,6 +2426,23 @@ def compose_thinking_paragraphs(body: dict[str, Any]) -> str:
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@app.get("/v1/e2e/captured")
|
||||
def e2e_captured() -> list[dict[str, Any]]:
|
||||
"""Phase 123 (task 04): the recent chat-completions request bodies
|
||||
(oldest first, capped at ``_CAPTURED_MAX``) — the mock's capture
|
||||
for request-shape assertions (see ``_CAPTURED``)."""
|
||||
return _CAPTURED
|
||||
|
||||
|
||||
@app.post("/v1/e2e/captured/reset")
|
||||
def e2e_captured_reset() -> dict[str, int]:
|
||||
"""Phase 123 (task 04): clear the capture so a suite can assert on
|
||||
exactly the requests of the turn it is about to drive (e.g. the
|
||||
toggle-off rejection proves ZERO chat calls — an empty capture)."""
|
||||
_CAPTURED.clear()
|
||||
return {"cleared": True}
|
||||
|
||||
|
||||
@app.post("/__shutdown__")
|
||||
def shutdown() -> dict[str, Any]:
|
||||
"""Test hook (loading-feedback story): terminate this mock process to
|
||||
@@ -2551,6 +2646,12 @@ def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
def chat_completions(body: dict[str, Any]) -> Any:
|
||||
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
|
||||
# recorded before any flow decision, so a 500-injected request is
|
||||
# captured too; the buffer never touches the answer path).
|
||||
_CAPTURED.append(body)
|
||||
if len(_CAPTURED) > _CAPTURE_MAX:
|
||||
del _CAPTURED[: len(_CAPTURED) - _CAPTURE_MAX]
|
||||
user_lower = _user(body).lower()
|
||||
# Phase 37 (agent document tools): the deterministic marker flow.
|
||||
# The app's chat path is the only streaming consumer of this mock, so
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Phase 123 E2E (Playwright): chat image questions — attach an image to a
|
||||
question (TODO L6).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov
|
||||
|
||||
The suite's app instance runs with ``BOR_IMAGES=true`` (module env
|
||||
override — the conftest per-suite-app pattern, leak guards included;
|
||||
the question-image store is a suite-private scratch dir so the app
|
||||
under test never writes into the owner's real ``~/bor-sources``), and
|
||||
a SECOND module app runs with ``BOR_IMAGES`` forced ``false`` (the
|
||||
default contract — both apps pin the toggle EXPLICITLY, so an
|
||||
operator's local ``.env`` cannot leak it in either direction).
|
||||
|
||||
The KB is deliberately NOT seeded (each test truncates it): the
|
||||
question-image turn is a DETERMINISTIC deflection (no chunks → LOW
|
||||
→ the mock's honest "I haven't done anything like that" answer) —
|
||||
the deflected branch is a construction site for the multimodal user
|
||||
message (pinned by ``app/api/chat.py``'s docstring), and the answer
|
||||
the mock streams is independent of the image part (the mock's
|
||||
``_content_text`` maps a part list to its text parts).
|
||||
|
||||
* attach a fixture PNG in the composer → the preview strip shows
|
||||
(thumbnail ≤48px + the filename) → remove → the strip clears and
|
||||
the file state is gone (a fresh pick re-renders in place);
|
||||
* re-attach → send → exactly ONE upload, the user bubble shows the
|
||||
image (the live data URL, alt = the filename), the preview strip
|
||||
must not linger into the turn, the mock's (text-only) answer
|
||||
streams, and — via the mock's capture (``/v1/e2e/captured``) —
|
||||
the model RECEIVED the multimodal user content list (the text
|
||||
part == the question + the ``image_url`` data URL that decodes to
|
||||
the uploaded bytes);
|
||||
* the saved record carries the stored PATH (A5: never base64 —
|
||||
nothing base64 crosses the localStorage boundary);
|
||||
* ``page.reload()`` → the user bubble restores WITH its image from
|
||||
the stored path (the image route's request count confirms a PATH
|
||||
fetch, not an inline data URL);
|
||||
* share the chat → a fresh anonymous context opening the shared link
|
||||
sees the user's image on the shared page (the serve route is
|
||||
public — the shared view is faithful);
|
||||
* default-off negative (the flag-off app): ``#attach-btn`` stays
|
||||
hidden for good (the default-off contract) and a direct
|
||||
``POST /api/chat`` with an ``image`` settles the hinted error
|
||||
frame with ZERO model calls (the capture stays empty).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||||
#: the pipeline is content-agnostic; the well-formed bytes keep the
|
||||
#: upload + serve + render + capture pins honest).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
PNG_NAME = "diagram.png"
|
||||
|
||||
QUESTION = "What is in this screenshot? (chat-images)"
|
||||
TEXT_ONLY_QUESTION = "How is my Kubernetes cluster set up? (chat-images text-only)"
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
TURN_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module apps: images ON (the story) and images forced OFF (the default
|
||||
# contract). Each owns its port + its scratch question-image dir.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-123
|
||||
knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app,
|
||||
false for the default app — process env ranks above an operator's
|
||||
local gitignored ``.env``, so the contract under test cannot leak
|
||||
in either direction) and the question-image store in the suite's
|
||||
scratch dir (the app under test must not write into the owner's
|
||||
real ``~/bor-sources``)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated gate (conftest pattern) — with an UNSEEDED KB
|
||||
# (this suite's determinism) every turn is a deflection anyway;
|
||||
# the pins keep the gate's quadrant stable if the dev KB leaks in.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# Leak guards (conftest pattern).
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# Phase 123: the toggle under test + the suite-private image store.
|
||||
env["BOR_IMAGES"] = "true" if images else "false"
|
||||
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||
"""The story app — ``BOR_IMAGES=true`` (the module env override;
|
||||
the conftest session app is never started in this isolated run,
|
||||
so no port clash)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_chat_image_on")
|
||||
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES", "8160"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, port, images=True, scratch=scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def default_app_server(
|
||||
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Iterator[str]:
|
||||
"""The default-contract app — ``BOR_IMAGES=false`` (only the
|
||||
negative test starts it)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_chat_image_off")
|
||||
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES_OFF", "8161"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, port, images=False, scratch=scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def default_app_url(default_app_server: str) -> str:
|
||||
return default_app_server
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, seed: bool) -> None:
|
||||
"""Truncate the KB (and the turn log) so every turn in this suite
|
||||
is a deterministic deflection. ``seed`` is always False here — a
|
||||
question image is a separate concern from document ingestion (it
|
||||
is NEVER indexed as a document), so the suite never imports."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
assert seed is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser helpers (the composer's attach flow + the turn's settle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _png_file(tmp_path: Path) -> Path:
|
||||
png = tmp_path / PNG_NAME
|
||||
png.write_bytes(PNG_1X1)
|
||||
return png
|
||||
|
||||
|
||||
def _attach(page: Page, png: Path) -> None:
|
||||
"""One file pick in the composer's HIDDEN file input (the
|
||||
paperclip button's backend — the native picker is replaced by
|
||||
``set_input_files``, the E2E's standard input simulation): the
|
||||
preview strip must reveal with the data-URL thumbnail + name."""
|
||||
page.set_input_files("#attach-file", str(png))
|
||||
strip = page.locator("#attach-preview")
|
||||
expect(strip).to_be_visible(timeout=TURN_TIMEOUT_MS)
|
||||
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||
thumb = page.locator("#attach-preview img")
|
||||
src = thumb.get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
|
||||
|
||||
|
||||
def _wait_deflected_turn(page: Page) -> None:
|
||||
"""Wait until the (unseeded-KB) turn has fully settled — the
|
||||
deflected answer streamed and the Send button is back (the
|
||||
``done`` frame restored it)."""
|
||||
bubble = page.locator(".msg.brain.is-deflected .bubble").first
|
||||
bubble.wait_for(state="visible", timeout=TURN_TIMEOUT_MS)
|
||||
expect(bubble).to_contain_text(
|
||||
re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=TURN_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def _stored(page: Page) -> dict[str, Any] | None:
|
||||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()["chats"]
|
||||
|
||||
|
||||
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
|
||||
return next((c for c in rows if c["title"] == title), None)
|
||||
|
||||
|
||||
def _auto_title(question: str) -> str:
|
||||
"""The phase-50 auto-title convention: the first question,
|
||||
whitespace-collapsed, capped at 120 chars."""
|
||||
return " ".join(question.split())[:120]
|
||||
|
||||
|
||||
def _wait_saved_row(
|
||||
app_url: str,
|
||||
cookies: dict[str, str],
|
||||
title: str,
|
||||
messages: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
|
||||
A2 — so there is no status line to wait on)."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: dict[str, Any] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
last = _find_row(_chats(app_url, cookies), title)
|
||||
if last is not None and last["message_count"] >= messages:
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||
|
||||
|
||||
def _delete_row(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _send_with_attachment(page: Page, png: Path) -> tuple[str, list[str]]:
|
||||
"""Attach → type → send → wait for the deflected settle. Returns
|
||||
(the record's stored image PATH, the upload request URLs)."""
|
||||
uploads: list[str] = []
|
||||
|
||||
def _on_request(req: Any) -> None:
|
||||
if req.method == "POST" and req.url.endswith("/api/chat-images"):
|
||||
uploads.append(req.url)
|
||||
|
||||
page.on("request", _on_request)
|
||||
_attach(page, png)
|
||||
page.fill("#message-input", QUESTION)
|
||||
page.click("#send-btn")
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
expect(img).to_be_visible(timeout=TURN_TIMEOUT_MS)
|
||||
_wait_deflected_turn(page)
|
||||
stored = _stored(page)
|
||||
assert stored is not None, "the conversation must be persisted (save point 1)"
|
||||
path = stored["messages"][0]["image"]
|
||||
assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
|
||||
return path, uploads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The mock's capture (phase 123, task 04 — the request the SERVER built)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_base(mock_llm: int) -> str:
|
||||
return f"http://127.0.0.1:{mock_llm}"
|
||||
|
||||
|
||||
def _reset_capture(mock_llm: int) -> None:
|
||||
r = httpx.post(f"{_mock_base(mock_llm)}/v1/e2e/captured/reset", timeout=10)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def _captured(mock_llm: int) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{_mock_base(mock_llm)}/v1/e2e/captured", timeout=10)
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Attach → preview → remove (the composer's draft state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_preview_shows_and_remove_clears(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# Flag on (the story app): the paperclip is revealed at boot, with
|
||||
# its accessible name (the SVG is decorative).
|
||||
btn = page.locator("#attach-btn")
|
||||
expect(btn).to_be_visible()
|
||||
expect(btn).to_have_attribute("aria-label", "Attach an image")
|
||||
|
||||
png = _png_file(tmp_path)
|
||||
_attach(page, png)
|
||||
|
||||
# The strip: the data-URL thumbnail + the filename (the readable
|
||||
# label) + the remove ✕ (its accessible name).
|
||||
expect(page.locator("#attach-preview img")).to_be_visible()
|
||||
remove = page.locator("#attach-remove")
|
||||
expect(remove).to_be_visible()
|
||||
expect(remove).to_have_attribute("aria-label", "Remove the attached image")
|
||||
|
||||
# Remove: the strip clears and the file state is GONE — a fresh
|
||||
# pick re-renders the strip in place (the state was truly reset,
|
||||
# not merely hidden under a stale one).
|
||||
page.click("#attach-remove")
|
||||
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||
_attach(page, png)
|
||||
expect(page.locator("#attach-preview")).to_be_visible()
|
||||
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Send with an attachment: one upload, the image in the bubble, the
|
||||
# multimodal request at the mock, the PATH (never base64) in storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_with_attached_image_delivers_the_multimodal_request(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
png = _png_file(tmp_path)
|
||||
|
||||
_reset_capture(mock_llm) # exactly the requests of THIS turn
|
||||
path, uploads = _send_with_attachment(page, png)
|
||||
|
||||
# A8's ordering at the wire level: EXACTLY one upload (the double-
|
||||
# fire guard never let a second through) and it preceded the turn.
|
||||
assert len(uploads) == 1
|
||||
|
||||
# The live user bubble: the data URL (no fetch), alt = the
|
||||
# filename; the preview strip must not linger into the turn.
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
src = img.get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
|
||||
assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
|
||||
assert img.get_attribute("alt") == PNG_NAME
|
||||
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||
|
||||
# The mock (text-only) answer streamed normally (the mock ignores
|
||||
# the image part) — and the REQUEST it received is the multimodal
|
||||
# user content list: text part == the question + the image_url
|
||||
# data URL that decodes to the uploaded bytes (the server built
|
||||
# it from the stored file + the phase-122 mime map).
|
||||
captured = _captured(mock_llm)
|
||||
assert len(captured) == 1, "the deflected turn is exactly one model request"
|
||||
user = captured[0]["messages"][-1]
|
||||
assert user["role"] == "user"
|
||||
assert user["content"] == [
|
||||
{"type": "text", "text": QUESTION},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# A5 at the storage boundary: the record carries the PATH, and
|
||||
# NOTHING base64 crossed into the localStorage payload.
|
||||
stored = _stored(page)
|
||||
assert stored is not None
|
||||
assert stored["messages"][0]["image"] == path
|
||||
assert "base64" not in json.dumps(stored)
|
||||
|
||||
# Cleanup: drop the auto-saved row (the dev DB is shared).
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||
_delete_row(app_url, cookies, row["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Reload: the user bubble restores WITH its image (from the stored
|
||||
# path — the image route's request count confirms the path fetch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reload_restores_the_image_from_the_stored_path(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
png = _png_file(tmp_path)
|
||||
|
||||
path, _uploads = _send_with_attachment(page, png)
|
||||
|
||||
# Track the image route's fetches from here on — the restored
|
||||
# bubble must load the image by FETCHING the stored path (not an
|
||||
# inline data URL).
|
||||
fetches: list[str] = []
|
||||
|
||||
def _on_request(req: Any) -> None:
|
||||
if "/api/chat-images/" in req.url:
|
||||
fetches.append(req.url)
|
||||
|
||||
page.on("request", _on_request)
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
|
||||
|
||||
# The restored user bubble carries the image — its src is the
|
||||
# STORED PATH (the record's key), not the live data URL.
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
expect(img).to_be_visible(timeout=30_000)
|
||||
assert (img.get_attribute("src") or "") == path, "the restore renders from the stored path"
|
||||
|
||||
# The path fetch must actually fire (the lazy img loading it) —
|
||||
# poll with a deadline. ``wait_for_timeout`` (not ``time.sleep``)
|
||||
# is the tick: the sync API dispatches the ``request`` events
|
||||
# queued during it, and a bare sleep would starve the listener.
|
||||
deadline = time.monotonic() + 10
|
||||
while not any(u.rstrip("/").endswith(path) for u in fetches):
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError(
|
||||
f"no fetch of the stored path ({len(fetches)} image requests: {fetches!r})"
|
||||
)
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
# The rest of the conversation is unchanged.
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
|
||||
expect(page.locator(".msg.brain .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.brain .bubble")).to_contain_text(
|
||||
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
|
||||
)
|
||||
|
||||
# Cleanup.
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||
_delete_row(app_url, cookies, row["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Share: a fresh anonymous context sees the user's image on the
|
||||
# shared page (the public serve route — the shared view is faithful)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_page_shows_the_user_image(
|
||||
page: Page, browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
png = _png_file(tmp_path)
|
||||
|
||||
path, _uploads = _send_with_attachment(page, png)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||
|
||||
# The saved row's user record carries the PATH (the share's source
|
||||
# of truth — A5: never base64).
|
||||
detail = httpx.get(f"{app_url}/api/chats/{row['id']}", timeout=10, cookies=cookies)
|
||||
assert detail.status_code == 200
|
||||
saved_user = detail.json()["messages"][0]
|
||||
assert saved_user["image"] == path
|
||||
|
||||
# Share (the chat page's pill — the owner-locked one action):
|
||||
# grant the clipboard so the copy path runs (the status line is
|
||||
# the assertion surface).
|
||||
page.context.grant_permissions(
|
||||
["clipboard-read", "clipboard-write"], origin=app_url
|
||||
)
|
||||
page.locator("#share-chat-btn").click()
|
||||
expect(page.locator("#send-status")).to_have_text("Share link copied.", timeout=15_000)
|
||||
|
||||
row = _find_row(_chats(app_url, cookies), _auto_title(QUESTION))
|
||||
assert row is not None and row.get("share_url")
|
||||
share_url: str = row["share_url"]
|
||||
assert SHARE_URL_RE.fullmatch(share_url)
|
||||
try:
|
||||
# A FRESH context (no cookies, no localStorage): the shared
|
||||
# page renders the user's image (public route, same bubble
|
||||
# treatment — alt = the record's text).
|
||||
anon = browser.new_context()
|
||||
try:
|
||||
anon_page = anon.new_page()
|
||||
anon_page.goto(app_url + share_url)
|
||||
anon_img = anon_page.locator(".msg.user .msg-image").first
|
||||
expect(anon_img).to_be_visible(timeout=30_000)
|
||||
assert (anon_img.get_attribute("src") or "") == path
|
||||
# The answer text is there too (the shared view is the
|
||||
# full conversation, read-only).
|
||||
expect(anon_page.locator(".msg.brain .bubble")).to_contain_text(
|
||||
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
|
||||
)
|
||||
finally:
|
||||
anon.close()
|
||||
finally:
|
||||
_delete_row(app_url, cookies, row["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Default-off negative: the control stays hidden; an API request
|
||||
# with an image gets the hinted error frame, with NO model call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _post_chat_sse(
|
||||
app_url: str, cookies: dict[str, str], body: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""``POST /api/chat`` straight from the test process (the hand-
|
||||
crafted request the absent composer control would otherwise make)."""
|
||||
frames: list[dict[str, Any]] = []
|
||||
with httpx.stream(
|
||||
"POST", f"{app_url}/api/chat", json=body, cookies=cookies, timeout=30
|
||||
) as r:
|
||||
assert r.status_code == 200, r.read()
|
||||
buf = ""
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
frame = frame.strip()
|
||||
if frame.startswith("data:"):
|
||||
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||||
return frames
|
||||
|
||||
|
||||
def test_flag_off_hides_the_control_and_rejects_the_request(
|
||||
page: Page,
|
||||
default_app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, default_app_url, next="/")
|
||||
|
||||
# The config says images off — and the control stays hidden for
|
||||
# GOOD (the default-off contract: the static markup ships hidden,
|
||||
# the reveal gate never fires, the rendered DOM is pre-phase).
|
||||
cfg = httpx.get(f"{default_app_url}/api/config", timeout=10).json()
|
||||
assert cfg["images"] is False
|
||||
expect(page.locator("#attach-btn")).to_be_hidden()
|
||||
expect(page.locator("#attach-file")).to_be_hidden()
|
||||
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||
|
||||
# The server-side contract (the API is the authority — a hand-
|
||||
# crafted request with an image): the phase-114 error frame with
|
||||
# the EXACT detail + hint, ONE terminal frame (no ``done``), and
|
||||
# ZERO model calls (the capture stays empty — the embed never ran).
|
||||
_reset_capture(mock_llm)
|
||||
cookies = _admin_cookies(page)
|
||||
frames = _post_chat_sse(
|
||||
default_app_url,
|
||||
cookies,
|
||||
{"message": "What is in this image?", "image": "/api/chat-images/" + "b" * 32 + ".png"},
|
||||
)
|
||||
assert [f["type"] for f in frames] == ["error"]
|
||||
assert frames[0] == {
|
||||
"type": "error",
|
||||
"detail": "Image support is turned off on this server.",
|
||||
"hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.",
|
||||
}
|
||||
assert _captured(mock_llm) == [] # no model call (the rejected turn)
|
||||
@@ -149,27 +149,29 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
|
||||
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
|
||||
# empty, so it is the inert false here. Phase 62 (task 01): the
|
||||
# endpoint grew with the UI-customization keys; phase 91
|
||||
# (task 03) deleted the retired CSS-file theming's ``theme`` key —
|
||||
# the five keys below are the entire contract (this suite's
|
||||
# instances carry no UI-customization overrides, so the string
|
||||
# keys are their defaults).
|
||||
# (task 03) deleted the retired CSS-file theming's ``theme`` key;
|
||||
# phase 122 (task 01) added the ``images`` flag (inert false here
|
||||
# — neither instance sets BOR_IMAGES) — the six keys below are
|
||||
# the entire contract (this suite's instances carry no
|
||||
# UI-customization overrides, so the string keys are their
|
||||
# defaults).
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text",
|
||||
"images", "input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["app_name"] == TESTY_NAME
|
||||
assert body["docs_repo_configured"] is False
|
||||
assert body["images"] is False
|
||||
|
||||
# The shared conftest instance keeps the default (the other
|
||||
# suites' title/label contract rides on it) — and its key set
|
||||
# tracks the endpoint contract (five keys after phase 91,
|
||||
# task 03).
|
||||
# tracks the endpoint contract (six keys after phase 122, task 01).
|
||||
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
|
||||
assert r2.status_code == 200
|
||||
r2_body = r2.json()
|
||||
assert set(r2_body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text",
|
||||
"images", "input_placeholder", "footer_text",
|
||||
}
|
||||
assert r2_body["app_name"] == DEFAULT_NAME
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user