Repository navigation
Bug tracker
This page mirrors
docs/BUG_TRACKER.mdfrom the repository and is regenerated on every merge tomain. To correct it, edit that file; changes made here are overwritten.
Lightweight, in-repo, git-versioned bug tracker. Created 2026-08-21 as part of setting up
ongoing implementation tracking — this is the first version, not a backfill of every bug ever
found (those are already recorded, resolved, in CHANGELOG.md's per-version entries).
Scope. This file tracks real, currently-open code defects only — something that behaves
incorrectly against its own documented/intended behavior. It does NOT track: product/design
decisions awaiting a call (see docs/IMPLEMENTATION_TRACKER.md), unbuilt features or specs,
or this sandbox's inherent inability to run live QGIS (a standing environmental limitation,
not a bug — see docs/RELEASE_SMOKE_TEST.md).
Format. One entry per bug: ID, found date, severity, status, description, repro, fix status.
Severity is critical (data loss / security / crash) / high (wrong result, no workaround) /
medium (wrong result, has a workaround) / low (cosmetic, wording, non-blocking). Status is
open / fixed-unverified (fixed in code, not yet re-tested) / fixed-verified (fixed and
confirmed via a real test run or live check) / wontfix (with rationale).
None currently. BUG-2026-09-13-2 and BUG-2026-09-13-4 (below) are both closed. The same
crash TEXT recurred 3 times after BUG-2026-09-13-2's fix (v1.15.4) shipped -- turned out to be a
genuinely separate bug (BUG-2026-09-13-4) with the same error message, not the first one
resurfacing. If this exact text is ever reported a third time, check extract_openai_style_usage
and the tool-dispatch surface are BOTH still fixed before assuming it's a new instance -- two
unrelated bugs sharing one crash signature ("something assumed to be a dict wasn't") is possible,
maybe even likely, for a code style this consistent across the codebase; don't assume the
signature always points to the same line. BUG-2026-09-11-1, BUG-2026-09-12-1, and
BUG-2026-09-12-2 (below) were all found and fixed the same day -- see those entries for the
full history.
Known, non-crashing inconsistency, not tracked as a bug: get_layers()/get_attributes()
(agent/tools/vector_tools.py) both return a bare list on success rather than the
{"success": True, ...} dict shape every other tool in this codebase uses. Confirmed harmless
today (every call site guards with isinstance(res, dict) first, and v1.15.4's new safety net
in _execute_tool closes the risk class regardless) -- flagged here in case a future change
ever assumes the dict shape without guarding, but not worth a dedicated fix on its own.
BUG-2026-09-12-2 | found 2026-09-12 (caught verifying the v1.8.2 release zip before
announcing it) | fixed-verified, shipping in v1.8.3 | medium | plugin_upload.py's
EXCLUDE_FILES set matches a file by basename regardless of directory (os.walk()'s file,
no path check). Before the same-day repo-organization pass, IMPLEMENTATION_TASK_LIST.md,
LICENSE_AUDIT.md, CARTOGEN_AI_PRD.md, and CARTOGEN_AI_FEATURE_LIST.md lived at the repo
root and were correctly excluded there (internal dev docs, not meant to ship). That pass moved
all 5 of them (the 4 above, plus DOCUMENTATION.md) into docs/archive/ -- a directory that is
NOT excluded and whose other ~29 files ship normally -- and the comment above EXCLUDE_FILES
claimed the move "didn't require a functional change here" since the match is basename-only.
That claim was wrong: those same 4 basenames kept matching inside docs/archive/ too, so
cartogen_ai_v1.8.2.zip (already built and its GitHub Release already published) silently
shipped without them, while their sibling DOCUMENTATION.md (never in EXCLUDE_FILES) shipped
fine. Caught live, minutes after publishing, by running unzip -l against the built zip and
diffing it against docs/archive/'s real contents -- not by any test (there is no test coverage
over the release zip's file list). Does not affect plugin functionality: confirmed via a real
QGIS 4.2.2 headless import of the extracted zip (__init__.py imports cleanly, classFactory
present, TOOL_REGISTRY reports the full 165 tools) that the plugin installs and runs
correctly even with the 4 files missing -- this was a shipped-content-completeness bug, not a
functional/crash bug. Fixed by removing those 4 basenames from EXCLUDE_FILES (DOCUMENTATION.md
never needed an entry, so none of the 5 moved docs are listed there anymore); rebuilt the zip and
re-ran the same real-QGIS import check, plus a fresh unzip -l confirming all 5 files now
present and non-trivial (>1KB) on disk after extraction. Full suite still 1406 tests, 0 failures
(no src/ behavior touched, packaging script only).
BUG-2026-09-12-1 | found 2026-09-12 (live user report) | unreleased | high | Two related
issues reported live: (1) an exported generate_html_dashboard/generate_temporal_dashboard's
background basemap showed a blocked/unavailable tile message instead of real map tiles; (2)
after the agent creates or modifies layers, the QGIS canvas keeps whatever extent it already had
-- the result is never actually visible without the user manually zooming ("the map lose[s]
focus to the current layer"). Root causes and fixes, both live-verified:
-
Dashboard basemap.
_build_dashboard_html/_build_temporal_dashboard_html(agent/tools/export_tools.py) both call barefolium.Map(), which defaults totiles="OpenStreetMap"-- rawtile.openstreetmap.orgrequests, client-side, on every open/reload of the exported HTML, with no custom User-Agent and no caching. OpenStreetMap's own tile usage policy explicitly prohibits this pattern (bulk/embedded-app use with no distinguishing User-Agent or local caching) and blocks misbehaving clients. Fixed by switching both call sites totiles="cartodbpositron", folium's standard permissively-licensed alternative built for exactly this "embed a basemap in your own generated page" case -- confirmed directly (no QGIS needed, both dashboard builders are pure Python over already-extracted GeoJSON) that the generated HTML now embedscartocdn.comtile URLs and no longer containstile.openstreetmap.orgat all, for both dashboard builders. -
Canvas doesn't follow new/changed layers. New
zoom_to_layers(iface, layers)(ui/canvas_highlight.py) wired intoChatTabWidget._after_successful_response(runs once per turn, on the main thread, alongside the existingflash_layer_extenthighlight call, over the samefind_mentioned_layers-matched layer set) -- moves the canvas to the UNION extent of every layer the turn's response mentions, instead of leaving the previous extent in place. A real bug caught live before it shipped, not after: the first version also skipped any layer whoseextent().isEmpty()was true, alongsideisNull()-- live-confirmed against real QGIS 4.2.2 that a single-point layer's extent is a legitimate, non-null extent withisEmpty()=True(zero width/height, since a point has no area), so that check silently dropped every single-point layer -- one of the most common layer types this plugin creates (facilities, incidents, points of interest), exactly backwards from the fix being built. Corrected to only skip onisNull()(a genuinely empty layer), matchingvector_tools.py's existingzoom_to_layertool, which never checked either flag. Live-verified against a realQgsMapCanvas(not a mock --ifaceitself is only populated inside the real interactive GUI, so this wraps a real canvas behind a minimal stand-in exposing just.mapCanvas()): a real single-point layer moved the canvas off a deliberately stale extent; two real layers produced a real union extent containing both; a real WGS84 layer against a UTM-33N canvas produced a correctly-transformed extent (confirmed by magnitude -- meters, not degrees), confirming the realQgsCoordinateTransformpath actually ran. 8 new tests (tests/test_canvas_highlight.py). Full suite 1406 tests (up from 1398), 0 failures.docs/generate_tools_reference.pynot affected -- no new registered tool, a UI-layer behavior change only.
BUG-2026-09-11-1 | found 2026-09-11 | unreleased | medium | create_print_layout
(agent/tools/layout_tools.py), portrait orientation only. Root cause, confirmed live
against real QGIS 4.2.2 (python-qgis.bat, real QgsLayoutExporter.exportToImage, real PNG
inspected): portrait's body_h is hardcoded to 6mm — roughly one line of default-font text —
but QgsLayoutItemLabel does not clip content to its own box; any body_text longer than that
silently overflows downward and visually overlaps the fixed-position disclaimer footer
("AI-generated -- verify before operational, humanitarian, or safety use."), the one safety-
relevant element this whole feature exists to keep visible (see [1.6.0]'s and earlier
sessions' repeated emphasis on this exact footer). This directly contradicts the tool's own
schema description, which encourages multi-line body_text ("body_text accepts multi-line
text (use \n between bullets/findings for a summary panel)") with no portrait-specific caveat.
Landscape's body_h=78 has ample room and was not affected — confirmed via the same live
export in this session, landscape was not re-tested this pass but its much larger box makes the
same overflow far less likely for realistic body_text lengths. Box positions themselves are
correctly non-overlapping (body: 283–289mm, footer: 291–295mm, 2mm gap, on a 297mm page) —
the defect is overflow past the label's own box, not a geometry-placement bug. Not fixed this
session — the honest fix requires reallocating vertical space among map/legend/scalebar/body
in portrait mode, which is a real product tradeoff (how much legend space a real humanitarian
export needs vs. how much body-text room to guarantee), not a mechanical one-line correction;
flagged per this project's "flag a real tradeoff rather than guess" convention rather than
applied unilaterally. Repro: create_print_layout(title=..., page_orientation="Portrait", body_text=<a 3-sentence paragraph>, output_path=".../out.png"), inspect the exported PNG.
Fixed and live-verified the same day, 2026-09-11. Two changes, both in
agent/tools/layout_tools.py: (1) portrait's footer is now pinned to a FIXED distance from the
page bottom (footer_h=8, footer_margin=6) instead of being derived from body_y+body_h --
the footer now sits at a predictable page position regardless of body_text length; legend_h
trimmed 45->35mm to help fund a larger body_h, computed dynamically as whatever room remains
above the now-fixed footer instead of a hardcoded 6mm constant. (2) A real correctness
guarantee, not just a bigger budget: new _fit_text_to_box(text, box_w_mm, box_h_mm) -- pure
Python, no QGIS needed, calibrated from this session's own live render (one line of the
label's default font measured ~6mm tall and fit ~110 characters in a 180mm-wide box) --
truncates body_text (by whole words, with an ellipsis) to what the box can actually hold
before it's ever handed to QgsLayoutItemLabel. This is the real fix: since the label itself
never clips, only bounding the text content guarantees no overflow regardless of exactly how
generous the mm budget in either orientation turns out to be. 6 new tests
(tests/test_layout_tools.py, TestFitTextToBox). Full suite 1339 tests, 0 failures (up from
1333, the 6 new tests), 7 skipped. Live-verified against real QGIS 4.2.2 with the exact
same repro that found the bug (the same 3-sentence SITREP paragraph, same categorized-legend
polygon layer): the exported PNG now shows the body text cleanly truncated with an ellipsis,
a clear gap, and the disclaimer footer fully legible and unobstructed -- confirmed via direct
visual inspection of the real exported PNG, not just a geometry-value assertion. Status
fixed-verified. Landscape was not touched (its body_h=78 remains derived from
body_y+body_h as before, unchanged, since it was already live-confirmed clean 2026-09-05) --
_fit_text_to_box is NOT yet applied to landscape's body_text, since it was never the reported
failure mode; worth revisiting as a defense-in-depth follow-up if an unusually long body_text
ever gets reported there too, but not applied speculatively here.
Correction, 2026-09-11, same day: the paragraph above is wrong. _fit_text_to_box's call
site sits in the shared code path after the portrait/landscape if/else block closes, not inside
the portrait-only branch, so it already runs for landscape too, using landscape's own
(col_w=95, body_h=78) values -- there was no gap to revisit. Verified two ways before
correcting this record rather than trusting the code-reading alone: a direct call,
_fit_text_to_box("word " * 300, box_w_mm=95, box_h_mm=78), truncated 1500 chars to 750 with an
ellipsis; then live against real QGIS 4.2.2 with an extreme 2774-character body_text in a
real create_print_layout(page_orientation="Landscape", ...) call, exported to PNG and visually
inspected -- the paragraph truncated cleanly with an ellipsis and the disclaimer footer remained
fully legible and unobstructed. A regression test locking this in
(test_landscape_dimensions_also_truncate_an_extreme_body_text) added to
tests/test_layout_tools.py. No source code changed -- this was a documentation correction, not
a fix, since nothing was actually broken.
None otherwise currently open. BUG-2026-09-05-2 (the sole entry here as of 2026-09-08)
moved to "Fixed (recent)" below on 2026-09-10 once an actual root-cause fix was implemented for
both failure shapes, not just isolation, then live-confirmed 2026-09-11 against real QGIS
4.2.2 (collinear 1-segment network, L-shaped 2-segment network, and a realistic 5-segment grid
network regression guard — all three produced a real, correctly-built service area with no
crash and no silently skipped facility) — status now fixed-verified. See that entry for the
full history. Do not read an empty section here as "no bugs exist" — check
docs/RELEASE_SMOKE_TEST.md and the Obsidian task board for what still needs live-QGIS
confirmation.
Every other real code defect found during this project's review history
(across all rounds through v1.2.33) was fixed in the same session it was found, verified via
a real test run or direct execution, and recorded in CHANGELOG.md — see that file for the
full fix history (e.g. the gemini.py header-auth fix in v1.2.28, the two real bugs found and
fixed in route_optimization_prototype.py in v1.2.33). None of that history is duplicated
here; this file starts tracking forward from today.
Update 2026-11-05 (HY-2 sweep). "None currently" was true of the September entries above and is not true of the project: open defects are tracked as DEF-n rows in docs/MASTER_TASK_LIST_2026-10-27.md (with owner, priority and status), and the plugin faults a live QGIS run found are listed in docs/dashboard_data/defects.json and recorded per feature in docs/LIVE_RESULTS.json. New bugs go there; this file keeps the history of the ones fixed in September and October. Nothing above was re-verified in this sweep.
Recorded here specifically so a future session doesn't rediscover these and mistake them for new regressions — both are stable, understood, environment-specific artifacts, not code defects:
-
ModuleNotFoundError: No module named 'cartogen-ai-qgis-plugin-commercial-plugin-v1'(reported 2026-09-24, QGIS 4.2.2 / Python 3.12). Caused by installing GitHub's auto-generated "Source code (zip)" for releasecommercial-plugin-v1.16.0-rc4instead of its attached assetcartogen_ai_v1.16.0-rc4.zip. QGIS imports the zip's top-level folder name as a module, and GitHub names it<repo>-<tag>. The dots inv1.16.0-rc4make__import__treat everything after the first dot as a submodule. This fails before any plugin code runs, so the plugin can't guard against it. The attached asset (built byplugin_upload.py, foldercartogen-ai) is not affected. Fix: delete that folder frompython/plugins/and install the asset. The README's Installation section now warns about this. -
test_is_safe_url_accepts_public_host(1 failure) —tests/test_new_tools.py. Fails because this sandbox's DNS/network egress can't resolve a public hostname the way a real deployment environment can. Not a code defect; the SSRF-guard logic itself is not in question here. -
6 errors in
tests/test_reporting_tools.py— allPermissionErroronos.remove()cleanup for files namedscratch_test_*.csv/.docx/.pdfwritten to the repo root. This sandbox specifically cannot delete files matching that pattern once written (confirmed live, not assumed). Not a code defect — new tests should usetempfile.mkdtemp()/shutil.rmtree()instead of thescratch_test_*naming convention (seetests/test_attachments.pyfor the pattern that avoids this). -
Files on this FUSE-mounted tree cannot be
rm/os.remove'd (confirmed live, repeatedly,Operation not permitted), though they can be freely overwritten/truncated.df -T/stat -fconfirm this working tree is mounted viafuse/fuseblk(a Windows-host bridge), which is almost certainly the actual cause — a mount-layer quirk, not anything in this repo's code. Same root cause as thescratch_test_*entry above, but on 2026-08-21 it also hit git's own internals: agit commitfailed partway through (unable to unlink its own temp object files) and left an orphaned, empty.git/HEAD.lock, which then blocked every subsequent commit attempt for the rest of that session since the lock file itself couldn't be removed either. If this happens again: don't fight it by hand-editing git internals directly against the real.git/index— use a scratch copy of the index (GIT_INDEX_FILE=/tmp/scratch_index git add -Aetc., thenwrite-tree/commit-tree/update the ref by hand, then copy the scratch index back over the real one) — see the commits from 2026-08-21 in this repo's history for the exact sequence used. Correction, same day, later: the above was true as originally investigated, but incomplete — it was written after only testingrmand a cross-filesystemmv(moving a file to a different mounted folder), both of which do fail. A same-filesystemmv/rename (moving a file to a new path still inside this same mounted tree) was not tested until fixing BUG-2026-08-21-6, and it works:mv agent _legacy_stubs/agent_dir_testsucceeded outright. The actual restriction is on unlink (removing a path with nothing replacing it), not on rename — cross-filesystemmvfails for the same reasonrmdoes, since without a hard-link-style rename available it has to fall back to copy-then-delete-source, and the delete half is exactly the unlink this mount blocks. Earlier work in this repo's history (agent/ui/QGIS_AI_Agent_*.mdhandling from before this correction) used the more conservative "overwrite content with a stub,.gitignoreit,git rm --cachedit" pattern instead of a true move, because that's what the incomplete finding above supported at the time — that work is not wrong, just more conservative than it needed to be. Seedocs/archive/MULTITIER_REPO_ARCHITECTURE_SPEC.md§3 for where this correction mattered in practice.
Current baseline (as originally recorded above): 691 tests, 1 known failure + 6 known errors, 0 real defects, specific to the FUSE-mounted sandbox described above. If a full suite run in that same environment ever shows a different failure/error count or a different failing test name, that's real signal — investigate it, don't assume it's this same known baseline.
2026-08-22, different environment (native Windows filesystem, not FUSE-mounted, folium
0.20.0 installed): python -m unittest discover -s tests -t . -p "test_*.py" gives 691
tests, 0 failures, 0 errors, 1 skipped, in 13.874s. Neither the test_is_safe_url_accepts_public_host
DNS-egress failure nor the 6 PermissionError-on-cleanup errors reproduce here — consistent
with both being properties of the old FUSE mount, not of the code. This does not supersede the
baseline above (that one is still accurate for that sandbox); it's a second, separately-tracked
baseline for whoever is running tests on a normal local machine, so a clean run there isn't
mistaken for something broken having been silently fixed.
| ID | Found | Fixed | Severity | Summary |
|---|---|---|---|---|
| BUG-2026-08-21-1 | 2026-08-21 | v1.2.33 | medium |
route_optimization_prototype.py: ox.graph_from_bbox called with bbox tuple in the wrong element order for the installed osmnx version (2.0.7 wants (west, south, east, north), script passed (north, south, east, west)). |
| BUG-2026-08-21-2 | 2026-08-21 | v1.2.33 | low |
route_optimization_prototype.py: ox.distance.nearest_nodes() requires scikit-learn on an unprojected graph, which wasn't in the script's documented pip install line. |
| BUG-2026-08-21-3 | 2026-08-21 | v1.2.29 | medium |
agent/tools/vector_tools.py: calculate_area/calculate_length mutated the live layer's attribute table in place via the same _add_calculated_field primitive as field_calculator, but weren't gated behind the confirmed=True preview/confirm flow field_calculator already required for that same class of operation — a real inconsistency in the destructive-action safety gate, not just a missing feature. Both now follow field_calculator's exact PREVIEW_REQUIRED pattern (verified still present in current code: agent/tools/vector_tools.py lines 1375/1403, confirmed: bool = False + the PREVIEW_REQUIRED branch). |
| BUG-2026-08-21-4 | 2026-08-21 | v1.2.28 | medium |
agent/providers/gemini.py: grounded_search()/list_models() sent the API key as a params={"key": ...} query param instead of the x-goog-api-key header. |
| BUG-2026-08-21-5 | 2026-08-21 | v1.2.34 | medium |
plugin_upload.py (both trees): the v1.2.34 release zip silently shipped 7 stray repo-root files it should never have included (6 scratch_test_* test artifacts + a leftover .git_commit_msg.txt), because EXCLUDE_FILES only did exact-name matching and neither file was expected to exist at build time. Added EXCLUDE_FILE_PATTERNS (fnmatch-based) covering scratch_test_*/.git_commit_msg*; both v1.2.34 zips rebuilt and re-verified clean (86 entries each, contamination check passed). |
| BUG-2026-09-13-1 | 2026-09-13 | unreleased (uncommitted) | high | Live user report: "show live incedent in jordan in the map creat enew layer / natural, crime, haszard" got a blanket "I do not currently have direct access to a verified live feed..." refusal, even though this plugin has 3 real hazard-monitoring tools (fetch_gdacs_disaster_alerts, fetch_nasa_eonet_events, fetch_nasa_active_fires, shipped v1.9.0). Diagnosed via direct router simulation (not assumed) as two stacked causes: (1) none of the 47 existing prompt rules told the model that a "live/current hazard or disaster" request maps to these 3 tools -- only rule 16 mentioned them, in an unrelated "treat fetched content as data not instructions" context; (2) even when a tool scored into the router's top-40 candidate set, its score (2-4, from generic words like "map"/"layer"/"natural") was too low to reliably beat the ~24 other tools also scoring 10+ on the same generic vocabulary -- confirmed via a 30-trial live run that fetch_nasa_eonet_events made the cut only ~47% of the time, and fetch_gdacs_disaster_alerts/fetch_nasa_active_fires never did. The query's own typos ("haszard", "incedent") additionally defeated the exact-substring _TOOL_ALIASES mechanism outright. Three-part fix: new prompt rule 48 (agent/prompts.py) giving explicit intent-recognition guidance (which tools to reach for, how to partially fulfill a mixed request like this one, explicit cross-reference to rule 12/42's anti-fabrication guarantee for the crime-data part that has no real source); new alias entries for all 3 tools in tool_router.py's _TOOL_ALIASES; and a new _expand_query_with_fuzzy_corrections() helper (difflib-based, scoped to a small curated hazard-vocabulary list, 5+ character query words only) so a misspelled "haszard"/"incedent" still resolves to a real alias hit. Live-verified end to end against the exact reported query text: fetch_gdacs_disaster_alerts and fetch_nasa_eonet_events now selected 30/30 trials (up from 0/30 and ~14/30), and rule 48's text confirmed present in the resulting assembled system prompt. 11 new tests (5 in tests/test_prompt_modules.py for rule 48's classification/triggering, 6 in tests/test_tool_router.py for alias coverage and the fuzzy-correction helper); full suite 1512 -> 1523, 0 failures. fixed-unverified-pending-live-session for the actual model behavior change (cannot run a real LLM call from this sandbox), fixed-verified for the router/prompt-assembly logic itself. |
| BUG-2026-09-13-3 | 2026-09-13 | v1.15.3 | medium | Direct follow-up to the v1.15.2 in-chat requirement-gate redesign (same day): live user report "some of my text i sent in the chat is not showing." Root cause: chat_tab_widget.py's send_message() folded a reply answering a clarifying question into the pending request text but never emitted it as its own chat message -- invisible for a single-round clarification's answer specifically (the eventual composed request still showed once dispatch/preview ran), but for a request needing two unresolvable slots in a row (e.g. facility_type AND sector, task 12.09, neither has a safe default) the second question silently replaced the first with no trace the first answer was ever received. Fixed by echoing the literal reply into the chat log immediately, before the merge. Live-verified in real QGIS: a genuine 2-round clarification (facility type, then sector) now shows both replies as their own messages, in order. New test_multi_round_clarification_shows_every_reply_in_chat plus an added assertion on the existing single-round test in tests/test_chat_widget_live.py; full live-widget suite 8/8, full suite 1525 tests, 0 failures, re-verified against the actual built release zip. fixed-verified. |
| BUG-2026-09-13-2 | 2026-09-13 | v1.15.4 | high | Live user report, reproduced twice: a multi-tool-call turn ended in a chat bubble reading exactly Error: 'str' object has no attribute 'get'. Initial investigation (two faithful reproduction attempts, one under real QGIS 4.2.2 with Processing initialized and real layers created) confirmed the crash was an uncaught AttributeError escaping agent.run() entirely, but neither repro reproduced it -- meaning the trigger depended on something not captured by a scripted client. Requested a full audit of the tool-dispatch and .get() call surface, which found the real gap: agent.py's _execute_two_phase_tool (the dispatch path for fetch_worldpop_population and the 3 hazard-monitoring fetch tools, among others) has no exception handling of its own, unlike the regular dispatch path (_real_execute_tool, which wraps its function call in try/except) -- and agent.py's _execute_tool, the one call site all 3 dispatch paths funnel through, had no safety net either. Within that gap, 3 real network-fetch functions called .get() on a freshly json.loads()'d API response OUTSIDE their own try/except's coverage: fetch_nasa_eonet_events_network_phase/fetch_gdacs_disaster_alerts_network_phase (hazard_monitoring_tools.py), fetch_hdx_admin_boundaries_network_phase (humanitarian_tools.py) -- if any of these APIs ever returned valid JSON that wasn't a dict (a bare string/list/null error body; GDACS's own docs already warn its data "may require further validation"), the .get() call raised uncaught, with nothing catching it before it escaped the whole tool-calling loop. Fixed in two parts: a general safety net in _execute_tool (any exception from any dispatch path now becomes a normal {"error": ...} result, closing this class of bug for every tool, present or future) plus proper isinstance(data, dict) guards at the 3 specific sites (and fetch_building_footprints_network_phase's per-line parse, same pattern, lower risk) for a clear, attributable error message. Also fixed a matching but structurally different gap in chat_tab_widget.py's _analyze_image (already caught by its own surrounding try/except, so not this exact bug, but the same risk class). 5 new tests reproducing the exact malformed-response scenarios and the general safety net; full suite 1525 -> 1530, 0 failures, re-verified against the actual built release zip. fixed-verified for the code paths (the specific external-API-returns-malformed-JSON trigger condition remains empirically unconfirmed against real historical GDACS/EONET/HDX responses -- structurally sound, not yet observed in the wild independently of this live report). |
| BUG-2026-09-13-4 | 2026-09-13 | v1.15.5 | high | Three more live reports of the exact same crash TEXT as BUG-2026-09-13-2 (Error: 'str' object has no attribute 'get') arrived right after that bug's fix (v1.15.4) shipped -- but with 3 completely different tool sequences each time (none overlapping with the hazard/HDX tools that bug fixed), and this time confirmed running on a Google Gemini-configured session. Three fresh reproduction attempts (a scripted client, a real-QGIS run with real layers actually created, and a run through the genuine cross-thread ToolDispatcher/BlockingQueuedConnection mechanism a live session actually uses -- the first two rounds had never actually exercised this real threading path, having patched _on_dispatcher_thread to bypass it) all came back clean against the reported tool sequences -- correctly ruling out every individual tool and the dispatch machinery a second time, since the common factor across all 3 reports wasn't a tool, it was the provider. Root-caused by auditing Gemini-specific code added since the last audit: providers/base.py's extract_openai_style_usage (new in v1.15.0's Gemini-caching-visibility work, so not covered by BUG-2026-09-13-2's audit) extracted the cache-hit count with (usage.get("prompt_tokens_details") or {}).get("cached_tokens") -- safe only when the field is falsy; a TRUTHY non-dict value (a bare string being the likely real shape for an OpenAI-compat-shim quirk) makes x or {} evaluate to x itself (or short-circuits on the first truthy operand), and the following .get() raises uncaught -- confirmed with a one-line repro producing the byte-identical error text: ("some string" or {}).get("cached_tokens"). Fixed with an explicit isinstance(details, dict) check. This is the shared usage-extraction point for all 4 OpenAI-style providers (Gemini, OpenAI, OpenRouter, the hosted-gateway prototype), so the fix protects all of them, not just Gemini. New regression test confirms the malformed shape now degrades to "no cache-hit info" instead of crashing the whole turn; full suite 1530 -> 1531, 0 failures, re-verified against the actual built release zip. fixed-verified. Process note: the exact error text recurring after a fix shipped does not mean the fix failed -- verify the SPECIFIC line that was patched is still intact before assuming that; here it was a second, unrelated bug sharing the same generic Python exception message. |
| BUG-2026-09-24-1 | 2026-09-24 | unreleased | medium | Live report (QGIS 4.2.2, v1.16.0-rc4): add_layer_from_path with a URL failed as Invalid layer: C:\Users\...\Temp\tmp_2kgn_72.geojson. The download itself had worked. agent_orchestrator.py's two-phase dispatch saves the URL to a temp file and then calls add_layer_from_path with only that temp path, so (1) the error named the temp copy instead of the URL, (2) a load with no layer_name was named after the temp file (e.g. tmp_2kgn_72), and (3) nothing said what GDAL was handed, so neither the user nor the model could correct the URL. Fixed: the dispatcher now passes the original URL as source_label (not in the tool schema, so the model can't set it), and a failed URL load runs _describe_unreadable_download() on the file's first bytes. It reports an empty file, a zip, an HTML page (with a hint to use the raw.githubusercontent.com link instead of a GitHub /blob/ page), XML, JSON that isn't GeoJSON (top-level key names only, never values), a JSON array, or a FeatureCollection with no features. Local-file errors are unchanged. 8 new tests (6 in tests/test_vector_tools_extended.py, 2 in tests/test_agent_runner.py, both confirmed to fail against the old dispatcher code); full suite 2030 -> 2038, 0 failures. The actual URL in the live report is unknown, so which content it served is unconfirmed; this fix makes the next occurrence self-explaining rather than fixing that specific URL. fixed-unverified (not yet run in a live QGIS session). |
| BUG-2026-09-24-2 | 2026-09-24 | unreleased | medium | Live report: "Buffer 5 km around active GDACS alerts 5189969,1563615" burned 20 tool calls (repeated execute_pyqgis_script/search_web/get_attributes) and hit the per-request tool-call limit before finishing. Root-caused as two compounding gaps: (1) fetch_gdacs_disaster_alerts's JSON result includes each alert's GDACS eventid (as unit), but add_gdacs_disaster_alerts_layer_main_thread_phase never stored it as an attribute on the created point layer -- a request naming specific alert IDs had no field to select them by, and NASA EONET's equivalent tool had the identical gap (event id in the JSON, never on the layer). (2) Even with a selection, buffer_analysis always buffered every feature in the layer (INPUT: layer with no selection scoping) -- so "buffer just these 2 alerts" could not be expressed even after selecting them; the model had no way to isolate the operation to specific features at all, for any layer. Fixed: both fetch_gdacs_disaster_alerts and fetch_nasa_eonet_events now store the event id as an event_id field on their layers (both tools' descriptions now say so, with the select_by_attribute/highlight_features pattern to use it); buffer_analysis gained only_selected (default False, matching every existing caller unaffected) which wraps the processing input in QgsProcessingFeatureSourceDefinition(layer.id(), selectedFeaturesOnly=True) -- same fail-loudly-if-nothing-selected convention as export_tools.py's _write_vector/only_selected, not a silent fallback to the whole layer. Also fixed in passing: GDACS's str(props.get("eventid")) turned a genuinely missing eventid into the literal string "None", both in the tool's JSON result and (now) the layer's event_id field -- now an empty string. The intended workflow for "buffer around alert X,Y" is now 2 tool calls (highlight_features with event_id IN (...), then buffer_analysis(..., only_selected=True)) instead of the ad-hoc script/search loop this report shows. 6 new tests (tests/test_hazard_monitoring_tools.py, tests/test_vector_tools_extended.py), all confirmed to fail against the pre-fix code; full suite 2222 -> 2228, 0 failures, ruff check . clean. Not verified: a live QGIS session actually running this exact request end to end -- the tool-call-count improvement is inferred from the fix closing the two concrete gaps found, not measured against a second live run. fixed-unverified. |
| BUG-2026-09-24-2 | 2026-09-24 | unreleased | high | Live report (QGIS 4.2.2): after Install from ZIP of v1.16.0-rc5 over a running rc4, opening the panel failed with "cannot import name 'SETTINGS_PROJECT_INSPECTOR_ENABLED' from 'cartogen_ai.infrastructure.settings_keys'", although the rc5 file on disk defined it. Root cause: the root __init__.py bootstrap evicted cached cartogen_ai.* modules only when the cached package came from a different path. An in-place upgrade reuses the same folder, so rc4's settings_keys stayed in sys.modules while rc5's freshly imported modules asked it for new names. QGIS's unloadPlugin only drops modules named after the plugin folder (cartogen-ai), never the separate cartogen_ai.* tree. Reproduced outside QGIS by extracting the real rc4 zip, importing it, then extracting the real rc5 zip over it (same ImportError). Fixed by evicting unconditionally on every (re)load (a no-op on first load). Repro passes with the fix, including upgrading from rc4, since the new version's __init__.py is what runs. New tests/test_plugin_bootstrap_upgrade.py drives the real __init__.py through the same sequence; it fails with the original ImportError without the fix. Workaround on rc5 and earlier: restart QGIS after upgrading. Not yet verified through the real QGIS Plugin Manager upgrade flow (§1.10). fixed-unverified-live. |
| BUG-2026-09-24-3 | 2026-09-24 | unreleased | high | Live report (QGIS 4.2.2, rc5): "Health facilities beyond one hour's travel" near Amman drew the Road Network as a dense blob of points and stopped at the 20-call limit. ingest_osm_features asks Overpass for matching ways plus >; out skel qt; (every node of those ways, untagged), then turned every element into a Point. Checked against real Overpass data at that location: amenity=hospital|clinic gave 24 real facilities but a 112-point layer (88 building-outline corners labelled as hospitals); highway=primary|secondary in a 2 km box gave 81 ways + 802 vertices, so 883 points and no lines. A point "road network" then went through calculate_service_area as a success with meaningless output, and the model spent its remaining budget on execute_pyqgis_script workarounds. Fixed: untagged vertex nodes are used only as coordinates; ways under linear keys (highway/railway/waterway/aerialway) become LineStrings; other keys keep one point per feature (the way's centre); the result now reports geometry_type. calculate_service_area, travel_time_matrix and optimize_delivery_route refuse a non-line network with an error naming the fix. Live-verified in real QGIS 4.2.2 against real Overpass data at the reported location: roads load as 1,009 lines, facilities as 24 points, service area and travel-time matrix run on the line layer, and a point layer is refused (6/6). New tests use Overpass-shaped responses (with the untagged vertices); on the old code they fail with 5 != 2 and 6 != 2. fixed-verified. |
| BUG-2026-09-24-4 | 2026-09-24 | unreleased | medium | Same live report: the prompt preview said "Assumed, because you did not specify: threshold = 5 km" for "beyond one hour's travel", and the plan then read "(1 hour / 5 km)". task_matcher.py's threshold evidence pattern needed digits and singular units (hour\b can't match "hours"), so "one hour", "2 hours", "30 minutes", "half an hour" all counted as unspecified. Fixed: number words and plurals are recognised; no false positives on "I am here", "a map of clinics", "one more layer". New test_spelled_out_and_plural_thresholds_count_as_given fails on the old pattern. fixed-verified (unit level; the preview text follows directly from missing_slots). |
| BUG-2026-09-24-5 | 2026-09-24 | unreleased | high | Found while live-verifying BUG-2026-09-24-3; measured and fixed 2026-09-25. The network tools' costs were not real metres. Root cause, measured in real QGIS 4.2.2 on a road of known length (10 km, origin in the middle): the network algorithms measure distance with the processing context's ELLIPSOID and fall back to raw layer units without one. calculate_service_area built a bare QgsProcessingContext(), which has no ellipsoid whatever the project's setting, so a 1000 m service area covered the whole 10 km network on a lat/long layer (travel_cost read as degrees) and reached only 1,699 m on EPSG:3857 at Amman (686 m at 70N, 348 m at 80N: Web Mercator inflates distances by 1/cos(latitude)); only UTM was right. travel_time_matrix and the shortest-path calls used processing.run's default context, which takes the project's ellipsoid, so they were right only when the project had one (QGIS new projects do; imported or older ones may not: 0.042 'metres' for a 4 km route with none). A second bug: START_POINT was built from the facility/origin/stop layer's own coordinates, which the algorithms read in the NETWORK's CRS, so an origin typed as Web Mercator metres against lat/long roads found no route at all. Fixed: _network_context() gives every network call a context with the project attached and an explicit ellipsoid (the project's, else WGS84; 'NONE' is QGIS's truthy sentinel and is checked by value); _point_for_network() transforms every start/end point into the network's CRS and raises if it can't, instead of routing from the wrong place. Tool descriptions now say metres (or hours for strategy='fastest') instead of "network CRS units, usually meters", and results carry travel_cost_unit / cost_unit / distance_unit. Also covers optimize_delivery_route and population_access_gap. Corrections to my own earlier reading: setProject does not overwrite an explicit ellipsoid (checked: order is irrelevant) and does not by itself give the context the project's ellipsoid, so the explicit call is what matters; and the ellipsoid is not what makes these tools slow (see BUG-2026-09-25-2). Verified: new tests/test_network_units_live.py (real QGIS, in the CI live runner): every combination of network CRS (4326, 3857, UTM) x project ellipsoid (none, WGS84) x latitude (32N, 70N, 80N), fastest vs shortest, origin/stop layers in a different CRS than the network (4 pairings), delivery-route distance, and a road crossing the antimeridian (EPSG:3832); on the original code it fails on the numbers (whole network reached, 1,699/686/348 m, 8e-07 'hours', no route); plus 8 unit tests for the two helpers (2 mutation-checked). And the real workflow, tests/manual_live_checks/one_hour_travel_workflow.py: the real Jordan extract, the origin typed as Web Mercator metres in a project with no ellipsoid: 206 of 355 health facilities within one hour, 147 beyond, farthest 6.4 h, 1-hour reach 46.4 km (about 50 km of road at 50 km/h); every route longer than the independent straight-line distance (min 1.03x, median 1.18x over 351 facilities); transcript in docs/release_smoke_assets/logs/one_hour_workflow_live_2026-09-25.txt. Also verified on the INSTALLED plugin (10/10: roads/points/ellipsoid combinations including the reported one). fixed-verified. |
| BUG-2026-09-24-6 | 2026-09-24 | unreleased | high | Live re-test of the same request after BUG-2026-09-24-3/4 were patched in: ingest_osm_features got HTTP Error 504 four times, the model then tried geocode_batch four times and hit the 20-call limit. ingest_osm_features had no retry, so every Overpass hiccup cost a model tool call. Measured that evening against overpass-api.de: 5 of 12 requests spaced 3 s apart succeeded (six 504s, one 429), with failures scattered. Public alternative servers were checked as fallbacks and rejected (one ~40 s, one timed out, one failed TLS verification). Fixed: the request now goes through the shared urlopen_with_retry (new backoff_seconds parameter) with 4 attempts and 3/6/9 s waits. A persistent overload returns an error telling the model not to retry or rebuild the data another way this turn (retryable_later: True); a non-transient error such as 400 is not retried. Point-type queries no longer ask for way vertex nodes (live: 100 elements down to 24 for the same hospitals). Live re-run against the real server: 6/6 tool calls succeeded, 3 of them only after 2-3 attempts inside the tool. New TestOverpassResilience; 3 of its 4 tests fail on the old code. fixed-verified. |
| BUG-2026-09-25-1 | 2026-09-25 | unreleased | high | Cost review (an external review of the API-cost plan flagged it; reproduced here): automatic model selection never took effect on Gemini, OpenAI, OpenRouter or Cartogen. agent_orchestrator._apply_auto_model_selection assigns client.model = picked, but those clients loop over a fallback list built once in __init__ (configured model first) and overwrite self.model with each entry, so the configured model, on OpenAI the priciest (gpt-5.6), was sent for every request. Fresh installs default to auto for these providers, so default installs paid top-tier prices for trivial requests. Reproduced by setting client.model to a made-up id and recording what _post received: always the configured model, on all three providers tried; Claude and Ollama use self.model directly and were never affected. Making the pick take effect exposed a second bug, found by running the picker on a real cached Gemini list from a user's profile: keywords matched by substring, so mini hit every gemini-*, and a complex request would have gone to deep-research-max-preview-04-2026 (matches max), a research-agent model that is not a chat model; simple requests went to the oldest gemini-2.0-flash-lite. Fixed: (1) ModelChainMixin in providers/base.py: a model assigned from outside is tried first and fallbacks follow, and a fallback does not stick to the next call. (2) The picker matches whole name tokens, never picks special-purpose models (deep-research, robotics, music, computer-use, codex, realtime, ...), prefers mid-tier flash/mini/haiku over the weakest lite/nano, and breaks ties by a -latest alias, then non-preview, then newest version. (3) Policy: auto is a cost saver, not an escalator. It never selects a capable-tier model: a simple request may get a smaller model, every other request uses the built-in default, and a default that is already a small model is left alone. The escalation target was a guess from the name and on real lists was gpt-5-pro or a research model. "Smaller" is inferred from the model NAME (mini, flash, haiku), not from price, so this does not guarantee a lower bill on every provider or account. Special-purpose models are never picked without exception: if a list has no other model the picker returns nothing and the default is kept. Ties go to a non-preview model first, then a -latest alias, then the newest version. Tool-calling support is not checked, only chat eligibility (a tracked follow-up). Behaviour change to be aware of: for a Gemini install whose default is already gemini-flash-latest, auto is now correctly a no-op (nothing cheaper to step down to); the saving applies to providers whose default is an expensive model (OpenAI, Cartogen). Claude: a fresh install already routed, and still does (haiku for simple, opus default otherwise); an explicit model choice is never overridden. Verified: 20 new tests (per-provider wiring, fallback, all-404, the picker on a real 31-model Gemini list, the orchestrator policy, and an end-to-end test that checks the model in the outgoing request payload; 4 fail with the provider wiring reverted, including ['gpt-5.6', 'gpt-5.6'] where ['gpt-5-mini', 'gpt-5.6'] is expected); full suite 2,162 passing, 51 live-QGIS tests passing; and against the INSTALLED plugin in real QGIS 4.2.2 with the user's real settings (Gemini: right model on every request, no special-purpose model) plus a simulated OpenAI default install (gpt-5-mini for simple requests, gpt-5.6 for a complex one, no sticking): 14/14, from the re-runnable tests/manual_live_checks/model_selection_installed.py, transcript saved in docs/release_smoke_assets/logs/model_selection_live_2026-09-25.txt. A code review of PR #28 found and this fix corrected: an all-special-purpose list fell back to the whole list (so nano-banana-pro-preview could be picked as cheap), and a -latest-preview name outranked a stable versioned model. Not verified: billed cost against a real provider account. fixed-verified for the routing; the actual saving is unmeasured. |
| BUG-2026-09-25-2 | 2026-09-25 | unreleased (freeze fixed; service areas fast; matrix speed open) | high | Found running the real one-hour workflow on the full Jordan OSM extract (161,041 roads, the network the 'download local data' step loads): travel_time_matrix took 572 s and calculate_service_area 364 s for one origin. Ordinary tools run on QGIS's GUI thread (ToolDispatcher, BlockingQueuedConnection), so QGIS freezes for that long. Pre-existing (the same graph build ran before BUG-2026-09-24-5 was fixed) but now easy to hit. Measured with native:serviceareafrompoint on subsets of the real network: 39,017 roads 25 s, 68,123 roads 60 s, 107,719 roads 125 s; the ellipsoid makes no difference (planar vs ellipsoidal within 2 s), so the cost is building the graph from every road, per call. Freeze fixed 2026-09-25 (speed not): see below. Options considered: (a) clip the network to the needed extent before routing: exact for a service area (anything reachable within cost R is within straight-line R of the origin; for hours, R = hours x the fastest speed in play), not exact for a matrix (a route can detour outside the bounding box of its endpoints), so it needs a padded box and a fallback; (b) run network analysis off the GUI thread (a QgsTask) with progress and Stop; (c) warn and ask before routing over a network above a size threshold. Recommendation: (a) for service areas plus (c) for everything, then (b). What was done: (b) and the Stop button. Networks of 2,000 roads or more are routed by QgsProcessingAlgRunnerTask on a worker thread while the tool spins a nested Qt event loop on the GUI thread (tools/_background_processing.py); agent.run() publishes the Stop check and a status callback for the request (cancel_signal.py), so the tool notices Stop and shows progress. A Stop propagates as AnalysisCancelled and is never recorded as a skipped facility or an unreachable pair; a stopped run adds nothing to the project. Small networks, other threads and the kill-switch setting cartogen_ai/network_analysis_in_background = false use the old synchronous call. Prototyped on the real 39,017-road network first (293 UI ticks in 18.5 s, worst gap 77 ms; cancel returned 0.0 s after it was requested). Verified: 9 live tests in real QGIS (identical results to the synchronous path; UI keeps ticking; the synchronous control does not; prompt Stop that leaves the project alone; stale Stop cannot cancel a later run; progress; failure messages; kill-switch; only on the GUI thread) and 16 QGIS-free tests, mutation-checked; and the FULL 161,041-road network: Stop after 30 s returned in 30.1 s with the GUI ticking (274 ticks, worst gap 125 ms); a full one-hour service area ran 331 s with 2,978 ticks, worst gap 125 ms, and the same 46.4 km reach. Transcript: docs/release_smoke_assets/logs/network_no_freeze_live_2026-09-25.txt. fixed-verified for the freeze. Speed, service areas (fixed 2026-09-25): calculate_service_area routes only over roads that can be reached: _clip_network_to_reach keeps every road intersecting a disc of the reach (metres, or hours x the fastest speed in the network for strategy='fastest') around the start, built in an azimuthal-equidistant CRS so it is exact in any network CRS, with a 3% + 100 m margin. Exact because nothing reachable within cost R is farther than R in a straight line and whole roads are kept. It steps aside (whole network, as before) for networks under 2,000 roads, a box that still holds 90%+ of the roads, a reach over 5,000 km, an unreadable speed_field maximum, no road within reach (QGIS snaps a far start to the nearest road and a clip could change which), or any error. Real Jordan network, clipped vs all roads, identical geometry in every case: Amman 3 km 2.3 s vs 176 s (76x), Irbid 3 km 1.2 s vs 171 s (148x), Amman 10 km 16 s vs 174 s (11x); the one-hour service area 93 s vs 331 s with the identical 46.42 km reach. 9 live tests (network CRSs, speed field, several bands and facilities, off-road start, reach beyond the network, unreadable max speed; 4 mutations caught, after strengthening a first grid too sparse to tell a wrong clip from a right one) and 12 unit tests for the reach bound and each fallback. Transcript: docs/release_smoke_assets/logs/network_clip_real_2026-09-25.txt. Non-drivable roads (footway, path, steps, pedestrian, cycleway, bridleway, busway, unknown: 6,146 of 161,041) are now left out of the loaded road layer by a layer filter: routing treats every line as drivable, so a route could climb steps at 50 km/h; a correctness fix that also removes 3.8% of the work (the one-hour reach moved 46.4 to 46.1 km because of it, checked). fixed-verified. Still open: (1) travel_time_matrix is not clipped (a route can detour outside the box around its endpoints, so a clip would not be exact): 473 s on the full network, responsive and stoppable but slow; (2) optimize_delivery_route rebuilds the graph for every stop pair (n squared) where one shortest-path call per stop would do; (3) a dense urban network is inherently ~100k roads for a one-hour reach. Update 2026-09-27, item (2) partially addressed: _build_network_distance_matrix (logistics_tools.py) now mirrors matrix[j][i] from the already-computed matrix[i][j] instead of making its own native:shortestpathpointtopoint call, whenever no direction_field is given -- STRATEGY is fixed at 0 (shortest, not time-based) for every call here, so with no one-way data the network is undirected for this purpose and a route is genuinely reversible; halves the call count (n(n-1)/2 instead of n(n-1)) for the common case of a plain road layer with no one-way attributes, unchanged when direction_field is set (one-way streets can make A->B and B->A real different routes, so mirroring there would be wrong, not just slower). Does NOT attempt the bigger "one shortest-path call per stop" rewrite this note names: that needs native:shortestpathpointtolayer per stop instead of point-to-point, which _build_network_distance_matrix's own docstring already tried and rejected once (destination-side matrix keys came from an internal coordinate-string field that didn't reliably map back to a specific stop, confirmed live) -- re-attempting that via geometry-based nearest-stop matching is plausible but unverified without live QGIS, and given this drives a delivery ROUTE's visiting order, a wrong silent mismatch is worse than today's slower-but-correct O(n^2)-halved behavior, so it's flagged here rather than attempted blind. Item (1) (travel_time_matrix clipping) also not attempted: unlike calculate_service_area's disc-based clip, there is no cost threshold here to bound a clip radius by (this tool returns unbounded shortest-path costs, not "everything within cost R"), so no exact clip is available with the same reasoning that made service area's clip provably exact -- confirmed by re-reading _clip_network_to_reach's own docstring, not new information. 6 new tests in tests/test_logistics_tools.py (TestBuildNetworkDistanceMatrix) confirm the call-count halving and that a direction_field keeps the full O(n^2) count; full suite 2229 -> 2238 (also includes BUG-2026-09-25-3's fix below), 0 failures, ruff check . clean. fixed-unverified for the halving (no live QGIS session in this environment to re-time it against the real Jordan network); items (1) and (3) remain open exactly as before. |
| BUG-2026-09-25-3 | 2026-09-25 | open | high | Found while measuring the real Jordan network: maxspeed is set on only 1,504 of 161,041 roads (0.9%; Geofabrik's oneway is set everywhere). With strategy='fastest' every other road therefore uses the flat default_speed (50 km/h): motorways and 30 km/h residential streets alike, and a one-hour reach or a 'beyond one hour' answer is only as good as that. The road class is there (fclass: residential 62%, service 10%, unclassified 8%, tertiary 5%, track 4%, secondary 1.4%, trunk 1.3%, primary 1%, motorway 0.1%). Not fixed: assumed speeds by road class (e.g. motorway 100, trunk 80, primary 70, secondary 60, tertiary 50, residential 40, service and track 20, in the style of common routing profiles) written to a speed field when the roads are loaded and passed as speed_field would make travel times far more realistic, but it changes results and the table is a judgement call, so it needs a decision (and ideally a check against a known journey). Update 2026-09-27: built as a new opt-in tool, estimate_road_speeds (logistics_tools.py), rather than a silent default -- per this entry's own framing, the assumption table is a judgement call this tool doesn't get to make unilaterally for the user. Writes an assumed_speed_kmh field from ASSUMED_SPEED_BY_ROAD_CLASS_KMH (the exact table this entry names, plus _link variants and a few non-motor classes), keyed off fclass (Geofabrik extracts) or highway (OSM/Overpass ingests) -- confirmed both use the same OSM road-class vocabulary. Never called automatically by calculate_service_area/travel_time_matrix/optimize_delivery_route; the model must call it explicitly and its own tool description says the result is an assumption, not this network's real posted limits, and to tell the user so. Follows the same PREVIEW_REQUIRED/confirmed=True gate as calculate_area/calculate_length (in-place attribute mutation on a live layer). Default overwrite=False only fills features with no existing value, so it composes safely with real maxspeed data where present. 7 new tests in tests/test_logistics_tools.py (TestEstimateRoadSpeeds: confirmation gate, missing layer, missing class field, non-line layer, correct per-class speed assignment, unknown-class fallback to default_speed_kmh, overwrite=False skip behavior); docs/TOOLS_REFERENCE.md regenerated (178 -> 179 tools); full suite 2229 -> 2238, 0 failures, ruff check . clean. Not checked against a known real journey time, exactly the caveat this entry itself asked for -- the table is public routing-profile convention (OSRM/GraphHopper-style car profiles), not this project's own measurement of any specific road network; fixed-unverified-pending-live-session for both the tool's live QGIS behavior (startEditing/changeAttributeValue/commitChanges on a real layer, unverified beyond mocked unit tests, same limitation as every other QGIS-editing tool in this sandbox) and the speed table's real-world accuracy. |
| BUG-2026-08-21-6 | 2026-08-21 | v1.4.1 | high |
cartogen_ai.py (repo root, this repo only — introduced by the same-day namespace-package restructure, not present before it): its filename collided with the new cartogen_ai.core namespace package under src/. Since both the repo root and src/ end up on sys.path, and a regular module anywhere on sys.path always wins over a namespace-package portion regardless of path order, every from cartogen_ai.core.agent... import X resolved to this file instead — confirmed via python -m unittest discover, which failed 31 tests with ModuleNotFoundError: No module named 'qgis' (this file's own unconditional qgis.PyQt import executing where the namespace package was expected). Fixed by renaming the file to plugin_main.py (pure rename, no behavior change) and updating __init__.py's classFactory() to import from it; see docs/archive/MULTITIER_REPO_ARCHITECTURE_SPEC.md §3.1 for the full writeup. Re-verified: full py_compile and python -m unittest discover both clean after the fix (see that run's output for the exact pass count). |
| BUG-2026-09-02-1 | 2026-09-02 | unreleased (uncommitted) | high | Cross-checked the codebase against QGIS's own official Qt5/Qt6 plugin-migration documentation (plugins.qgis.org/docs/migrate-qgis4, github.com/qgis/QGIS/wiki/Plugin-migration-to-be-compatible-with-Qt5-and-Qt6) ahead of a QGIS 4.2 demo. Found 5 unscoped-enum/removed-API usages that the wiki explicitly names as Qt6-breaking (QGIS 4 uses real Qt6/PyQt6, which requires scoped enum access): Qt.UserRole (6x, ui/tasks_tab_widget.py) needs Qt.ItemDataRole.UserRole; QMessageBox.Yes/.No (4x each, same file) need QMessageBox.StandardButton.Yes/.No; QgsMapLayer.RasterLayer (1x, agent/tools/styling_tools.py) needs QgsMapLayer.LayerType.RasterLayer; QgsWkbTypes.PolygonGeometry/PointGeometry/LineGeometry (11x across agent/tools/logistics_tools.py, raster_tools.py, styling_tools.py, vector_tools.py) need the QgsWkbTypes.GeometryType.* form; and .exec_() (1x, ui/dock_widget.py, removed outright in PyQt6, not just deprecated) needs .exec(). All replacements use the exact dual-compatible form the QGIS wiki documents, so QGIS 3.x (Qt5) support is unaffected. Fixed by scoping every flat reference; re-verified with python -m py_compile (clean) and the full suite (699 tests, same known 1-failure/6-error/14-skip sandbox baseline as before the change, 0 new failures) — but the Qt.UserRole/QMessageBox.* fix in tasks_tab_widget.py and the .exec_() fix in dock_widget.py have zero automated test coverage (these Qt-widget files import qgis.PyQt/qgis.core unconditionally and cannot be imported outside a real QGIS process, matching this project's existing convention for dock_widget.py's tab-widget split) — status fixed-unverified for those two files specifically pending a real QGIS session check; fixed-verified for the QgsMapLayer/QgsWkbTypes fixes in agent/tools/, which the suite exercises via 3 updated stale mocks in tests/test_styling_tools.py (2 occurrences) and tests/test_vector_tools_extended.py (2 occurrences) that still set the pre-fix flat mock attribute. Also separately noted, NOT fixed: QVariant.Double/.String/.Bool used in several QgsField() constructor calls (analysis_tools.py, imagery_extraction.py, vector_tools.py) are deprecated since QGIS 3.38 in favor of QMetaType.Type.*, per qgis.org/pyqgis/master/core/QgsField.html's own deprecation note — confirmed this still works without error on Qt6 (just prints a deprecation warning), so left as a lower-priority modernization item, not blocking today's demo. |
| BUG-2026-09-02-2 | 2026-09-02 | unreleased (uncommitted) | critical | Live QGIS 4.2 test (run by the user right after BUG-2026-09-02-1's fix was packaged) failed opening the dock panel at all: AttributeError: type object 'Qt' has no attribute 'RightDockWidgetArea'. This exposed that BUG-2026-09-02-1's sweep was NOT exhaustive -- it only checked the specific patterns named in QGIS's migration wiki, not every flat Qt.* enum access in the codebase. A full grep for every Qt.<Attr> usage surfaced 5 more real Qt6-breaking flat accesses, all fixed with the same scoped form: Qt.RightDockWidgetArea/Qt.LeftDockWidgetArea -> Qt.DockWidgetArea.* (ui/dock_widget.py:48, the confirmed crash site, hit on every dock-panel open); Qt.RichText -> Qt.TextFormat.RichText (ui/tasks_tab_widget.py:231); Qt.Key_Return/Qt.Key_Enter -> Qt.Key.* and Qt.ShiftModifier -> Qt.KeyboardModifier.ShiftModifier (ui/chat_tab_widget.py:36, the chat input's Enter-to-send handler); Qt.BlockingQueuedConnection -> Qt.ConnectionType.BlockingQueuedConnection (agent/agent.py:95-96, the tool-dispatch cross-thread connection every tool call goes through -- also updated that file's QGIS_AVAILABLE=False test-stub Qt class to expose both the flat and nested attribute so the existing mocked test path keeps working). All five are zero-automated-test-coverage call sites (three import qgis.PyQt unconditionally with no fallback; agent.py's stub path never exercises the real qgis.PyQt.Qt object) -- status fixed-unverified-pending-retest: applied and syntax-checked, full suite re-verified clean (699 tests, 1 known failure, 0 errors -- better than the previously documented 6-error baseline, because delete permission granted earlier in this session let scratch-file cleanup succeed), zip rebuilt, but NOT yet re-confirmed inside a live QGIS 4.2 session. Given one exhaustive-looking sweep already missed 5 real breaks, treat BUG-2026-09-02-1 and this entry together as needs a real QGIS 4.2 smoke test before the plugin is trusted for the 2026-09-02 presentation, per docs/RELEASE_SMOKE_TEST.md. |
| BUG-2026-09-02-3 | 2026-09-02 | unreleased (uncommitted) | critical | Second live QGIS 4.2 crash the same afternoon, right after BUG-2026-09-02-2's fix: AttributeError: type object 'QScrollArea' has no attribute 'NoFrame'. Two prior sweeps both scoped their search to the Qt class itself (Qt.<Attr>); neither checked flat enum access on other Qt widget classes. Widened the sweep to every Q<Class>.<Attr> pattern across the whole plugin and cross-checked each one against what it actually is (static method vs. real flat enum member vs. a false-positive text match inside a comment/docstring -- QFont.Italic and QgsLegendStyle.Item in agent/tools/layout_tools.py's comments are the latter, not code, left alone). Found and fixed 5 more real Qt6-breaking flat accesses: QScrollArea.NoFrame -> QScrollArea.Shape.NoFrame (ui/dock_widget.py:114,121, the confirmed crash, hit on every dock-panel open right after BUG-2026-09-02-2's dock-area fix); QDialogButtonBox.Ok/.Cancel -> QDialogButtonBox.StandardButton.* (ui/settings_dialog.py:271, the Settings dialog's OK/Cancel row) and .Close (ui/account_dialog.py:76); QLineEdit.Password -> QLineEdit.EchoMode.Password (API-key fields in ui/settings_dialog.py:183 and ui/account_dialog.py:44,48); and 8 occurrences of QPalette.Window/.AlternateBase/.Base/.WindowText/.Highlight/.HighlightedText/.Mid -> QPalette.ColorRole.* plus QPalette.Disabled -> QPalette.ColorGroup.Disabled, in the live-theme-palette extraction duplicated identically in ui/theme.py and ui/settings_dialog.py:_extract_theme_palette() (feeds every dock/dialog stylesheet and the chat bubble colors -- would have broken theming on first dock-widget paint). ui/account_dialog.py and the _extract_theme_palette duplicate in ui/settings_dialog.py are part of the separate, pre-existing, not-yet-committed 'account' feature this session has no other context on -- fixed the Qt6 break there too since it ships in the packaged zip regardless of git commit state, but left everything else in those two spots untouched. All test-suite-invisible for the same reason as BUG-2026-09-02-1/-2 (unconditional qgis.PyQt imports); full suite re-verified clean (699 tests, same 1 known failure, 0 errors) and zip rebuilt. Separately: while widening the sweep, also found ~15 flat enum accesses on QGIS's own native classes (not generic Qt/PyQt6 widget classes) -- QgsGraduatedSymbolRenderer.EqualInterval/Jenks/Quantile, QgsColorRampShader.Interpolated, QgsContrastEnhancement.StretchToMinimumMaximum, QgsTask.CanCancel, QgsUnitTypes.DistanceKilometers/LayoutMillimeters, QgsVectorFileWriter.NoError, QgsZonalStatistics.Mean/Sum, QgsRasterBandStats.Max/Min, QgsTextBackgroundSettings.ShapeRectangle -- spread across agent/tools/{layout,raster,vector,monitoring}_tools.py. Spot-checked a sample against the live qgis.org/pyqgis/master docs rather than guessing: results were inconsistent -- QgsVectorFileWriter.NoError is explicitly documented as valid in BOTH flat and scoped form (no fix needed), while others' docs only show the scoped form without confirming whether flat access still works. Unlike the pure-PyQt6 cases above (verified hard breaks via QGIS's own Qt5/Qt6 migration wiki), QGIS's own enum modernization has been gradual and inconsistent, so a blanket scoped-rewrite here risks introducing new AttributeErrors by guessing wrong rather than fixing real ones. Deliberately NOT touched pending real verification -- these only fire when the agent actually invokes the specific tool that touches them (graduated-symbol styling, layout export, raster contrast stretch, color ramp shading, zonal statistics, unit conversion, vector file export, cancellable background tasks), not on every session like the three dock/panel/theme bugs above, so flagged as a known risk for whichever of those specific tools get demoed today rather than blocking the whole plugin. |
| BUG-2026-09-02-4 | 2026-09-02 | unreleased (uncommitted) | high | Live user feedback from an expert test session, three distinct issues: chat text unreadable in QGIS dark theme, the agent not visualizing mappable results or guessing a stale year instead of asking, and (confirmed via a live screenshot) markdown tables rendering illegibly. Root cause for the color issues: ui/chat_formatting.py's derive_bubble_colors() already correctly derived bubble background/text from the live QGIS theme, but render_markdown() -- which renders the actual message content inside that bubble (code blocks, inline code, tables, blockquotes, <hr>) -- was completely disconnected from it, hardcoded to fixed light-theme colors (#e8e8e8 code background with no explicit foreground, #666/#999/#ddd/#ccc borders and blockquote text). In dark theme this collapsed to near-invisible contrast -- confirmed live via a screenshot where Gemini had wrapped table cell values (layer names) in backtick code formatting, rendering as a near-white block (inherited near-white bubble text on a hardcoded light-grey #e8e8e8 background) in an otherwise-readable dark-themed table. Fixed by threading the same theme-derived colors dict _add_message already builds through render_markdown(text, colors) and _render_table(...), replacing every hardcoded color with colors['text']/colors['subtle']/colors['border'] and a theme-derived code-chip background (_blend_hex(agent_bg, text, 0.15)); reproduced the exact reported table scenario against both the old and new code as a regression check (old: bare #e8e8e8 background, no text color, i.e. exactly the reported white block; new: theme-derived #313d5b-class background with explicit matching text color) rather than trusting the fix without reproducing the failure first. Defaults to the same static light-theme fallback derive_bubble_colors() itself falls back to, so a bare render_markdown(text) call is unaffected. Fully unit-testable (this file deliberately has zero qgis/PyQt imports) -- added test_dark_theme_colors_flow_into_code_and_table_and_blockquote plus a default-fallback regression test; full suite re-verified clean (701 tests, same 1 known sandbox failure, 0 errors). For the middleware behavior issues (no fix verifiable from this sandbox -- these are LLM behavior, not code logic): added system-prompt rule 40 (default to creating a real map layer for mappable results instead of only describing them in chat text) and rule 41 (never silently fill in a guessed year/date for a tool parameter the user didn't mention -- fetch_fts_funding_data/fetch_worldpop_population already support omitting the year to auto-select the most recent; the bug was the model guessing a stale year itself instead of leaving it blank). Added 3 matching cases to tests/manual_prompt_rule_evals.py per this repo's own convention (a live-user-discovered failure mode becomes a rule, and the rule becomes a manual eval case) -- these need a real LLM+QGIS session to actually confirm the model's behavior changed, same limitation as every other prompt rule in this file. |
| BUG-2026-09-02-5 | 2026-09-02 | unreleased (uncommitted) | feature | Feature request from the user: "i want the agent to self learn and improve itself gradually based on the user context and requests." Existing agent/memory.py (SpatialMemoryManager) already provided project/global notes and a rolling action-history log, but entirely opt-in and model-driven -- nothing auto-detected preferences, tracked correction feedback, or counted usage patterns. Clarified scope with the user via a multi-select question (auto-detect preferences passively / learn from corrections / usage-pattern-driven defaults / visible editable memory panel); the user selected all four, so all four were built as one coherent layer on top of the existing store: new agent/learning.py module (pure Python, zero qgis/PyQt imports, fully unit-testable) implements the policy of what gets written to SpatialMemoryManager's existing global-note store, using pref:*/rule:*/usage:* key prefixes rather than new storage plumbing. (1) Passive preference detection (maybe_infer_preferences): deliberately scoped to LLM-provider choice only for this first pass -- log_spatial_action's args are a stringified dict (str(args)), not structured data, so mining arbitrary tool-argument values (CRS, region, etc.) reliably enough to act on automatically needs more than a regex guess against that string; shipping a heuristic likely to store wrong "preferences" with false confidence was judged worse than starting narrow. Requires 5+ provider-usage samples and a 70%+ dominant share before writing pref:preferred_provider, and is idempotent (safe to call after every successful tool execution, which is where it's hooked in agent.py). (2) Correction learning (detect_correction/record_correction_rule): a plain-text regex heuristic over the user's next message after a tool call ("that's wrong", "no, ...", "undo that", "actually I meant", etc.) -- explicitly a starting heuristic, not a verified classifier (there's no ground truth for "the user meant this as a correction" short of asking them); false negatives just mean nothing extra gets remembered, false positives store an overly specific rule the user can remove via the new Forget control. Hooked into agent.py's run(), checked against self._last_tool_call (new, set in both _real_execute_tool's success branch and _log_tool_success) before the system prompt is built, so a correction made this turn is already visible to this same turn's model call. Stored rules persist as rule:<n> global notes and are surfaced every future turn via the formatted memory context. (3) Usage-pattern tracking (record_tool_usage/record_provider_usage): simple running counters as usage:* global notes, incremented on every successful tool call and once per session for the active provider; feeds mechanism 1 and is surfaced to the model as context ("you've used X most often") but deliberately does NOT silently change any UI default (e.g. the provider combo's initial selection) -- settings_dialog.py/dock_widget.py's provider-selection code already has an unrelated, uncommitted "account" feature in flight this session has no context on, so touching it was out of scope; surfacing the pattern (to the user via mechanism 4, to the model via this mechanism) was judged the safe subset. (4) Visible, editable memory panel: extended the existing "🧠 Project Notes & Memory" panel in ui/tasks_tab_widget.py (search box + read-only browser, already wired to get_formatted_memory_context()) with a new "🎓 Learned Preferences & Rules" row -- a combo box listing every pref:*/rule:*/usage:* entry plus a "🗑 Forget Selected" button wired to a new SpatialMemoryManager.delete_global_note(key) method (previously there was no per-key delete for global notes, only clear_project_notes() which doesn't touch global notes at all). get_formatted_memory_context() itself was also restructured to bucket global notes by prefix into labeled "### Learned Preferences" / "### Correction Rules" / "### Usage Patterns" sections instead of one flat "User Global Preferences" list, so both the model and this panel see clearly separated categories; any pre-existing uncategorized global note still renders under "User Global Preferences" exactly as before, so nothing already stored is silently hidden by the restructure. Verification: agent/learning.py and memory.py's new/changed methods (delete_global_note, get_formatted_memory_context) are fully covered by 17 new tests in tests/test_learning.py plus manual interactive verification against this machine's real accumulated memory store (confirmed real project notes render correctly, confirmed forget/delete round-trips correctly) -- full suite re-verified clean (718 tests, same 1 known sandbox-network failure as every prior entry this session, 0 new failures). The agent.py wiring (provider-usage recording in __init__, tool-usage/correction hooks in _real_execute_tool/_log_tool_success/run()) and the tasks_tab_widget.py UI addition are zero-automated-test-coverage for the same structural reason as every other UI/agent-lifecycle change this session (agent.py imports qgis.PyQt with a test-only stub path that isn't exercised by these specific new lines; tasks_tab_widget.py imports qgis.PyQt unconditionally and cannot be imported outside a real QGIS process) -- status fixed-unverified-pending-live-session for the wiring and UI specifically; fixed-verified for learning.py's and memory.py's actual logic. The correction-detection heuristic (mechanism 2) additionally needs real usage to tune -- its pattern list is a reasonable starting guess, not something that can be validated from this sandbox. |
| BUG-2026-09-02-6 | 2026-09-02 | unreleased (uncommitted) | high | Live user report: an exported "BEIRUT OPERATIONAL SECURITY & TRANSIT CORRIDORS BRIEFING" contained fully fabricated incident markers (INC-01 through INC-09) with invented severities and a professional-looking narrative, presented as an authoritative finished document with nothing marking it as illustrative -- described by the user as "bullshit not real." Root cause: add_point_layer (agent/tools/humanitarian_tools.py) and create_print_layout (agent/tools/layout_tools.py) both accept fully freeform, unvalidated data from the model (lat/lon, a free-text "category" severity label, title, body_text) -- the existing tool descriptions and system-prompt rule 12 already said "never invent placeholder values," but nothing enforced it in code, and the model produced a fabricated dataset anyway. Three-layer mitigation, since reliably detecting fabricated content from inside a tool call isn't possible: (1) new system-prompt rule 42, naming this exact failure mode (with the Beirut incident as the concrete example) as the single highest-stakes case of rule 12's general anti-fabrication principle -- a finished exported document reads as authoritative and is far more likely to be trusted/acted on than the same claim in chat; (2) strengthened tool descriptions on both add_point_layer and create_print_layout, adding the same instruction directly at the point the model reads right before deciding to call them; (3) a standing disclaimer footer ("AI-generated -- verify before operational, humanitarian, or safety use.") now added unconditionally by create_print_layout to every layout it creates, code-level and not something the model's title/body_text can omit or override -- this doesn't detect or block fabrication, but guarantees every export carries a visible verification reminder regardless of content. body_h (the optional summary panel) was shrunk in both orientations (landscape 87->78, portrait 13->6) to make room for the footer without touching the map/legend/scalebar/title geometry already confirmed against a live full-Yemen export; portrait's resulting fit is tighter and unconfirmed, same unverified-portrait caveat this file already carried before today. New eval case no_fabricated_security_incident_briefing added to tests/manual_prompt_rule_evals.py reproducing the exact live query. Verification: py_compile clean on all three changed files, full suite re-verified clean (727 tests, same 1 known sandbox failure, 0 new failures) -- but layout_tools.py/humanitarian_tools.py import qgis.core unconditionally and cannot be executed outside a real QGIS process, so the footer's actual on-page placement and rule 42's effect on live model behavior are both fixed-unverified-pending-live-session, the same honest limitation as every other layout/prompt-rule change this session. This is a mitigation, not a guarantee -- LLM instruction-following is never 100%, and this cannot be marked fixed-verified in the sense of "will never fabricate again." Update 2026-09-05 (live verification, see BUG-2026-09-05-1's session): the user drove a real create_print_layout call through the live chat UI in a real QGIS 4.2.2 session (Google Gemini (Hosted) provider). Confirmed via screenshot of the actual Layout Designer: the standing disclaimer footer ("AI-generated -- verify before operational, humanitarian, or safety use.") is present, fully legible, and correctly positioned at the bottom of a real landscape layout ("Layout_Health_Facilities_Situation_Map"), alongside a real title, graduated-severity legend, scale bar, and north arrow, with a "Data Source" attribution line naming the real source file and layer -- not a hallucinated document. landscape orientation is now fixed-verified for the footer's on-page fit specifically. portrait orientation's tighter fit remains unconfirmed -- this session didn't test it. |
| BUG-2026-09-02-7 | 2026-09-02 | unreleased (uncommitted) | medium | Live user report: with "Google Gemini (Hosted)" selected in the provider dropdown, a "hi" message appeared to produce two responses -- a normal greeting, and a separate "[API error] Ollama connection failed" bubble -- suggesting a provider-routing bug (Ollama invoked despite Gemini being selected). Checked all 5 places the provider could plausibly get crossed (dock_constants.py PROVIDER_CHOICES, plugin_main.py._get_agent()'s cache-invalidation hash, agent.py's provider if/elif construction, model_selector.py, and the new-this-session "account" feature's entanglement with "(Hosted)" labeling) -- none showed a live routing defect. Root cause found instead in ui/chat_tab_widget.py's _add_message(): every chat bubble's displayed timestamp was _relative_time(now_iso()) -- literally "now," at render time -- with no distinction between a message rendered live and one being replayed from agent/chat_persistence.py's project-saved history on _populate_initial_chat()/refresh_chat_for_project_change(), confirmed via the file's own prior comment ("restored chat history has no stored per-message timestamp to draw on... a known, accepted limitation"). This meant a stale error bubble from an earlier turn or an earlier session -- e.g. an old Ollama failure from before the user switched providers -- would be restored on project reopen and rendered with a "just now" timestamp identical to a brand-new reply next to it, exactly matching the reported symptom; the two messages were never part of the same turn, they only looked that way. Fixed by having chat_persistence.py attach and persist a real per-message timestamp (_attach_timestamps, matching messages across saves by (role, content) and reusing the original timestamp for anything already on file, stamping a fresh one only for genuinely new messages -- correct across agent.py's front-trimming since conversation_history is always an append-only list or a trimmed suffix of one) via a new load_chat_history_with_timestamps() accessor; load_chat_history() itself is unchanged (still strips to plain {"role","content"} pairs, since conversation_history is extended directly into the next LLM messages call and an extra key isn't part of any provider's API contract). chat_tab_widget.py._populate_initial_chat() now reads the timestamped version directly and stashes each entry's real ts for _add_message() to use instead of "now." Verification: _attach_timestamps and the full save/load round trip (against a fake QgsProject) are fully covered by 9 new tests in tests/test_chat_persistence.py (14 total in that file now), including the exact reported scenario (an old message surviving a later save alongside new turns, keeping its original timestamp while the new turns get a distinct fresh one); full suite re-verified clean (727 tests, same 1 known sandbox failure, 0 new failures). The UI half (chat_tab_widget.py's _add_message/_populate_initial_chat) is fixed-unverified-pending-live-session for the same structural reason as every other UI file this session (unconditional qgis.PyQt import); chat_persistence.py's logic is fixed-verified. |
| BUG-2026-09-04-1 | 2026-09-04 | unreleased (uncommitted) | medium | Resolution of the ~15 unscoped-native-QGIS-enum risk BUG-2026-09-02-3 found but deliberately left unfixed (TASK-0008 in docs/MASTER_TASK_REGISTRY.md/the Obsidian task board), where a blanket rewrite to one literal form was judged too risky to guess without live QGIS verification. Instead of picking a form, added agent/tools/_qgis_enum_compat.py's resolve_qgis_enum(cls, nested_enum_name, member_name) -- tries the QGIS 4.x/Qt6 scoped form first (e.g. cls.Mode.Jenks), falls back to the QGIS 3.x/Qt5 flat form (cls.Jenks) if the nested enum doesn't exist, returns None if neither resolves -- and applied it at every one of the 9 flagged classes: QgsGraduatedSymbolRenderer (styling_tools.py, nested Mode), QgsColorRampShader (raster_tools.py, nested Type), QgsContrastEnhancement (raster_tools.py, nested ContrastEnhancementAlgorithm), QgsRasterBandStats (raster_tools.py, nested Stat, the Min/Max flag combination), QgsTask (task_runner.py, nested Flag), QgsUnitTypes (layout_tools.py, 16+2 call sites collapsed to two module-level resolved constants _LAYOUT_MM/_DISTANCE_KM, nested LayoutUnit/DistanceUnit), QgsVectorFileWriter (export_tools.py, nested WriterError, already known valid in both forms per BUG-2026-09-02-3's own spot-check, fixed anyway for consistency), and QgsTextBackgroundSettings (humanitarian_tools.py, nested ShapeType). QgsZonalStatistics (analysis_tools.py/raster_tools.py) was found already fixed with the identical hand-rolled getattr pattern from an earlier round -- this change generalizes that pre-existing pattern into a shared, tested helper rather than reinventing it a third time. This closes the risk regardless of whether every specific nested-enum-name guess above is correct: a wrong guess only means the resolver falls through to the flat form exactly as before (no regression), and on a true Qt6-only QGIS build where the flat form has been fully removed, an unresolvable enum now degrades to None (caught by each call site's existing surrounding try/except, producing a clean error) instead of crashing with an unhandled AttributeError. Verification: resolve_qgis_enum itself is pure Python and fully covered by 5 new tests in tests/test_qgis_enum_compat.py (scoped-preferred, flat-fallback, both-present, neither-present, nested-enum-exists-but-member-missing); py_compile clean on all 7 touched files; full suite re-verified clean (753 tests, same 1 known sandbox failure, 0 new failures) -- the actual enum values resolved against a real QGIS 4.2 install remain unverified from this sandbox, same standing limitation as every other QGIS-touching change, but the resolution mechanism no longer needs that verification to be safe. |
| BUG-2026-09-04-2 | 2026-09-04 | eee84eb (committed) | critical | GDPR compliance review (docs/GDPR_COMPLIANCE_REVIEW.docx, run 2026-09-01 against cartogen-ai-community) finding F1: global memory (agent/memory.py) had store_global_note()/get_global_notes() but no bulk erasure path -- clear_project_notes() existed, clear_global_notes() did not. An earlier same-day status update to the project owner overstated delete_global_note(key) (added for the self-learning panel's "Forget Selected" control, BUG-2026-09-02-5) as addressing this; re-checked directly against the review document's exact text (extracted via python-docx rather than trusted from memory) and it does not -- that method is per-key only and the UI control wired to it filters to pref:/rule:/usage: keys specifically, neither of which satisfies F1's actual requirement (recommendation R4: erase ALL global memory, any key, any writer, in one action). Fixed by adding MemoryManager.clear_global_notes(), symmetric with the existing clear_project_notes() -- clears the in-memory cache and the QgsSettings-backed store together, since a partial clear would just have the note reappear on the next get_global_notes() call -- and wiring a "Clear Global Memory" button (with a confirmation dialog, matching the existing "Clear Project Memory" button's pattern) into ui/tasks_tab_widget.py. 4 new tests added to tests/test_memory_and_tasks.py (single-key delete, missing-key delete, clear-everything, clear-does-not-touch-project-notes); all 16 tests in that file re-verified passing after the change, including the 4 new ones. Committed as eee84eb (the project owner's explicit go-ahead: "go ahead and commit it") -- scoped to exactly the 3 files above, nothing else from the working tree's other uncommitted work swept in. Not fully closed cross-repo: the GDPR review document itself lives in cartogen-ai-community, which does not have this method -- the finding and its fix are still in two different, diverged repos (see /areas/cartogen-ai.md project memory for the repo-landscape context); someone still needs to either port this method over or update the review doc to point at cartogen-ai specifically. |
| BUG-2026-09-05-2 | 2026-09-05 | unreleased (uncommitted) | medium | calculate_service_area (agent/tools/logistics_tools.py) failed on a small/degenerate synthetic road network in two distinct live-reproduced shapes: a single-segment (2-point, collinear) network raised "Could not add feature with geometry type LineString to layer of type Polygon" from the native:convexhull step; a 2-segment L-shaped network raised "...has invalid geometry. Please fix the geometry or change the 'Invalid features filtering' option..." from native:serviceareafrompoint itself. 2026-09-08: per-facility isolation added so one facility's failure no longer aborted the whole multi-facility call (skipped/warnings result fields) -- did not fix either underlying failure, just contained the blast radius. 2026-09-10: both root causes isolated and an actual fix implemented, reasoned from QGIS's own documented Processing/GEOS behaviour rather than newly reproduced live (no live/headless QGIS available in this session -- see the "Known non-bugs" list above for this sandbox's standing QGIS-access limitation). (1) native:convexhull's OUTPUT sink is declared as a fixed Polygon geometry type regardless of input; the convex hull of collinear points (a straight 1-segment road) is geometrically a line, so QgsGeometry.convexHull() correctly returns a LineString and the algorithm's own Polygon-typed sink then refuses to write it -- native:convexhull has no parameter to accept a non-Polygon result, so this cannot be fixed by changing our call's parameters. Added _degenerate_hull_fallback(): on exactly this error, recomputes the hull directly via QgsGeometry.unaryUnion()/.convexHull() and, if it is degenerate (not a polygon), buffers it into a thin valid polygon by a small fraction of travel_cost rather than leaving the facility's hull unbuilt. (2) native:serviceareafrompoint's own "invalid geometry" abort now runs with a QgsProcessingContext whose setInvalidGeometryCheck(QgsProcessing.GeometrySkipInvalid) asks the algorithm to skip an invalid input feature internally instead of aborting -- the exact remedy the error message itself names ("change the 'Invalid features filtering' option"), applied via QGIS's documented Processing API rather than guessed. Both fixes are defensive additions alongside the 2026-09-08 isolation (kept as the safety net for any other convexhull failure shape); if _degenerate_hull_fallback itself cannot recover a hull, the facility is still recorded in skipped exactly as before. 12 new/updated tests in tests/test_logistics_tools.py (7 new: 5 direct unit tests of _degenerate_hull_fallback's branches -- degenerate-to-buffered-polygon, already-polygon, no-usable-geometry, exception-safety, buffer-still-degenerate -- plus one confirming the fallback actually gets wired into calculate_service_area's convexhull-failure path and one confirming the GeometrySkipInvalid context is actually constructed and passed to native:serviceareafrompoint; 5 existing tests updated for the new context= kwarg on that call). Full suite re-verified clean: 1239 tests, same known baseline (1 DNS-dependent failure, 6 FUSE PermissionError cleanup errors, 6 skipped), 0 new failures; py_compile and pyflakes both clean on the two changed files. Status fixed-unverified-pending-live-session: the geometry-degeneracy reasoning behind fix (1) is provable independent of live QGIS (convex hull of collinear points is deterministically a line, not an area), but fix (2)'s actual effect against a real native:serviceareafrompoint invalid-geometry case, and whether _degenerate_hull_fallback's buffered polygon looks sane on a real map, are both unconfirmed pending a live/headless QGIS session -- do not mark this fixed-verified until one runs. Update 2026-09-11: live-confirmed against real QGIS 4.2.2 (python-qgis.bat, real QgsApplication/Processing.initialize()). Three cases run: (1) the exact single-segment collinear network -- success: True, no crash, no skipped facility, a real fallback hull built; (2) the exact 2-segment L-shaped network -- success: True, no crash, the GeometrySkipInvalid context handled the invalid-geometry case as designed; (3) a realistic 5-segment grid network as a regression guard -- still success: True, confirming the fix didn't break the already-working case. Status now fixed-verified. |
| BUG-2026-09-05-1 | 2026-09-05 | a424b36 (committed) | critical | Found by an actual live headless PyQGIS session (QGIS 4.2.2's own python-qgis.bat, QT_QPA_PLATFORM=offscreen, real QgsApplication/Processing.initialize() -- not the sandboxed no-QGIS suite) run specifically because docs/RELEASE_SMOKE_TEST.md's GUI checklist turned out undriveable in this environment (QGIS launched and stayed responsive, confirmed via process state, but its window never composited to anything screen-capture/input-injection could reach). Pivoted to calling 14 of the checklist's representative tool functions directly against a purpose-built test dataset (point/polygon/raster around Amman, Jordan); apply_raster_stretch failed on the very first pass with {'error': 'QGIS not available'} despite a genuinely live QGIS session. Root cause: agent/tools/raster_tools.py's top-level from qgis.core import ... QgsColorRampShaderItem, ... raises ImportError on real QGIS 4.2.2 -- confirmed directly (from qgis.core import QgsColorRampShaderItem fails; QgsColorRampShader.ColorRampItem exists in its place) -- because the class moved from a standalone qgis.core name to a nested QgsColorRampShader.ColorRampItem between QGIS 3.x and 4.x. That ImportError was caught by the module's own try/except and set QGIS_AVAILABLE = False for the entire file, silently degrading all 11 registered raster tools (hillshade, slope_analysis, aspect_analysis, zonal_statistics, raster_clip, unsupervised_classification, supervised_classification, histogram_equalization, mosaic_rasters, band_composite, pan_sharpening) to "QGIS not available" in a real live session -- invisible to the sandboxed test suite by construction, since it has no real QGIS to ever hit this import path. Fixed by resolving the class the same way this file's enum members already are via _qgis_enum_compat.py (BUG-2026-09-04-1's pattern, generalized here since this is a class rename, not an enum member): try QgsColorRampShader.ColorRampItem first, fall back to the standalone qgis.core.QgsColorRampShaderItem for older QGIS 3.x installs, None if neither resolves. Also ran a systematic sweep of all 38 agent//agent/tools/ modules' QGIS_AVAILABLE flags under the same live session after the fix -- confirmed clean, raster_tools.py was the only module hit by this failure class. Verification: re-ran the same live headless session after the fix -- apply_raster_stretch now succeeds and visibly changes the raster's renderer; all 14 checklist categories tested (including 2 real network calls -- HDX dataset search, Earth Search STAC satellite imagery search -- that returned real current data) now pass, 14/14. Sandboxed suite re-verified clean (1025 tests, 0 failures, 7 skipped). fixed-verified -- this is the rare case where the live-QGIS half of a fix (usually the unverifiable part for every other entry in this file) is the most verified part, since it's exactly what caught the bug. Committed as a424b36. Not covered by this pass: the GUI itself (chat UI, LLM provider dispatch, canvas rendering, Tasks tab live plan view) remains unverified in this environment -- see docs/RELEASE_SMOKE_TEST.md's still-open manual checklist. |
| BUG-2026-08-21-7 | 2026-08-21 | v1.4.1 | high | Two compounding issues found while re-verifying the suite after BUG-2026-08-21-6's fix, both specific to this repo's namespace-package restructure: (1) 15 from agent.../import agent.../import ui... statements survived the earlier bulk import-rewrite across tests/test_providers.py, test_new_tools.py, test_export_tools.py, test_chat_persistence.py, and tests/manual_prompt_rule_evals.py — all function-local imports (inside a test body or context manager), which the original rewrite pass (scoped to module-top-level import lines) didn't reach; each failed with ModuleNotFoundError: No module named 'agent'. Rewritten to cartogen_ai.core.agent.../cartogen_ai.core.ui..., matching the top-level rewrite already done elsewhere. (2) Separately, and more subtly: python -m unittest discover -s tests -p "test_*.py" — the exact command documented in CLAUDE.md, README.md, CONTRIBUTING.md, .github/workflows/tests.yml, .github/PULL_REQUEST_TEMPLATE.md, and SECURITY.md before this fix — silently never executes tests/__init__.py's src/-on-sys.path bootstrap, because without an explicit -t/--top-level-directory, discover() treats -s tests as also the top-level directory and imports each test_*.py as a bare top-level module rather than as a member of the tests package, so tests/__init__.py never runs as a package initializer. The fix is -t . (repository root as the true top-level directory): python -m unittest discover -s tests -t . -p "test_*.py". Without it, every test importing cartogen_ai.core.* fails with ModuleNotFoundError: No module named 'cartogen_ai' even though the exact same import works fine outside unittest (e.g. via plain python3 -c "...") — confirmed by direct comparison. All 6 documentation/CI locations updated to the -t . form. Re-verified after both fixes: 691 tests, 1 known failure + 6 known errors — back to the pre-restructure baseline exactly. |
| BUG-2026-09-04-1 | 2026-09-01 (found) | commit 99079e3 | critical | GDPR compliance review (docs/GDPR_COMPLIANCE_REVIEW.docx, run 2026-09-01 against this repo) finding F1: global memory (agent/memory.py's SpatialMemoryManager) had store_global_note()/get_global_notes() but no bulk erasure path -- clear_project_notes() existed for project-scoped notes, nothing equivalent existed for global ones. Left explicitly open in the 2026-09-01 remediation round alongside F6-F12 (see docs/IMPLEMENTATION_TRACKER.md §1). Fixed by adding SpatialMemoryManager.clear_global_notes(), symmetric with clear_project_notes() -- clears the in-memory cache and the QgsSettings-backed store together -- and wiring a "Clear Global Memory" button (with a confirmation dialog, matching the existing "Clear Project Memory" button) into ui/tasks_tab_widget.py. 2 new tests added to tests/test_memory_and_tasks.py; full suite re-verified: 837 tests, same known 1-failure/6-error sandbox baseline as before, 0 new failures. Ported from cartogen-ai (that repo's own eee84eb, 2026-09-04, closed this same finding first, discovered when the two repos' independent GDPR/code-quality reviews were cross-checked against each other) -- this repo predates cartogen-ai's self-learning system, so there's no pref:/rule:/usage: key scheme or per-key delete_global_note() here to reconcile, just a plain bulk clear. |
Full history before this file existed: see CHANGELOG.md, every version from v1.0.0 forward.
| NEW-2026-09-08-1 | 2026-09-08 | committed (this commit) | critical | Found during a full independent code review of this repo: execute_pyqgis_script's denylist-based sandbox (agent/tools/system_tools.py) was fully bypassable via exception-traceback frame-walking -- a script catching an exception and walking e.__traceback__.tb_frame up through .f_back to the outermost stack frame could read f.f_globals["__builtins__"], the REAL unrestricted builtins module/dict, and from there retrieve real open/__import__/eval/exec by plain dict-key lookup -- none of __traceback__, tb_frame, f_back, f_globals were in _BLOCKED_DUNDER_ATTRS, so _validate_script_safety's AST walk never flagged it. Live-reproduced before the fix (see docs/CODE_REVIEW_2026-09-08.md §4.1 for the full writeup and PoC). Fixed by extending _BLOCKED_DUNDER_ATTRS with f_back, f_globals, f_locals, f_builtins, f_code, gi_frame, cr_frame, ag_frame, tb_frame, tb_next, __traceback__ -- the same denylist-sweep pattern as every prior fix in this list (2026-08-21/09-04/09-05). Two regression tests added to tests/test_new_tools.py: test_script_safety_blocks_frame_traceback_introspection_escape (the exact PoC, plus each new attribute name individually, _validate_script_safety direct) and test_execute_pyqgis_script_rejects_frame_traceback_escape_end_to_end (same PoC through the real execute_pyqgis_script entry point, confirming no file gets written). Full suite re-verified clean after the fix: 1219 tests (1217 + 2 new), same 1 known DNS-dependent failure (test_is_safe_url_accepts_public_host), 0 errors, 0 other new failures. fixed-verified. As with every prior sweep, this closes the specific technique found this review, not a claim of denylist completeness -- see docs/QGIS_PRODUCTION_ARCHITECTURE_REVIEW_2026-09-04.md point 19 for the standing tiered-execution/allowlist architecture question this doesn't resolve. |
| BUG-2026-09-08-1 | 2026-09-08 | committed (this commit) | high | agent/account.py's normalize_account_base_url() accepted a plain http scheme (default itself http://localhost:3000) while ui/account_dialog.py's own text told the user their password "is sent only over HTTPS." Fixed by rejecting http for any host except localhost/127.0.0.1/::1, matching the fix plan recorded when this was first found. 3 new tests in tests/test_account_client.py (rejects real-host plain-http, still accepts localhost-dev plain-http, still accepts real-host https). account_dialog.py's existing ValueError handling (_make_client/_open_portal, both already catch and show it via _show_error) means the new rejection surfaces as a normal in-dialog error message, not a crash -- confirmed by reading those call sites, not assumed. Full suite re-verified clean: 1225 tests, same 1 known DNS-dependent failure, 0 errors, 0 other new failures. fixed-verified. |
| BUG-2026-09-08-2 | 2026-09-08 | committed (this commit) | high | GDPR: the Hosted-Account feature (agent/account.py/ui/account_dialog.py) was outside docs/GDPR_COMPLIANCE_REVIEW.docx's original scope (see docs/GDPR_HOSTED_ACCOUNT_ADDENDUM_2026-09-08.md for the verified inventory that surfaced this). The project owner's decision: "Ship, extend GDPR review." Fixed in two parts: (1) ui/account_dialog.py now shows an in-app privacy notice (privacy_notice_label) before registration/login, closing the transparency gap this bug flagged; (2) docs/GDPR_COMPLIANCE_REVIEW.docx extended with Finding F14 (MEDIUM), Recommendation R11, two new Section 4 data-inventory rows, and three new Appendix entries -- folding in the addendum's verified inventory and this session's HTTPS fix (BUG-2026-09-08-1) rather than leaving the account feature undocumented. Remaining, explicitly not closed by this fix: no in-app account-deletion/export path (R11, server-side today) and no documented retention policy for the hosted service -- both organizational/server-side facts outside this plugin's own code, tracked as F14/R11 rather than reopened as new bugs. Docx changes verified via validate.py (XML-schema diff against the original) and a rendered PDF visual check (all new content matches the original's exact styling); full suite re-verified clean: 1225 tests, same 1 known DNS-dependent failure, 0 errors. account_dialog.py's own change is fixed-unverified-pending-live-session (unconditional qgis.PyQt import, same standing limitation as every other UI file this session) -- fixed-verified for the docx extension. |
| BUG-2026-09-27-1 | 2026-09-27 | this commit | medium | generate_html_dashboard/generate_temporal_dashboard's feature cap (_build_capped_simplified_layer, agent/tools/export_tools.py) truncated an over-2,500-feature layer to "the first N features" via QgsFeatureRequest().setLimit() -- for a layer whose features happen to be grouped by region (a common real shape: a region-by-region ingest or a source sorted by admin area), the dashboard silently showed only one corner of the data instead of a representative view. Live-reported symptom: dashboards described as poor-quality/incomplete for large layers. Fixed by replacing the limit-based truncation with an even stride sample (every Nth feature across the whole source, in whatever order getFeatures() returns) so kept features spread across the entire dataset regardless of storage order. New regression test (test_over_cap_layer_samples_across_whole_source_not_just_the_start) constructs a layer split 50/50 between two region tags and confirms both are represented in the output -- fails against the pre-fix "first N" logic. Full suite re-verified: 2244 tests, 0 failures, 139 skipped. fixed-unverified-pending-live-session (no live QGIS in this sandbox to visually confirm the resulting dashboard map, same standing limitation as every UI-facing change here) for the visual outcome; the sampling logic itself is plain Python, fully covered by the new unit test. |
| BUG-2026-09-27-2 | 2026-09-27 | this commit | low | estimate_road_speeds (agent/tools/logistics_tools.py) used one fixed global per-road-class speed table regardless of country, acknowledged in its own comment as "not this project's own measurement of Jordan or any other specific road network." Added an optional country parameter (ISO 3166-1 alpha-2) and COUNTRY_SPEED_TIERS_KMH, a curated table of real national legal urban/rural/motorway speed defaults verified via live web search 2026-09-27 against public references (Wikipedia's "Speed limits by country" and per-country articles, TyreMap, AARoads Wiki), modeled on the same three-tier convention the real open-source westnordost/osm-legal-default-speeds project (used by GraphHopper/Valhalla) already applies to this exact gap. Omitting country, or passing one not in the curated table, keeps prior behavior exactly as before. 5 new tests in tests/test_logistics_tools.py. Full suite re-verified: 2244 tests, 0 failures, 139 skipped. Not measured data for any specific real road network -- same "assumption, not measurement" caveat the tool's own description already carries, now truer per-country rather than one flat global guess. |
| BUG-2026-09-27-3 | 2026-09-27 | this commit | medium | Live report: routing/logistics output rendering as scattered dots rather than a connected line, described as unprofessional and non-standard. Root cause: agent/map_intelligence.py's apply_component_symbology -- the single engine responsible for all "professional" cartographic styling in this codebase -- unconditionally return Falsed for any non-polygon geometry, so every line-output tool (optimize_delivery_route's road-snapped route layer via _build_road_snapped_route, calculate_service_area's reachable-network lines_layer) was left at QGIS's raw default new-layer symbology every single time; neither function called process_map_output at all, unlike buffer_analysis's polygon output which already did. Fixed by adding a route_line style profile and _apply_line_component_symbology, implementing the standard road/route "casing" technique (a wider, darker outline beneath a narrower, brighter fill line -- the same technique OSM Carto, Mapbox Streets, and ArcGIS's own transportation symbol categories use, documented in Krygier & Wood's "Making Maps: A Visual Guide to Map Design for GIS"), and wiring both line-output call sites to invoke process_map_output(..., output_role="route_line"), matching the pattern buffer_analysis already established. Considered and rejected a straight-line "desire line" fallback for the no-network case in optimize_delivery_route -- docs/HUMANITARIAN_CARTOGRAPHY_STANDARDS.md Section IV explicitly prohibits drawing a straight line as a substitute route ("Prohibited: Do not draw straight lines for route paths"), so that case's existing behavior (no line layer at all, explicit warning not to render one) is correct as-is and was left untouched. 3 new tests in tests/test_map_intelligence.py (route_line profile shape, the two-layer casing symbol construction, confirming point/other geometry is still untouched). Full suite re-verified: 2247 tests, 0 failures, 139 skipped. fixed-unverified-pending-live-session: no live QGIS in this sandbox to visually confirm the resulting route line's on-canvas appearance -- the symbol-construction logic itself is fully covered by the new unit tests. |
| BUG-2026-09-28-1 | 2026-09-28 | this commit | medium | Live report (real QGIS 4.2.2, real Yemen OSM data: 139,748 road features, 3,369 health-facility features): a "Health facilities beyond one hour's travel" request's execute_pyqgis_script call failed with "Script exceeded the 60s isolation timeout and the worker was terminated" -- the overall task still completed via other tools, but this one call gave up with no other diagnostic at all. Reproduced first, before guessing at a cause: a synthetic layer pair at the same real-world scale (139,748 + 3,369 features) through run_isolated_script() with a trivial script completed in 4.7s, ruling out dataset-size/serialization slowness as the cause. Reading the actual timeout/diagnostics path (agent/services/script_isolation.py) instead found three real, confirmable-by-inspection bugs: (1) find_python_interpreter()'s Windows fallback could resolve to sys.executable, which inside a real QGIS desktop session is the QGIS application binary itself, not Python (a known upstream QGIS behavior) -- spawning that as "the worker" would sit there producing no output at all until the full 60s job timeout fired, matching the exact symptom; (2) _drain_stderr() used select.select() on a pipe object, which Windows' select() does not support (sockets only) -- silently swallowed by a broad except Exception, so stderr diagnostic capture has never actually worked on Windows for this feature; (3) the timeout path called _kill() directly without ever calling _drain_stderr(), losing diagnostic stderr even on platforms where select() would have worked. Fixed with three layered defenses: find_python_interpreter()'s Windows search now also checks sys.base_prefix/sys.exec_prefix and an apps/Python3* child directory (some QGIS-for-Windows layouts put the bundled interpreter one level down from sys.prefix); a new _QGIS_BINARY_BASENAMES fast-refusal in _ensure_started() rejects a QGIS-binary-named interpreter before ever attempting to spawn it; and a new ~20s startup handshake (the worker now emits {"ready": true} on stdout right after QgsApplication/initQgis() succeeds, _script_isolation_worker.py's main()) means even an interpreter this detection misses fails fast with a real error instead of hanging for the full 60s job timeout. _drain_stderr() was rewritten to kill-then-read (guarantees EOF, no select() needed at all, identical on every platform) and every failure path (submit()'s write-pipe failure, response-parse failure, and the timeout itself) now routes through a single _kill_and_drain_stderr() helper so stderr is never lost again. Live-verified (Docker, the exact qgis/qgis digest this repo's CI pins): normal handshake path still works (~1.18s cold call, no regression); a QGIS-binary-named interpreter is refused in under 2s with zero spawn attempts; a real-but-wrong binary fails via the handshake timeout with its actual stderr captured verbatim, proving the drain fix genuinely works end to end; all 3 existing Phase 1 harnesses (tests/manual_isolation_bench/phase1_live_check.py, phase1_persistence_check.py, phase1_recovery_check.py) re-run clean with no regressions. 6 new unit tests in tests/test_script_isolation.py (TestQgisBinaryRefusal, TestEnsureStartedRefusesQgisBinary, plus the widened Windows interpreter-search coverage via a real-filesystem-fixture rewrite of the prior deep-mocked Windows tests). Full suite re-verified: 2271 tests, 0 failures, ruff check . clean. Not verified: this fix set could not be tested on an actual Windows machine from this sandbox -- the interpreter-lookup theory is the most likely root cause given the exact symptom match, not a certainty; the two diagnostic bugs (select() on Windows, drain-before-kill ordering) are real, confirmable-by-inspection bugs independent of whether that theory is the true cause of this specific report. fixed-unverified-pending-live-session. |
| BUG-2026-09-28-2 | 2026-09-28 | this commit | medium | Direct follow-up to BUG-2026-09-28-1, same day: that fix made the isolation worker fail fast (~20s) with a real diagnostic instead of hanging silently for the full 60s. A live user then re-tested it on real Windows QGIS 4.2.2 (the "Health facilities beyond one hour's travel" request against real data around 31.84 N, 35.93 E, Amman) and got exactly that -- a fast, clear failure -- which surfaced the actual bug underneath: the interpreter it found (C:\PROGRA~1\QGIS42~1.2\apps\Python312\python.exe) was a genuinely correct, real Python interpreter (not the QGIS application binary BUG-2026-09-28-1's _QGIS_BINARY_BASENAMES guard targets), yet it failed with ModuleNotFoundError: No module named 'qgis'. Root cause: on a real Windows QGIS desktop install, the qgis Python bindings directory is added to the EMBEDDED interpreter's sys.path by QGIS's own C++ bootstrap at startup -- it is never exported as an OS-level PYTHONPATH environment variable, so _build_worker_environment() (which only inherited PYTHONPATH from os.environ) never gave the spawned subprocess a way to find it, even with the correct interpreter. Fixed by adding _qgis_sys_path_entries(), which reuses the LIVE plugin process's own already-resolved sys.path (this plugin's own process can already import qgis.core, so whatever made that true is already sitting there) instead of guessing an install layout that differs across OSGeo4W, the standalone installer, and Docker. _build_worker_environment() now folds those entries into the worker's PYTHONPATH, ordered: this plugin's own parent dir first (so import cartogen_ai in the worker always resolves to this install, never a same-named package elsewhere on the QGIS interpreter's own sys.path), then the live process's sys.path entries, then any existing PYTHONPATH env var -- deduplicated, nothing dropped. 4 new unit tests in tests/test_script_isolation.py (TestBuildWorkerEnvironment): confirms sys.path entries are folded in, confirms deduplication, and both existing PYTHONPATH-ordering tests updated to account for the new entries. Full suite re-verified: 2273 tests, 0 failures, ruff check . clean. Not verified: this fix could not be re-tested on the actual Windows machine that reported the ModuleNotFoundError -- the root-cause read (QGIS's C++ embed-time sys.path injection never being exported as PYTHONPATH) is a known, well-documented pattern for embedded-interpreter plugin hosts, not a guess, but the fix itself is unverified against that specific real install from this sandbox. fixed-unverified-pending-live-session. |
| BUG-2026-09-28-3 | 2026-09-28 | this commit | medium | Live report: same user, same "Health facilities beyond one hour's travel" flow, a different bug. Asked whether to download local data, the user replied "dowmload" (one transposed letter); the chat lost track entirely -- it asked an unrelated "what data would you like to download?" question, the user then replied "fetch online", and the assistant fetched GDACS disaster alerts instead of continuing the original request at all. Root cause: agent/local_data_sources.py's parse_reply() only exact-matched against fixed word sets (_DOWNLOAD_REPLIES/_ONLINE_REPLIES); a reply that missed both returned None, and chat_tab_widget.py's send_message() (line ~975) responded to that None by setting self._local_data_pending = None -- silently discarding the ORIGINAL pending request, not just the unanswered question -- before falling through to treat the typo as a brand-new, context-free message. The second turn's derailment (an unrelated GDACS fetch) was a direct consequence of that first context loss, not a separate bug. Fixed: parse_reply() now also checks a close-match ratio (difflib.SequenceMatcher, cutoff 0.8) against the two headline single-word replies ("download"/"online") when the exact-match sets miss, scoped to single-token replies only so a real new multi-word request never gets accidentally swallowed as a "yes". Checked for false positives against plausible short replies before picking the cutoff ("decline", "delete", "cancel", "later", "roads" -- all score well under 0.75, confirmed by direct computation, not assumed). 4 new tests in tests/test_local_data_sources.py: two single-letter/transposition typos of each headline word all resolve correctly, and the unrelated-short-reply set is confirmed to still fall through to None. Full suite re-verified: 2275 tests, 0 failures, ruff check . clean. Not verified: this closes the specific typo this report showed, but the deliberate "anything else drops the pending question" design (send_message()'s own comment: "the same way an unrecognised preview reply is 'send as typed'") is unchanged and untouched here -- a reply that misses both the exact and fuzzy checks still silently drops context, by design, not by oversight; not re-tested in a live QGIS chat session from this sandbox. fixed-unverified-pending-live-session. |
| BUG-2026-09-28-4 | 2026-09-28 | this commit | medium | Live re-test of BUG-2026-09-28-1/2/3 together, same user, same request: the typo-tolerance fix worked (a "downlod" reply resolved correctly this time) and the ModuleNotFoundError was gone, but execute_pyqgis_script still failed on its first call of the session: "Isolation worker did not answer within 20s of starting ... Worker stderr, if any: " -- stderr EMPTY this time, no traceback, no crash evidence. The user also reported a Windows console window briefly opening and closing during the run, consistent with a real subprocess actually starting (not the QGIS-binary-misdetection case BUG-2026-09-28-1 already guards against, which never gets that far). Diagnosis: _WORKER_HANDSHAKE_TIMEOUT_SECONDS (20s) was sized off the ~1.18s cold-start measured against the lightweight qgis/qgis Docker image this repo's CI pins -- not representative of a real desktop QGIS install, which has more bundled plugins/providers to register, disk I/O contention with the already-running main QGIS process, and antivirus scanning a freshly spawned python.exe. This was the worker's first cold start of the session (confirmed from the tool-call list: execute_pyqgis_script appears exactly once), so a slower-than-Docker cold start is a plausible, non-alarming explanation for empty-stderr-plus-timeout, not a crash. Fixed by widening _WORKER_HANDSHAKE_TIMEOUT_SECONDS from 20 to 60 (matching the existing per-job timeout's own magnitude). This has no real downside: the two failure modes the handshake exists to catch are unaffected by its value -- a wrong interpreter resolving to the QGIS binary itself is caught by the _QGIS_BINARY_BASENAMES basename check before the handshake ever starts waiting (BUG-2026-09-28-1), and a genuine crash still surfaces immediately via the already-fixed _kill_and_drain_stderr capture, not after a long wait. No test changed (no test asserted the literal 20s value; the full suite re-verified clean: 2275 tests, 0 failures, ruff check . clean). Also flagged, NOT fixed: the same live report showed the local-data download offer naming the wrong country -- "The OpenStreetMap extract for Israel and Palestine is 216 MB" for a point the model's own later analysis correctly placed in "southern Amman / Al-Jizah area, Jordan" (31.8335 N, 35.9304 E), well inside Jordan and not near the actual Israel/West Bank border (~35.5 E). This is agent/local_data_sources.py's find_region() (Geofabrik point-in-polygon + smallest-bbox-area tie-break) picking the wrong region. Investigated but could not be root-caused or fixed from this sandbox: download.geofabrik.de is blocked by this environment's network egress policy (confirmed via the agent proxy's own status endpoint: connect_rejected, "gateway answered 403 to CONNECT"), so the actual Geofabrik index-v1.json polygon data for Jordan vs. "israel-and-palestine" could not be fetched and inspected -- and Geofabrik's own regions are known to be simplified "download convenience" shapes rather than precise political borders, so a real data-level overlap near this border area is a plausible cause that can't be distinguished here from a logic bug without the real polygon data. Per CONTRIBUTING.md #3 (flag rather than guess-fix what can't be verified here), this is left open -- see IMPLEMENTATION_TRACKER.md §1 for tracking. Low real-world severity as reported: the flow already asks the user to confirm the country/size before downloading anything, so the wrong region was caught and declined rather than silently downloading the wrong country's data. partially-fixed; the handshake-timeout half is fixed-unverified-pending-live-session, the region-matching half is not-fixed-needs-live-network-access. |
| BUG-2026-09-28-6 | 2026-09-28 | this commit | medium | Live report: same "Health facilities beyond one hour's travel" test, user flagged "the visualization of the layers on the map is not correct" -- screenshot showed the service-area polygon as a plain solid fill with a dashed outline in an arbitrary color, not the "professional" symbology this codebase's other analysis outputs get. Root cause: calculate_service_area's (agent/tools/logistics_tools.py) single-band hull polygon -- the common case, one scalar travel_cost value, not a list -- was added to the project with zero styling call at all. This sat right next to two already-correctly-styled siblings in the same function: the reachable-network lines_layer a few lines above (gets process_map_output(..., output_role="route_line"), BUG-2026-09-27-3) and the multi-band merged isochrone layer a few lines below (gets apply_graduated_style) -- only the single-band hull, the path most requests actually take, was left at QGIS's raw default new-layer symbology. Same gap class as BUG-2026-09-27-3, for this tool's single-band polygon output instead of its line output. Fixed by adding the same process_map_output(hull_layer, output_role="proximity_buffer", source_layer_id=facilities.id()) call buffer_analysis's polygon output already uses (vector_tools.py) -- proximity_buffer fits directly: a service-area isochrone is, symbologically, an area of reach around a point, the same shape buffer_analysis already styles. Wrapped in the same try/except-and-log pattern the neighboring lines_layer call already uses, so a styling failure degrades gracefully rather than failing the whole request. New test_single_band_hull_gets_proximity_buffer_styling in tests/test_logistics_tools.py, plus confirmed the existing test_scalar_travel_cost_never_calls_auto_style regression guard (unrelated to this styling call) still passes unchanged. Full suite re-verified: 2276 tests, 0 failures, ruff check . clean. Not verified: no live QGIS in this sandbox to visually confirm the resulting polygon's on-canvas appearance -- the styling call itself is fully covered by the new unit test, matching every other UI-facing change in this tracker's own standing caveat. fixed-unverified-pending-live-session. |
| BUG-2026-09-28-7 | 2026-09-28 | this commit | medium | Direct root-cause of BUG-2026-09-28-4's "flagged, NOT fixed" Geofabrik wrong-region finding, found via a second live re-test the same day: instead of "Israel and Palestine" for a Jordan point, this time the download offer named the region for the literal, meaningless coordinate "(0.000, 0.000)" -- a far more diagnostic symptom, pointing straight at the real bug rather than a border-polygon data quirk. Root cause: _maybe_ask_local_data (ui/chat_tab_widget.py) picked which Geofabrik region to offer from the QGIS canvas's CURRENT VIEW CENTRE (self._canvas_center()), never from the coordinate the request itself named. Both symptoms trace to the same cause: whenever the canvas hadn't been panned to the request's actual area yet (a fresh/default project view, or one still showing a previous request's area), the offer named whatever region the canvas happened to be looking at -- a neighboring country in the first report, a canvas with no meaningful extent at all (hence literal zeros) in the second. find_region()'s own point-in-polygon logic, already cleared by inspection in BUG-2026-09-28-4's write-up, needed no change -- this was a caller bug, not a matching bug. Fixed by preferring the coordinate the request itself names: new extract_coordinate_pair() (agent/local_data_sources.py, QGIS-free) finds an "X,Y" pair in the request text, requiring a decimal point on both numbers so a thousands separator or unrelated number list is never mistaken for one; new query_point_wgs84() (agent/local_data_loader.py) reprojects it from the project's current CRS to WGS84, the same transform pattern canvas_center_wgs84() beside it already uses for the canvas's own numbers. _maybe_ask_local_data now tries this first, falling back to the canvas centre exactly as before for a request that names no coordinate (e.g. "buffer 5km around active GDACS alerts"). 6 new tests (4 for the text-parsing half, 2 for the QGIS-availability/no-coordinate short-circuit paths -- the full CRS-transform path is live-QGIS-only, same standing limitation as canvas_center_wgs84 itself). Full suite re-verified: 2282 tests, 0 failures, ruff check . clean. See IMPLEMENTATION_TRACKER.md §1.15 for the full before/after. fixed-unverified-pending-live-session for the on-canvas behavior itself; the text-extraction and reprojection-fallback logic are fully unit-tested. |
| BUG-2026-09-28-8 | 2026-09-28 | this commit | low | Live report: "during the process a cmd window opened with nothing inside" -- a visible, blank Windows console window during execute_pyqgis_script's isolation call. Root cause: _IsolationWorker._ensure_started() (agent/services/script_isolation.py) spawns the worker via subprocess.Popen with stdin/stdout/stderr all redirected to pipes -- nothing was ever meant to print to a visible window -- but passed no creationflags, so Windows fell back to its default behavior of allocating a brand-new console for the spawned python.exe (a console-subsystem process) since the parent, QGIS, is a GUI app with no console of its own to inherit. The window was blank because everything really was captured via the pipes; the window itself was pure Windows default behavior, unrelated to any of this session's other isolation-worker fixes. Fixed by passing creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) to the Popen call -- suppresses the console allocation entirely on Windows; a deliberate no-op everywhere else (CREATE_NO_WINDOW doesn't exist outside Windows, and 0 is the only creationflags value subprocess.Popen accepts on POSIX at all -- any other value raises ValueError there, confirmed by reading CPython's own subprocess.py). 2 new tests in tests/test_script_isolation.py (TestEnsureStartedSuppressesConsoleWindow): confirms the kwarg is passed, and confirms it threads through a simulated Windows CREATE_NO_WINDOW value unchanged rather than being hardcoded to 0. Full suite re-verified: 2278 tests, 0 failures, ruff check . clean. Not verified: no live Windows QGIS session in this sandbox to confirm the window no longer appears -- the kwarg itself is fully covered by the new unit tests. fixed-unverified-pending-live-session. |
| BUG-2026-09-28-9 | 2026-09-28 | this commit | medium | Live report, a multi-step session ("Health facilities beyond one hour's travel", then a follow-up "Estimate population outside the one-hour health facility catchment" reusing the same origin): the QGIS Layers panel accumulated THREE separately-added but identically-named "Origin Point_service_area_0"-style layers stacked on top of each other, described as the map "losing control" and "creating layers on top of each other" on anything beyond a single simple request. Root cause: calculate_service_area's (agent/tools/logistics_tools.py) output layer names are deterministic -- built from the facility layer's own name plus its feature index (f"{facility_layer}_service_area_{i}"), not a per-call id -- and QgsProject.addMapLayer() does not deduplicate by name at all. Re-running the same analysis, or a follow-up request that reuses the same origin/facility layer, produced a second (third, ...) layer with the exact identical name every time; the old one was never replaced, just visually buried under the new one, at all three of this tool's addMapLayer call sites (the reachable-network lines_layer, the single-band hull, and the multi-band merged isochrone). Fixed by adding _replace_named_layer(name, new_layer), which removes every existing layer already using that exact name (not just the first match -- also cleans up any already-accumulated duplicates from before this fix) before adding the new one, and wiring it into all three call sites in place of the bare QgsProject.instance().addMapLayer(...) calls. 2 new tests (TestReplaceNamedLayer): confirms multiple stale same-named layers are all removed before the new one is added, and confirms a fresh name just adds normally with no removal calls. Full suite re-verified: 2286 tests, 0 failures, ruff check . clean. Scope note, not fixed here: this same deterministic-naming pattern is plausible in other analysis tools too (not audited) -- scoped to calculate_service_area only, the tool actually named in the live report; see IMPLEMENTATION_TRACKER.md §1 for the broader "smart mapping / layer decluttering across every analysis tool" question flagged for a human decision rather than guessed at project-wide. Not verified: no live QGIS in this sandbox to visually confirm the Layers panel no longer accumulates duplicates on a real multi-step session -- the dedup logic itself is fully covered by the new unit tests. fixed-unverified-pending-live-session. |
| BUG-2026-09-28-11 | 2026-09-28 | this commit | medium | User feedback (not a live crash report, a UX complaint about a working feature): "i don't like the style of answer with download / online is just used for critical actions not a routine task" -- every local-data-eligible request stopped to ask a free-text "reply download or online" question (_maybe_ask_local_data, ui/chat_tab_widget.py), even for the ordinary case of a small, known-size extract over a good connection, where there is no real decision to make. Separately, that same free-text question was itself a UX regression relative to this codebase's other chip-based confirmations (export/zoom/apply_style already use ChatActionRegistry clickable pills; this one alone used typed "download"/"online" text, the exact class of interaction BUG-2026-09-28-3's typo-tolerance fix had to work around rather than eliminate). Asked the user directly how routine-vs-interactive should be decided (AskUserQuestion); answer: silently auto-decide for the routine case, but genuinely ask -- via clickable chips, not free text -- when connectivity is poor or offline, calling out that field users in high-risk/low-connectivity areas need an explicit choice rather than a silent guess that might hang or fail. Fixed: new probe_connectivity()/resolve_region_with_connectivity() (agent/local_data_loader.py) run a single background-thread call combining a fast (4s timeout) HEAD-request connectivity probe with the existing Geofabrik region lookup, so the connectivity check adds no extra round trip. _maybe_ask_local_data now resolves this in the background (run_background_call, matching the existing pattern used elsewhere in this file) and _on_connectivity_and_region_resolved decides silently: good connectivity plus a reasonable/known extract size downloads immediately with only an informational note, no question; a large/unknown size, or a poor/offline connection regardless of size, calls _offer_local_data_choice, which now renders two ChatActionRegistry chips ("Download local data" / "Continue online") instead of a typed-reply prompt, and names the slow/offline condition explicitly when that's why it's asking. _on_action_clicked gained a local_data_choice chip-kind handler routing to the same _on_local_data_reply the old typed-reply path already used, so a click and a typed "download"/"online" (still accepted, unchanged) can never diverge. The old two-step "ask, then possibly ask again with the size" flow collapsed to one interactive round trip, since region/size is now always resolved before the first (and only) question is ever shown. local_data_sources.should_offer/parse_reply, find_region, and the coordinate-vs-canvas-centre logic (BUG-2026-09-28-7) are unchanged. 5 tests in tests/test_chat_widget_live.py's TestLocalDataOfferLive rewritten/added to match (silent-download-on-good-connection-and-known-size, single-round-trip large-extract ask, new poor-connection-asks-even-for-small-extract case, online-decline-flow, mid-download-stop) -- this file is live-QGIS-only (QGIS_LIVE_AVAILABLE gate), so these could not be run in this sandbox. Full plain-suite re-verified: 2286 tests, 0 failures, ruff check . clean (the plain suite doesn't cover chat_tab_widget.py/test_chat_widget_live.py at all -- both import qgis.PyQt unconditionally -- but does cover the new local_data_loader.py probe/combine functions' pure logic). Live-verified: this PR's qgis-live-tests CI job (real QGIS 4.2.2 Docker image) ran TestLocalDataOfferLive for real and passed on the second push -- the first push's single failure was the new test itself asserting chip markup against chat_browser.toPlainText() (which strips href targets, keeping only a rendered link's visible label) rather than toHtml(); the chip rendering was correct on the very first push, only the assertion's target was wrong, confirmed directly from that run's own captured log showing the chip labels present. fixed-verified; probe_connectivity's timeout-based detection is still a reasonable proxy for "slow or unreliable," not a guarantee against a connection that's merely borderline. |
| BUG-2026-09-28-10 | 2026-09-28 | this commit | medium | Live report: "the creation of the csv files can not be opened", paired with a screenshot showing an exported CSV literally named out_Health_Facilities_Beyond_1_Hour_278d9021_6333_4b63_808c_957ff81df274.csv. Root cause: export_to_csv's _derive_csv_path() (agent/tools/export_tools.py), when no output_path is given, sat the new CSV beside the layer's own on-disk source file if one existed. "Health Facilities Beyond 1 Hour" is a Processing-algorithm intermediate output, not a layer the user loaded from a real file -- its source() is a QGIS-managed, auto-generated TEMP file (the out_<name>_<uuid> pattern is QGIS's own scratch-output naming), sitting in a directory QGIS can clean up at any time. Reusing that exact ugly basename for the CSV reproduced the same unreadable name and the same unstable temp location -- explaining both the unusable filename and why the file could not reliably be found/opened afterward. Same live report separately asked that "the use of local directories should be limited to project folder / profile folder for more reusable data sets" -- the fix below is exactly that. Fixed: new _default_export_dir() returns the project's own data/20_processed folder (matching create_project_folder_structure's own "Analysis outputs" folder, project_tools.py) when the project is saved, else a per-profile folder under the QGIS profile (the same pattern local_data_loader.py's data_dir() already established for downloaded OSM data) -- creating the directory if missing. _derive_csv_path() no longer reads the layer's on-disk source at all; it always uses a clean, sanitized name under this stable folder. export_layer()'s equivalent Desktop-fallback (for GeoJSON/Shapefile/GPKG/KML) was moved to the same helper for consistency, since it's the identical "don't scatter generated files across the OS" gap, just without the ugly-basename half of the bug. Tool descriptions and the export_to_csv fallback message text updated to match (previously said "beside the layer's own on-disk source... fall back to Desktop", now correctly describes the project/profile folder). 3 tests rewritten, 1 added (TestExportToCsvDefaultOutputPath): confirms the saved-project path lands under data/20_processed and ignores an ugly on-disk source entirely, confirms the no-saved-project fallback lands under the QGIS-profile-equivalent folder (also with an ugly source present, still ignored), and the existing no-blocking-dialog regression guard re-verified with the new folder. Full suite re-verified: 2286 tests, 0 failures, ruff check . clean. Scope note, not fixed here: other export tools in this file still default to Desktop (print_map's PDF/PNG canvas export, the .docx report generator) -- same gap class, not touched in this pass since neither was named in the live report; flagged for a follow-up sweep rather than changed blind. Not verified: no live QGIS in this sandbox to confirm a real Processing-output layer's CSV now opens cleanly from the new location -- the path-derivation logic itself is fully covered by the new/updated unit tests. fixed-unverified-pending-live-session. |
| BUG-2026-09-30-1 | 2026-09-30 | unreleased (rc8 candidate) | high | Found in the rc7 interactive smoke test (real QGIS 4.2.2, Windows). execute_pyqgis_script's process-isolation path (script_isolation._build_scratch_project) called QgsProject.instance().write(snapshot_path) on the LIVE project. QgsProject.write(path) has Save As semantics, so after one isolated call the title bar read *live_snapshot, QgsProject.instance().fileName() was .../Temp/cartogen_isolation_xxxx/live_snapshot.qgz and homePath() was that temp folder. Consequences observed live: export_layer/export_to_csv wrote their GPKG and CSV into the temp folder (which run_isolated_script deletes in its finally), and a plain Ctrl+S would have saved into that vanished folder instead of the user's project. The function's own docstring claimed it "never touches the live QgsProject singleton", which was wrong. Fix: new _write_snapshot_of_live_project saves the original file name and dirty flag and restores both in a finally (so a failed write cannot leave the project renamed either). 5 new tests in tests/test_script_isolation.py using a stand-in project that reproduces the rename-on-write behaviour; mutation-checked (4 of 5 fail with the restore removed). Full suite 2,318 tests, 0 failures, ruff check . clean. fixed-unverified-pending-live-session: the stand-in reproduces the QGIS behaviour as observed, but the fix itself has not been re-run in a real QGIS window. To verify: run any execute_pyqgis_script prompt, then check the title bar and QgsProject.instance().fileName() still name the user's project. Note: this was the first occurrence found by the smoke test, not by CI; qgis-live-tests does not cover project-file identity across an isolated call. |
| NEW-2026-09-28-2 | 2026-09-28 | this commit | -- | Feature, not a bug: user asked for "alignment and scale [of] the 3D layer with the map" -- clarified via AskUserQuestion to mean georeferencing an external image (e.g. a scanned paper map or drone photo with no spatial reference) so it aligns and scales correctly against the rest of the project. georeference_image (agent/tools/raster_tools.py) already existed but only ever did one thing: a GDAL thin-plate-spline (tps=True) rubber-sheet warp, with no reported accuracy/alignment-quality figure at all -- there was no way to tell how well the image actually lined up, and TPS is the wrong transform for a clean scan of a rigid printed map (it can locally distort a source that was never actually warped). Added: a transform_type parameter ("tps" default/unchanged, "linear", "helmert"), matching QGIS Georeferencer's own transform vocabulary (researched via QGIS docs -- see the session's earlier "3D/alignment" research). "linear" and "helmert" are NOT delegated to gdal.Warp's automatic GCP-polynomial-order selection (undocumented/version-dependent behavior, and GDAL has no built-in Helmert/similarity fit at all) -- instead two new pure-Python closed-form least-squares fits, _fit_affine_transform (6-parameter: independent x/y scale, shear, rotation, translation -- QGIS's "Linear") and _fit_helmert_transform (4-parameter: one uniform scale, one rotation, no shear -- QGIS's "Helmert", the correct choice for an undistorted scan), compute the transform directly and set it via SetGeoTransform/SetProjection on a copy of the source raster. Both fits report RMSE (the same alignment-quality number QGIS's own Georeferencer panel shows) plus scale_x/scale_y (linear) or scale/rotation_degrees (helmert) in the tool's result, directly answering "alignment and scale" rather than leaving it silently unreported as before. New _crs_to_wkt() helper resolves the target_crs EPSG string to WKT for SetProjection() (which, unlike SetGCPs()/gdal.Warp(dstSRS=...), needs WKT/PROJ text, not a bare EPSG string). 18 new tests (TestFitAffineTransform, TestFitHelmertTransform, plus one new TestGeoreferenceImageValidation case): pure-Python round-trip recovery of known translation/scale/rotation/shear, RMSE staying at ~0 for an exact fit and >0 for a noisy one, the degenerate-input cases for each fit (collinear points correctly rejected only for the affine fit -- a Helmert fit's 4 unknowns are still well-determined from collinear points, only exactly-coincident points break it, confirmed by direct computation rather than assumed), and transform_type rejecting an unknown value before ever touching GDAL. docs/TOOLS_REFERENCE.md regenerated (179 tools, up from 177 -- the two new pure functions aren't tools themselves, the count moved because of an unrelated tool registered between the last regen and this one). Full suite re-verified: 2298 tests, 0 failures, ruff check . clean. Not verified: neither GDAL (osgeo) nor numpy is installed in this sandbox (osgeo import already guarded/untested even for the pre-existing TPS path -- same standing limitation, not new here), so the actual gdal.Open/CreateCopy/SetGeoTransform/SetProjection I/O path is untested end-to-end; only the transform-fitting math itself (the actual "alignment and scale" logic) is unit-tested, which is where the real risk of a wrong answer would be. fixed-unverified-pending-live-session for the GDAL I/O half; the fit math is fully unit-tested with exact-recovery assertions. |
| NEW-2026-09-28-3 | 2026-09-28 | this commit | -- | Feature, not a bug: user asked (separately from the georeferencing request above) for the map to "replicat[e] the same layer and context aware analysis visualization," citing the MapMate framework's dual-memory (operational history + persistent design-state memory) architecture as a research reference, and chose "Full dual-memory architecture" over a narrower scoped alternative via AskUserQuestion. This directly maps to IMPLEMENTATION_TRACKER.md §1.16 ("smart mapping"), where BUG-2026-09-28-9 already fixed one narrow symptom (duplicate identically-named layers from re-running calculate_service_area) but the broader "the tool loses the control" complaint -- follow-up analyses not visually matching the one before them -- was left open pending a product decision. Scoped deliberately, and documented as such in §1.16's own update: MapMate's architecture has two halves -- (1) the dual-memory system itself (operational history + design-state store + a retrieval step that lets past state inform new output), and (2) a request-validator/task-planner/tool-router layer sitting in front of the whole agent loop. Built now: half (1), for real. NOT touched: half (2) -- rearchitecting agent_orchestrator.py's core tool-calling loop is a materially larger, higher-risk change that can't be meaningfully verified without a live multi-turn session against a real LLM, and doing it in the same pass as several unrelated fixes risks regressing every existing tool-call path; flagged in §1.16 for its own dedicated pass rather than guessed at here. Built: operational history already existed (SpatialMemoryManager.log_spatial_action, recording every tool call since before this session) and needed no new work. New agent/map_state_memory.py (record_output()/recall_output()) adds the missing other half -- persistent, project-scoped design-state memory, storing {layer_id, layer_name, style_profile, properties} per output_role as a layout:<output_role> project note, reusing memory.py's existing store_project_note/get_project_notes API (the same pattern services/learning.py already established for its own pref:/rule:/usage: global notes -- no new storage plumbing invented; project-scoped rather than global since a map's visual choices belong to that project, not a cross-project preference). Wired into map_intelligence.py's process_map_output (the single choke point every analysis tool's styled output already passes through): before styling, it recalls the last style_profile/properties.color used for this output_role in this project and fills in whatever the caller left unset; after a successful styling call, it records what was actually used. A caller's own explicit style_profile/color always wins -- this only supplies a smarter default, never overrides an explicit choice. Net effect: a follow-up request producing a similar output (e.g. "do the same buffer for the other district") now visually matches the one before it instead of reverting to STYLE_PROFILES' one hardcoded per-role default every time. 15 new tests: tests/test_map_state_memory.py (10, pure Python, a fake memory-manager object matching SpatialMemoryManager's surface, same pattern tests/test_learning.py already uses) and 5 new cases in tests/test_map_intelligence.py's TestProcessMapOutputDesignStateMemory (remembered-style reuse, explicit-caller-override-wins, record-after-success, no-record-after-failure, no-active-memory-manager safe-no-op for a standalone Processing-provider run outside the chat loop) -- these mock apply_component_symbology/insert_layer_semantically themselves rather than the many individual qgis.core symbol classes they touch internally, isolating the actual thing under test (the recall/record wiring) from styling internals already covered elsewhere. Full suite re-verified: 2313 tests, 0 failures, ruff check . clean. Not verified: no live QGIS session in this sandbox to confirm the recalled color actually renders visually consistent across two real follow-up requests in a real chat session -- the recall/record logic itself is fully unit-tested, including the exact descriptor values apply_component_symbology receives, which is where a wrong answer would actually show up. |
The source tree (metadata.txt) is at v1.2.34 as of this line, but the last packaged
release zip in dist/ is still qgis_ai_assistant_v1.2.33.zip — v1.2.34 was a docs-only bump
(creating this file and IMPLEMENTATION_TRACKER.md) and was never rebuilt into a zip. If you're
looking at an installed plugin or a shipped zip rather than this source tree directly, "v1.2.33"
is the accurate, currently-installable version — that's not a discrepancy in this file, it's
just source-vs-package lag. All bugs listed above were fixed at or before v1.2.33, so this
tracker's content is identical either way; nothing here is gated on the 1.2.34 bump specifically.
Update (later same day): the root tree's install-package name changed from qgis_ai_assistant
to cartogen_ai_core as part of a full internal rebrand (see CHANGELOG.md) — the zip filenames
above (qgis_ai_assistant_v1.2.33.zip, and the v1.2.34 zip built earlier that day) are frozen
historical artifacts under the old name, left as-is rather than renamed after the fact. Any zip
built from this point forward will be named cartogen_ai_core_v*.zip instead.
Repo note: the "Update (later same day)" paragraph above and the zip-name history further up
this file describe the old qgis_ai_assistant/cartogen_ai_core repo's own rebrand, carried over
here verbatim as accurate history — not a description of this repo. This repo's build produces
cartogen_ai_v*.zip (see plugin_upload.py's PLUGIN_NAME), and it's a single tree, so there's
no second copy to propagate this file to.
| ID | Severity | Status | What was wrong | Found how | Fix and guard |
|---|---|---|---|---|---|
| BUG-2026-10-18-1 (#358) | medium | fixed-unverified | "Set the project CRS to EPSG:3857", "Assign EPSG:4326 to the layer", "Go to the bookmark" were refused as pasted fragments | rc46 tool sweep, row set_project_crs
|
verb list in task_matcher.py; ledger fragment-guard
|
| BUG-2026-10-18-2 (#359) | medium | fixed-unverified | "Show the metadata of towns" matched the "Create metadata" task and planned export tools | rc46 tool sweep, row get_layer_metadata (the only Fail) |
read-versus-create guard in classify; ledger metadata-read-no-task
|
| BUG-2026-10-18-3 (#360) | medium | fixed-unverified | A raster expression (dem@1 > 100) did not offer raster_calculator, so the model hit the allowlist refusal |
rc46 tool sweep, row raster_calculator
|
aliases; ledger raster-expression-routing
|
| BUG-2026-10-18-4 (#361) | medium | fixed-unverified |
tidy_project_layers crashed on a vector tile layer (no featureCount) |
rc46 tool sweep | skip such layers; ledger tidy-vector-tile
|
| BUG-2026-10-18-5 (#362) | medium | fixed-unverified (loop); unconfirmed cause (how-to answer, HTML dashboard) | Elevation profile had no way to get "the road's" coordinates, so the agent looped on list_layout_items; "animate" and "produce" were not recognised as requests; linked charts were not offered add_layout_chart
|
rc46 Part 4, I4, I6, I9 |
along_layer, verbs, aliases; ledger elevation-profile-along-layer, linked-charts-routing; PLAN-002 |
| BUG-2026-10-18-6 | low | fixed (process) |
docs/TOOLS_REFERENCE.md still showed the old add_layout_elevation_profile parameters after the code changed; nothing failed |
docs review 2026-10-23 |
tests/test_tools_reference_in_sync.py; ledger tools-reference-sync
|
| BUG-2026-10-18-7 | low | fixed (process) | A CI-only lint failure (ISC004) got past local checks because an older ruff was first on the PATH |
PR #365, 2026-10-18 |
tools/local_ci.sh checks the pinned ruff and runs mypy |
Still unexplained (no code fix yet, need a live session): five Processing algorithms that stalled in the rc45 hand test, and the grass:r.stream.extract crash.
Add a row above (in "Open bugs," converting the "None currently known" line to a real table
once the first one is filed) with: ID (BUG-YYYY-MM-DD-N), what's wrong, how you know (a
failing test, a stack trace, a live QGIS session's actual behavior — not a hypothetical),
severity, and current status.
Cartogen AI 1.18.5 · generated from the repository at commit 26536e9f75 on 2026-11-12 · this wiki is rebuilt on every merge to main; edits made in the browser are overwritten, so correct the source files instead.
Get started
How it works
- Architecture overview
- The agent loop
- Threads, tasks and Stop
- Tools and routing
- Providers and networking
- Memory, state and results
- Guards and honesty checks
- The dock and dialogs
- Processing provider
Tools and data
- Tool reference
- AI Imagery Feature Extraction
- Data Analysis & Prediction
- Database & Workflows
- Export & Reporting
- Humanitarian Data (HDX / OSM / geoBoundaries)
- Humanitarian Logistics
- Monitoring & Scheduling
- Print Layouts
- Project Management
- Raster
- Reporting & Document Analysis
- Satellite Imagery & Vision
- Styling & Labeling
- System, Search & Scripting
- Task & Memory Management
- Vector & Geoprocessing
- More tools, by module
- Task register
- External data sources
Humanitarian GIS
Security and privacy
Develop
Project