Commit 1fc06e6
fix+feat: web search URL cap, bulk job cancel, server logging, and vector search startup [v0.2.0] (#34)
* fix: prevent wiki root being prepended to URLs with backslashes
Windows-pasted URLs (e.g. https:\example.com\path) have backslashes
instead of forward slashes. Both needs_path_resolution and detect_skill
checked for the literal prefix 'https://' and silently fell through for
backslash URLs — causing needs_path_resolution to return True and the
wiki root to be prepended, producing a corrupted local path stored in
the job.
Three-layer fix:
- enqueue_ingest: normalise backslashes to / before path resolution so
the stored job always contains a valid URL
- needs_path_resolution: normalise before the URL prefix check as a
safety net for any path not submitted through the HTTP endpoint
- detect_skill: same normalisation so execution-time skill routing also
handles backslash URLs correctly
Also treat FileNotFoundError as a permanent failure (like NotImplementedError)
so a job with a missing or corrupted source is marked dead immediately
rather than being retried up to max_retries times.
* fix: prevent wiki root being prepended to backslash URLs; mark FileNotFoundError permanent
Root cause: Windows-pasted URLs (https:\example.com\path) have a single backslash
after the colon. The previous fix used replace('\', '/') which produced https:/example.com
(single slash), still not matching the https:// prefix check.
Fix: _normalize_url() reconstructs the proper https:// prefix using a regex that
matches 1-2 slashes or backslashes after http(s):, then normalises the remaining
path. Applied in three places:
- skill_agent.needs_path_resolution: prevent backslash URLs from being path-resolved
- skill_agent.detect_skill: route backslash URLs to the url skill, not treated as files
- http_server.enqueue_ingest: normalise before storing so jobs always contain valid URLs
Also: FileNotFoundError in _run_ingest now calls fail_permanent() instead of fail(),
so jobs with corrupt or missing sources are marked dead immediately with no retry.
Tests added:
- detect_skill routes https:\... to the url skill
- needs_path_resolution returns False for https:\... URLs
- needs_path_resolution returns True for relative paths with no URL prefix
- enqueue_ingest normalises backslash URLs before storing the job
- _run_ingest FileNotFoundError calls fail_permanent, not fail
- _run_ingest network timeout calls fail (retryable), not fail_permanent
* fix: coerce LLM-returned entities/tags to strings before BM25 search
Some LLMs return entities as dicts ({"name": "Canada", "type": "location"})
instead of plain strings. Passing a list of dicts to bm25_search caused:
TypeError: sequence item 0: expected str instance, dict found
Fix: _coerce_str_list() extracts the most useful text field from each item
(name > value > label > text, falling back to str()). Applied in _analyse()
before caching so stored results are always clean strings.
Tests added:
- _coerce_str_list unit tests: plain strings, dict entities, mixed input,
fallback fields (value/label/text), empty string dropping, non-list input
- _analyse integration test: LLM returns dict entities/tags → result contains
plain strings and the correct values are present
* fix: coerce cached entities/tags at read site to handle stale cache
The previous fix coerced dict entities only in _analyse() (write path),
but cached analyses from before the fix bypassed _analyse() entirely.
The TypeError still occurred when reading the cached dict-format data.
Apply _coerce_str_list() at the read site too so any cached analysis —
old or new — is always converted to plain strings before BM25 search.
* ci: update coverage badge
* docs: add missing v0.2.0 deliverables to design.md; remove What's New from README
Three items missing from the Delivered in v0.2.0 table:
- Per-model cost tracking (rate table covers all 5 providers, audit.db storage)
- Knowledge gap detection (3-signal scoring, gap flag + ingest suggestions, Obsidian callout)
- New Obsidian commands: 8 added in v0.2.0 (Lint run/auto-resolve, Jobs retry/purge,
Scaffold, Audit history/costs/queries) bringing total to 15
Also updated Appendix A v0.2.0 bullet list to match.
Removed 'What's New in v0.2.0' section from README — that detail lives in
design.md and will be reorganised with the v0.2.1 docs pass.
* feat: add SearchConfig with vector opt-in flag
* feat: add VectorStore for SQLite-backed page embeddings
Implements VectorStore in storage/search.py with aiosqlite for
async float32 blob storage (upsert, get, get_all, list_slugs, count).
Adds 9 async tests covering all methods including edge cases and
idempotent init; search.py coverage is 99%.
* feat: add vector re-ranking to HybridSearch with fastembed
Extend HybridSearch with optional cosine re-ranking: add search_cfg
parameter, init_vector(), embed_page(), _embed_text(), _get_embed_model(),
and make hybrid_search() async. Falls back to BM25 when vector is
disabled or embeddings.db is empty. Add 10 new tests; overall coverage 83%.
* feat: vector migration at startup and embed pages on ingest write
- Pass search_cfg to HybridSearch in Orchestrator.__init__ so vector
opt-in propagates from config to search layer
- init() calls init_vector() and spawns _run_vector_migration() as a
background asyncio task when search.vector=true
- _run_vector_migration() embeds all wiki pages not yet in
embeddings.db, skipping already-embedded slugs
- _run_ingest() embeds newly created/updated pages immediately after
the job completes when vector is enabled
- QueryAgent._search_one changed to async, now calls
await search.hybrid_search() instead of bm25_search(), enabling
vector re-ranking when configured
- Three new orchestrator tests cover migration embed, skip-if-present,
and no-op-when-disabled; all mocked to never load the embed model
* feat: add progress column to JobQueue and web search phase tracking
Task 5: add progress field to Job dataclass, silent ALTER TABLE migration for
existing DBs, and update_progress() method on JobQueue; dequeue/list_jobs both
hydrate the new field.
Task 6: orchestrator detects web-search sources and writes searching/found_urls
progress phases; enqueue_many return value captured as child_job_ids in the
complete() result; GET /jobs and GET /jobs/{id} expose progress in responses.
* feat: Obsidian live polling web search modal and docs update
- Replace WebSearchModal with live-polling panel: shows phase text
(searching → found URLs → ingesting N URLs), pages list, and errors
as fan-out child jobs complete; modal stays open until all jobs settle
- Add api.job() for single-job polling endpoint
- Configurable poll interval (500–10000 ms, default 2000 ms)
- Update design.md: vector search and live view rows in Delivered table,
embeddings.db description, job object with progress field, [search]
config keys, Appendix A v0.2.0 list
- Update README.md: [search] config snippet and semantic re-ranking section
- Update demo-guide.md: Step 9 Obsidian instructions reflect live modal
* fix: convert Anthropic image blocks to OpenAI format in OpenAIProvider
Image content sent by IngestAgent uses Anthropic format
({"type": "image", "source": {"type": "base64", ...}}) which Gemini
and Groq reject with HTTP 400. _to_openai_content() translates these
to OpenAI image_url blocks before the API call so vision ingest works
with all OpenAI-compatible providers.
Fixes quantum-computing-primer.png going dead when using Gemini/Groq.
* fix: fail_permanent on image ingest when provider has no vision support
Groq's llama models reject list content entirely (no multimodal).
Add supports_vision flag to LLMProvider; OpenAIProvider sets it False
when the base_url contains groq.com. IngestAgent raises NotImplementedError
(-> fail_permanent) with a clear switch-provider message rather than
retrying a call that will never succeed.
* fix: web search modal status freezes when parent job completes between polls
The phase-based status update and the child-count status update were both
gated on !isDone. When the parent job completed before the next poll,
neither branch fired, leaving the status stuck at 'Searching the web...'.
Fix: remove !isDone guard from child-count status; use a local settled
counter so the ingesting progress is always shown while children remain.
* queue stuck
* test: add tests for crash-recovery reset and rate-limit requeue
Covers init() resetting in_progress jobs to pending on restart,
and requeue() not incrementing the retry counter across multiple calls.
* feat: add --max-results flag to ingest command for web search URL limit
* feat: add max results field to Obsidian web search modal
* fix: cap web search child URLs to max_results total, not per sub-query
* feat: add jobs cancel command to bulk-skip all pending jobs
* test+docs: cancel_pending tests and doc updates for max-results, jobs cancel, vision, crash recovery
* fix: add vector search startup log messages for enabled and migration state
* fix: pass log_config=None to uvicorn to prevent it resetting our logging setup
* fix: uncomment [search] section in config template so vector setting is visible by default
* fix: show clear install instruction when fastembed missing instead of silent crash
* fix: check fastembed availability in init_vector so missing package fails early with clear message
* docs: note Python 3.12/3.13 requirement for fastembed vector search
* docs: soften fastembed Python 3.14 note — temporary gap, not permanent limitation
* ci: update cli_commands badge count to 26
---------
Co-authored-by: Paul Chen <32553156+paulmchen@users.noreply.github.com>1 parent dbcc72a commit 1fc06e6
26 files changed
Lines changed: 1221 additions & 66 deletions
File tree
- docs
- obsidian-plugin
- src
- synthadoc
- agents
- cli
- core
- integration
- providers
- storage
- tests
- core
- integration
- providers
- storage
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
390 | 390 | | |
391 | 391 | | |
392 | 392 | | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
393 | 419 | | |
394 | 420 | | |
395 | 421 | | |
| |||
508 | 534 | | |
509 | 535 | | |
510 | 536 | | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
511 | 542 | | |
512 | 543 | | |
513 | 544 | | |
| |||
605 | 636 | | |
606 | 637 | | |
607 | 638 | | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
608 | 642 | | |
609 | 643 | | |
610 | 644 | | |
| |||
656 | 690 | | |
657 | 691 | | |
658 | 692 | | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
659 | 697 | | |
660 | 698 | | |
661 | 699 | | |
| |||
1027 | 1065 | | |
1028 | 1066 | | |
1029 | 1067 | | |
1030 | | - | |
1031 | | - | |
1032 | | - | |
1033 | | - | |
1034 | | - | |
1035 | | - | |
1036 | | - | |
1037 | | - | |
1038 | | - | |
1039 | | - | |
1040 | | - | |
1041 | 1068 | | |
1042 | 1069 | | |
1043 | 1070 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
671 | 671 | | |
672 | 672 | | |
673 | 673 | | |
674 | | - | |
| 674 | + | |
675 | 675 | | |
676 | 676 | | |
677 | | - | |
| 677 | + | |
678 | 678 | | |
679 | | - | |
680 | | - | |
681 | | - | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
682 | 690 | | |
683 | 691 | | |
684 | 692 | | |
| |||
1099 | 1107 | | |
1100 | 1108 | | |
1101 | 1109 | | |
1102 | | - | |
| 1110 | + | |
1103 | 1111 | | |
1104 | 1112 | | |
1105 | 1113 | | |
| |||
1123 | 1131 | | |
1124 | 1132 | | |
1125 | 1133 | | |
| 1134 | + | |
| 1135 | + | |
1126 | 1136 | | |
1127 | 1137 | | |
1128 | 1138 | | |
| |||
0 commit comments