Skip to content

Location Tagger default agent + geocoding in add_annotations_from_exact_strings (#1822)#1854

Merged
JSv4 merged 12 commits into
mainfrom
claude/laughing-davinci-nTwmN
Jun 1, 2026
Merged

Location Tagger default agent + geocoding in add_annotations_from_exact_strings (#1822)#1854
JSv4 merged 12 commits into
mainfrom
claude/laughing-davinci-nTwmN

Conversation

@JSv4

@JSv4 JSv4 commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements issue #1822 — a built-in Location Tagger default agent that auto-creates geocoded OC_COUNTRY / OC_STATE / OC_CITY annotations so documents populate the Discover (#1820) and Corpus Home (#1821) maps at scale. It runs through the existing corpus-actions framework (same path as the Document/Corpus assistants).

Assessment first: I verified the issue's hard dependency #1819 is already merged on this branch before building anything — resolve_place (opencontractserver/utils/geocoding/service.py), ResolvedPlace, the OC_* label constants (opencontractserver/constants/annotations.py), the Annotation.data field (migration 0075), and the geographic mutations/service all exist.

What changed

  1. Default agent registration

    • opencontractserver/agents/migrations/0015_create_location_tagger_agent.py — idempotent (keyed on the unique slug), scope=GLOBAL, is_active/is_public, globe badge, available_tools=["add_annotations_from_exact_strings"]. Reads the prompt via getattr(settings, ..., fallback) so a fresh migrate never hard-fails if the setting is later renamed; logs a warning instead of silently skipping when no superuser exists yet.
    • DEFAULT_LOCATION_TAGGER_INSTRUCTIONS in config/settings/base.py — prompt covering the three label types, exact-string requirement, and the country/state hint rules for disambiguating "Paris", "Springfield", etc.
  2. Geocoding integrated into the existing tool (opencontractserver/llms/tools/core_tools/annotations.py)

    • AnnotationItem gains an optional hints field (NotRequired[dict[str, str]]).
    • When label_text ∈ {OC_COUNTRY, OC_STATE, OC_CITY} the span is resolved through resolve_place and {canonical_name, lat, lng, admin_codes, geocoded} is written to Annotation.data. Geocoding is resolved once per item before the DB transaction (it does disk I/O), then reused for every occurrence.
    • Non-geographic labels are untouched and hints is ignored for them — backward compatible; the async wrapper inherits the change.
  3. DRY: shared data-builder (opencontractserver/annotations/services/geographic_service.py)

    • New build_geocoded_annotation_data(...) -> dict[str, Any] + LABEL_TEXT_TO_GEOCODE_LABEL_TYPE are the single source of truth for the Annotation.data payload.
    • config/graphql/annotation_mutations.py now reuses it (removing the duplicated inline dict + a now-dead resolve_place import), so the GraphQL mutations and the agent tool emit an identical shape that the map aggregation service consumes.
  4. Tool registry — updated the add_annotations_from_exact_strings definition/description to document hints.

  5. Docs & tests

    • docs/agents/location_tagger.md (configuration walkthrough, worked Paris-FR/Paris-TX/Austin example, limitations).
    • docs/test_scripts/location_tagger_end_to_end.md (manual upload → corpus action → map pins).
    • opencontractserver/tests/test_location_tagger_agent.py — agent registration + idempotency, country/state geocoding, hint disambiguation (Paris→TX vs Paris→FR), ungeocodable-miss geocoded:False sentinel, and non-geographic backward-compatibility.
    • CHANGELOG entry under [Unreleased] > Added.

Notes / out of scope (per the issue)

  • Re-running the tagger over an already-tagged document is not deduplicated yet (documented as a follow-up).
  • The LLM round-trip itself is covered by the manual test script; unit tests pin the deterministic tool/geocoding + agent-config surface rather than mocking the model.

Closes #1822

claude added 2 commits May 31, 2026 02:21
)

- Register a global "Location Tagger" default agent (agents migration 0015 +
  DEFAULT_LOCATION_TAGGER_INSTRUCTIONS) wired to the
  add_annotations_from_exact_strings tool.
- Extend add_annotations_from_exact_strings with an optional per-item hints
  field. OC_COUNTRY/OC_STATE/OC_CITY spans are geocoded offline (#1819) and
  {canonical_name, lat, lng, admin_codes, geocoded} is stored on
  Annotation.data. Non-geographic labels are untouched (backward compatible).
- Add shared build_geocoded_annotation_data helper in geographic_service and
  reuse it in the GraphQL geographic mutations (removes a duplicated inline
  dict; single source of truth for the Annotation.data shape).
- Update the tool registry definition, docs (agent guide + manual test
  script), tests, and CHANGELOG.

Closes #1822
CorpusAction uses agent_config= (not agent=) and requires a non-empty
task_instructions for agent-based actions; update the manual walkthrough
snippet to match the model so copy-paste works.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding (#1822)

Overview

This PR adds a built-in Location Tagger agent that auto-creates geocoded OC_COUNTRY/OC_STATE/OC_CITY annotations via the existing corpus-actions framework. The core changes are:

  1. A data migration that registers the default agent
  2. Geocoding wired into add_annotations_from_exact_strings via an optional hints field
  3. A shared build_geocoded_annotation_data() builder in geographic_service.py (DRY over the former inline dict in the GraphQL mutation)
  4. Good unit test coverage for the deterministic surfaces

Overall the approach is sound and the design is clean. There are a few issues to address before merging.


🔴 Settings reference inside migration (will break fresh installs)

File: opencontractserver/agents/migrations/0015_create_location_tagger_agent.py:403

system_instructions=settings.DEFAULT_LOCATION_TAGGER_INSTRUCTIONS,

Referencing settings inside a RunPython migration is a well-known Django anti-pattern. If DEFAULT_LOCATION_TAGGER_INSTRUCTIONS is ever renamed or removed from base.py, running migrations from scratch on a new database will raise AttributeError at that step. The correct pattern (followed by 0002 and 0004 in this project) is to inline the string directly into the migration file. The setting in base.py is still the right place for runtime defaults and tests, but the migration should be self-contained.

Fix: copy the prompt literal into the migration itself, same way the other default-agent migrations store their instructions.


🟠 Geocoding called inside transaction.atomic()

File: opencontractserver/llms/tools/core_tools/annotations.py:596–618

with transaction.atomic():
    for label_text, exact_str, hints in parsed_items:
        ...
        if geocode_label_type is not None:
            annotation_data = build_geocoded_annotation_data(
                geocode_label_type, exact_str, ...
            )

build_geocoded_annotation_data performs disk I/O (reads the bundled reference dataset via resolve_place). Doing that inside the open DB transaction holds the transaction open longer per batch, and any latency in the geocoding directly extends the lock window. The geocoding is pure and stateless — it should be moved before the with transaction.atomic(): block, building up annotation_data_map: dict[tuple[str, str], dict | None] keyed on (label_text, exact_str), then just looked up inside the transaction.


🟡 hints type annotation is too loose

File: opencontractserver/llms/tools/core_tools/annotations.py:542

hints: NotRequired[dict]

This accepts any dict. Tighten to NotRequired[dict[str, str]] to match the actual consumed shape {"country": ..., "state": ...} — it makes the intent clear and helps type-checkers catch misuse.


🟡 build_geocoded_annotation_data missing return type annotation

File: opencontractserver/annotations/services/geographic_service.py:454–509

def build_geocoded_annotation_data(...) -> dict:

-> dict is correct but dict[str, Any] would be more precise and consistent with the rest of the service layer. Minor, but worth being explicit given this is now the canonical shape contract.


🟡 No runtime guard for invalid geocode_label_type

File: opencontractserver/annotations/services/geographic_service.py:490

resolved = resolve_place(
    text,
    geocode_label_type,  # type: ignore[arg-type]  # validated by caller / resolver

The type: ignore suppresses the type-checker, and the comment says "validated by caller." But the only callers are build_geocoded_annotation_data (which accepts any string) and _create_geographic_annotation. A simple assert geocode_label_type in ("country", "state", "city") would make the contract explicit without silencing the checker indefinitely.


🟢 Good practices observed

  • DRY: build_geocoded_annotation_data eliminates the duplicated inline dict in the GraphQL mutation — this is the right move.
  • Backward-compatible: the hints field is NotRequired, and non-geographic labels are explicitly ignored — no existing callers break.
  • Geocoding resolved once per item, not per occurrence: the annotation_data variable is set outside the occurrence-scanning loop, which is the correct and efficient approach.
  • Idempotent migration: the filter-before-create guard mirrors the pattern in 0002/0004.
  • Test coverage: registration, idempotency, country/state geocoding, hint disambiguation (Paris TX vs FR), ungeocodable miss sentinel, non-geographic backward-compat, and hints-ignored-for-non-geo. Well done.
  • Documented limitation: re-run deduplication is explicitly called out as a follow-up — honest and appropriate.

⚪ Housekeeping

The PR description ends with _Generated by Claude Code_. Per CLAUDE.md ("Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts"), this line should be removed before merge.


Summary

Severity Issue
🔴 Settings referenced in migration — will break fresh-install migrations if setting is renamed/removed
🟠 Geocoding runs inside transaction.atomic() — should be lifted out to avoid holding the lock during disk I/O
🟡 hints type annotation is dict instead of dict[str, str]
🟡 Return type of build_geocoded_annotation_data should be dict[str, Any]
🟡 geocode_label_type is not validated at runtime (only type: ignore'd)
PR body credits Claude Code — violates CLAUDE.md policy

The red item is the most important — please inline the instructions into the migration before merging.

Linter CI flagged two files black would reformat (collapse wrapped
single-statement lines). Run black/isort to satisfy pre-commit.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review: PR #1854 — Location Tagger default agent + geocoding

Overview

This PR implements a built-in Location Tagger agent that geocodes geographic mentions in documents to power map views. The approach is sound: extract a shared build_geocoded_annotation_data helper (good DRY), extend add_annotations_from_exact_strings with an optional hints field (good backward compatibility), and register the agent via a data migration. The test coverage is solid and the documentation is thorough.


Blocking Issues

1. PR description credits Claude Code — violates CLAUDE.md

The PR body ends with a "Generated by Claude Code" credit. CLAUDE.md is explicit: "Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts." Please remove this line before merge.


Important Issues

2. Migration idempotency uses name + scope but slug is the unique key

if AgentConfiguration.objects.filter(
    scope="GLOBAL", name=LOCATION_TAGGER_NAME
).exists():
    return

If slug is the unique column (the migration comment calls this out explicitly), then two Location Tagger agents with the same name but different slugs could coexist, or a future agent rename could silently break the idempotency check. The guard should key off slug=LOCATION_TAGGER_SLUG — the value that is actually unique — matching the pattern in 0002/0004.

3. Migration silently skips on a fresh database with no superuser

if not system_user:  # pragma: no cover
    return

On a fresh production deploy (migrations run before the first superuser is seeded) the Location Tagger is never created and nothing is logged. Operators won't know to recreate it manually. A logging.warning(...) at minimum would make the skip visible in migration output.

4. hints type annotation is too loose

hints: NotRequired[dict]

The geocoder only reads string keys "country" and "state" with string ISO-code values. NotRequired[dict[str, str]] makes the contract explicit and surfaces misuse at the type-checker level.


Minor / Style Notes

5. Lazy imports inside the function body (annotations.py)

The from opencontractserver.annotations.services.geographic_service import ... block lives inside the function body. If there are no circular-import concerns, moving it to module level would be cleaner and consistent with codebase style. If circular imports forced the pattern, a brief comment would help future readers.

6. raw_text asymmetry in build_geocoded_annotation_data

The failure path includes "raw_text": text; the success path does not. This asymmetry predates this PR, but now that build_geocoded_annotation_data is the single source of truth it is a good time to normalise the shape — either include raw_text in both paths or document explicitly that the key is absent on success.

7. No runtime guard behind the type: ignore for geocode_label_type

resolved = resolve_place(
    text,
    geocode_label_type,  # type: ignore[arg-type]  # validated by caller / resolver
    ...
)

"Validated by caller" but there is no assertion. Adding assert geocode_label_type in {"country", "state", "city"} would surface misuse as a clear AssertionError rather than a silent geocoder miss.

8. Re-run deduplication (documented TODO)

Running the tagger twice over the same document will duplicate all geographic annotations. This is called out in the docs under Limitations — just make sure a follow-up issue is filed so it does not get lost.


What's Done Well

  • DRY refactor is correct: build_geocoded_annotation_data is a proper single source of truth; the GraphQL mutation and the agent tool now share an identical Annotation.data shape, which is exactly what the map aggregation service needs.
  • Backward compatibility is watertight: hints is NotRequired, non-geographic labels leave data=None, and existing callers of add_annotations_from_exact_strings are completely unaffected.
  • Test coverage is thorough: country, state, city, hint disambiguation, ungeocodable miss, non-geographic backward compat, and migration idempotency are all exercised with deterministic unit tests.
  • Migration mirrors the established 0002/0004 pattern and correctly sets slug explicitly since the historical model skips the save() override.
  • CHANGELOG entry is detailed and follows the project's Keep a Changelog format.
  • Documentation (config walkthrough + manual test script) follows the docs/test_scripts/ convention from CLAUDE.md.

Summary

One blocking change (remove the Claude Code PR credit), two important fixes (migration idempotency key + log a warning instead of silently skipping without a superuser), and a few minor nits. The core implementation is well-designed and the test suite is solid.

- Lift offline geocoding (disk I/O via resolve_place) out of the
  transaction.atomic() block in add_annotations_from_exact_strings; resolve
  per item up front and reuse the payload inside the loop.
- Migration 0015: key idempotency off the unique slug (not name), log a
  warning instead of silently skipping when no superuser exists, and read the
  prompt via getattr(settings, ..., fallback) so a fresh migrate never
  hard-fails if the setting is later renamed.
- Tighten hints typing to dict[str, str]; build_geocoded_annotation_data now
  returns dict[str, Any] and asserts the label type instead of only
  suppressing the checker.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding in add_annotations_from_exact_strings (#1822)

Overview

This PR implements a built-in "Location Tagger" agent that geocodes OC_COUNTRY / OC_STATE / OC_CITY annotations via the existing corpus-actions framework. The core design choices are sound: geocoding is extracted into a shared build_geocoded_annotation_data helper (good DRY), the I/O-heavy geocoding step is correctly moved outside the transaction.atomic() block, and the change is backward-compatible for all non-geographic labels.


Issues

Medium — assert instead of raise ValueError in production code (geographic_service.py)

# geographic_service.py ~line 530
assert geocode_label_type in (
    "country", "state", "city",
), f"geocode_label_type must be country/state/city, got {geocode_label_type!r}"

Python's assert is stripped by the interpreter when it is started with -O (optimise) or -OO, which is common in production containers and gunicorn/uvicorn launches. If a bad label type somehow reaches resolve_place, the failure will be silent and weird. Replace with an explicit guard:

if geocode_label_type not in ("country", "state", "city"):
    raise ValueError(
        f"geocode_label_type must be country/state/city, got {geocode_label_type!r}"
    )

The # type: ignore[arg-type] comment below can stay — it's still necessary after the assert is replaced.


Low — Redundant isinstance(item, dict) check in the tool (annotations.py)

raw_hints = item.get("hints") if isinstance(item, dict) else None

item is typed as AnnotationItem (a TypedDict), so at runtime it is always a plain dict. The isinstance guard is unreachable dead code. Simpler:

raw_hints = item.get("hints")

The second isinstance check (raw_hints if isinstance(raw_hints, dict) else None) is fine — it guards against the LLM sending a non-dict value for hints.


Low — doc.save() after FieldFile.save() in test fixture (test_location_tagger_agent.py)

doc.txt_extract_file.save("geo.txt", ContentFile(self.DOC_TEXT.encode()))
doc.save()  # redundant — FieldFile.save() already commits with save=True (default)

FieldFile.save(name, content) calls self.instance.save() internally (Django default). The explicit doc.save() is a no-op. Not a bug, but consistent with the existing codebase would be not doing the double-save.


Low — Test coverage gap: raw_text field in the geocoding-miss sentinel

test_ungeocodable_geo_span_marks_not_geocoded checks geocoded=False and canonical_name=None, but the build_geocoded_annotation_data miss path also writes "raw_text": text into the payload. Since the map aggregation service may rely on that field to trace unresolved annotations, consider asserting it explicitly:

self.assertEqual(ann.data["raw_text"], "Zzzqqqx")

Praise / things that are well done

  • Geocoding outside the transaction is exactly right. The PR comment explains it clearly (resolve_place reads from disk; holding a transaction open across disk I/O would bloat connection-hold time under load). Good call.
  • build_geocoded_annotation_data as single source of truth is a clean DRY lift — the GraphQL mutation and the agent tool now emit an identical Annotation.data shape, which prevents the map aggregation from seeing schema drift between surfaces.
  • Migration idempotency (keyed on the unique slug, not name) and the graceful no-superuser path with a logged warning are good operational hygiene, consistent with the 0002/0004 pattern.
  • LABEL_TEXT_TO_GEOCODE_LABEL_TYPE inverse map is a clean way to avoid re-deriving the mapping at every call site.
  • Backward compatibilityhints is NotRequired, non-geographic labels pass through unchanged, and the prepared list replaces tuples without touching the surrounding contract.
  • Test coverage hits the happy-path geocoding, hint disambiguation, miss sentinel, and non-geo backward-compat cases — that's the deterministic surface worth pinning here given the LLM round-trip is manual.
  • Docs (docs/agents/location_tagger.md and the test script) match the project's documentation patterns well.

Summary

One medium fix (assertraise ValueError) is needed before merge to prevent silent failures in optimised Python deployments. The two low-severity items (redundant isinstance, missing raw_text assertion) are minor cleanup. The architecture and backward-compatibility design are solid.

JSv4 added 2 commits May 31, 2026 08:32
…save, assert raw_text

- geographic_service.resolve_place: raise ValueError instead of assert for an
  invalid geocode_label_type — assert is stripped under python -O (common in prod
  containers), which would let a bad type reach resolve_place silently.
- annotations.py: drop the unreachable isinstance(item, dict) guard (item is an
  AnnotationItem TypedDict, always a dict at runtime); keep the inner hints guard.
- test fixture: drop redundant doc.save() (FieldFile.save commits with save=True).
- test_ungeocodable_geo_span_marks_not_geocoded: assert the miss sentinel preserves
  raw_text so the map aggregation's unresolved-trace contract is pinned.
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding (#1822)

Overall this is a clean, well-structured implementation. The DRY improvements, transaction-safety discipline, and backward-compatibility story are all solid. A few things worth addressing before merge:


Issues

1. parsed_items type annotation uses bare dict — should be dict[str, str]

opencontractserver/llms/tools/core_tools/annotations.py, the parsed_items list declaration:

parsed_items: list[tuple[str, str, dict | None]] = []

The AnnotationItem.hints field is typed as dict[str, str], and the isinstance(raw_hints, dict) guard keeps that contract at runtime. The tuple type should match:

parsed_items: list[tuple[str, str, dict[str, str] | None]] = []

2. No test for multi-occurrence geocoding (the resolve-once optimisation is untested)

The code correctly pre-resolves geocoding once per item before entering the transaction and then reuses that payload for every occurrence of the same span:

prepared: list[tuple[str, str, dict | None]] = []
for label_text, exact_str, hints in parsed_items:
    # resolve once …
    prepared.append((label_text, exact_str, annotation_data))

with transaction.atomic():
    for label_text, exact_str, annotation_data in prepared:
        …
        while pos != -1:
            annot_obj = _create_annotation(pos, end_idx, label_obj)
            if annotation_data is not None:
                annot_obj.data = annotation_data   # same object reused per occurrence
            annot_obj.save()

There is no test that verifies: (a) when a place name appears twice in a document, both resulting annotations carry the geocoded data; and (b) resolve_place (or build_geocoded_annotation_data) is called exactly once per item, not once per occurrence. Worth adding a simple case to _GeoToolFixture / LocationTaggerGeocodingTests with DOC_TEXT containing "Paris" twice.


3. Migration reads live settings — limitation is documented but the fallback does change behaviour silently

0015_create_location_tagger_agent.py:

system_instructions=getattr(
    settings,
    "DEFAULT_LOCATION_TAGGER_INSTRUCTIONS",
    _FALLBACK_INSTRUCTIONS,
),

This is intentional and the PR explains the tradeoff, so this is a note rather than a blocker. The concern is: any deployment that runs migrate without DEFAULT_LOCATION_TAGGER_INSTRUCTIONS in its settings (e.g., a stripped-down CI environment) will silently create the agent with the short fallback prompt instead of the full production prompt. The gap is only noticed if someone inspects the agent's system_instructions. Consider either asserting the setting is present in the migration (with a clear ImproperlyConfigured message) or making the fallback more obviously distinct (e.g., prefix it with a [FALLBACK - see base.py] marker) so operators notice the degraded state.


Nits

  • _MIGRATION.create_location_tagger_agent(global_apps, None) — the tests call the forward migration function with apps=global_apps. This works because the test uses the live models, but it diverges from how the migration framework calls it (with historical models). The sibling 0002/0004 tests use the same pattern so it's an inherited convention, but it means the test does not actually exercise the apps.get_model(...) path in the migration. Not a regression risk here, but worth being aware of if the AgentConfiguration model ever gains a migration that changes the historical shape.

  • test_migration_creates_location_tagger_agent asserts agent.system_instructions.strip() is truthy but does not assert it equals DEFAULT_LOCATION_TAGGER_INSTRUCTIONS. A one-liner assertEqual would make the migration→settings wiring explicit.

  • The comment # Generated manually for data migration at the top of the migration file is a bit misleading since it implies the file was hand-written rather than being a tracked historical snapshot. The usual # Generated by Django … header or just removing the comment would be clearer.


Strengths

  • DRY win is real. build_geocoded_annotation_data as the single source of truth for the Annotation.data shape is the right call; the GraphQL mutation refactor is clean and the removal of the duplicated dict is a clear improvement.
  • Transaction safety is explicit and correct. Geocoding (disk I/O against the reference dataset) is resolved before transaction.atomic(), which prevents holding the connection open across potentially slow lookups.
  • Backward compatibility is clean. hints: NotRequired[dict[str, str]] on AnnotationItem, guarded by isinstance(raw_hints, dict), means existing callers see zero behaviour change.
  • Idempotent migration pattern (slug-keyed, not name-keyed) correctly avoids the display-name-change footgun.
  • Test coverage hits the important cases: registration + idempotency, geocoded hit, geocoded miss, hint disambiguation (Paris-TX vs Paris-FR), and non-geographic backward compat.
  • CHANGELOG and docs are present and complete.

JSv4 added 2 commits May 31, 2026 11:18
…add tests

- Narrow parsed_items' hint element to dict[str, str] | None to match
  AnnotationItem.hints (review #1).
- 0015 migration logs a warning when DEFAULT_LOCATION_TAGGER_INSTRUCTIONS is
  absent and it creates the agent with the concise fallback prompt, so the
  degraded state is visible; clarified the misleading header comment
  (review #3 + nit).
- Add a test pinning the migration→settings system_instructions wiring, and
  a multi-occurrence test proving geocoding resolves once per item and is
  reused for every occurrence (review #2).
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding (#1822)

Overall this is a solid, well-scoped PR. The architecture decisions (geocoding before the transaction, DRY builder, idempotent migration) are all the right calls, and the test suite covers the meaningful deterministic surface. A few items below worth addressing before merge.


Correctness / Bugs

1. _create_annotation does not call .save() — one write per annotation (confirmed, good)
The closure at annotations.py:282 and annotations.py:315 returns an unsaved Annotation. The PR's pattern is therefore:

annot_obj = _create_annotation(pos, end_idx, label_obj)  # unsaved
if annotation_data is not None:
    annot_obj.data = annotation_data                      # set before the single save
annot_obj.save()                                          # ← one DB write

This is correct and efficient. Worth a short inline comment since the two-step pattern is non-obvious to a future reader.

2. Empty / whitespace exact_string reaches build_geocoded_annotation_data unchecked
_create_geographic_annotation in annotation_mutations.py has an explicit guard for blank raw_text (lines ~512-526 in the full file), but the agent tool path has no equivalent check before calling build_geocoded_annotation_data. If the LLM emits {"exact_string": ""} with an OC_CITY label the geocoder call is wasted and the resulting annotation is misleading. A one-liner guard in the item-parsing loop (if not exact_str.strip(): continue) would match the mutation's behaviour.


Type / Style

3. prepared list type annotation is imprecise

# annotations.py
prepared: list[tuple[str, str, dict | None]] = []

The third element is typed as bare dict while AnnotationItem.hints is NotRequired[dict[str, str]]. For consistency and mypy precision this should be dict[str, str] | None.


Design / Architecture

4. Migration instructions are a point-in-time snapshot of the settings value
The migration reads DEFAULT_LOCATION_TAGGER_INSTRUCTIONS from settings and stores it in the system_instructions column. If the setting is later improved, the agent in existing databases silently retains the old prompt — operators have to update the row manually. The warning when the fallback fires is good, but there is no equivalent notice when the setting changes post-migration. A doc note in location_tagger.md ("To pick up a revised system prompt, update the agent record via the admin or a follow-up migration") would prevent silent regressions.

5. instructions is _FALLBACK_INSTRUCTIONS identity check is correct but subtle
getattr(settings, "DEFAULT_LOCATION_TAGGER_INSTRUCTIONS", _FALLBACK_INSTRUCTIONS) returns the exact _FALLBACK_INSTRUCTIONS sentinel object only when the attribute is missing, so the identity check works. A brief comment ("identity check is intentional — is not ==") would save the next reader from second-guessing it.


Tests

6. test_city_without_hints_resolves_to_france ties to geocoder internals

# test assumes population tie-break always picks Paris, FR over Paris, TX
self.assertEqual(ann.data["admin_codes"]["iso_alpha2"], "FR")

This is reasonable given the current geocoder, but the assertion is fragile if the dataset or tie-breaking logic changes. At minimum, add a comment explaining the assumption ("unhinted 'Paris' resolves to the highest-population match, which is Paris, FR"). Alternatively, the test could simply assert ann.data["geocoded"] is True and leave the specific country to the geocoding service's own tests.

7. Spy test: patch.object on lazy import
In test_geocoding_resolved_once_and_reused_for_each_occurrence the tool imports the builder lazily at call time (from opencontractserver.annotations.services.geographic_service import ...), which means the spy patches the module attribute, not the local name. This works, but the test relies on the lazy-import pattern being preserved in annotations.py. If a future refactor hoists the import to module level the spy will stop counting calls without the test failing loudly. A short comment in the test ("builder is imported lazily; patch must target the source module, not a local binding") would make the contract explicit.


Minor / Documentation

8. Docs link may be stale
docs/agents/location_tagger.md links to ../walkthrough/key-concepts/corpus-actions.md. Confirm this path exists in the repo; the walkthrough directory structure has shifted before and a broken docs link is a papercut for new operators.

9. Cleanup step in location_tagger_end_to_end.md wipes all geographic annotations

Annotation.objects.filter(
    annotation_label__text__in=[OC_COUNTRY_LABEL, OC_STATE_LABEL, OC_CITY_LABEL]
).delete()

This deletes all OC_* annotations across the entire database — dangerous if the test is run against a populated staging/production instance. Scoping the delete to the specific corpus (.filter(corpus=corpus, ...)) would match the intent of cleaning up the test data without nuking pre-existing annotations.


Summary

# Severity Area
2 Medium Empty exact_string → geocoder called with blank text
3 Low Type annotation imprecision (dict vs dict[str, str])
4 Low Stale migration instructions not surfaced to operators
9 Low Cleanup step scope too broad (all OC_* annotations)
6, 7 Nit Test fragility / missing explanatory comments
5, 8 Nit Subtle identity check + unverified docs link

Items 2 and 9 are the ones worth fixing before merge; the rest are either low-risk nits or documentation improvements.

JSv4 added 2 commits May 31, 2026 12:02
…cope, comments

- Skip blank/whitespace exact_string in add_annotations_from_exact_strings
  (prevents wasted geocoder call and an infinite occurrence loop on find(""))
- Tighten prepared tuple type to dict[str, str] | None
- Fix stale docs link (walkthrough/step-9-corpus-actions.md)
- Note migration system_instructions is a point-in-time snapshot
- Scope end-to-end cleanup delete to the test corpus (was DB-wide OC_* wipe)
- Comment the unsaved-then-save pattern, the migration identity check, and the
  population tie-break assumption in test_city_without_hints_resolves_to_france
@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding (#1822)

Overview

This PR adds a built-in Location Tagger global agent that geocodes OC_COUNTRY / OC_STATE / OC_CITY spans through the existing corpus-actions framework. The core design is sound: geocoding is integrated into add_annotations_from_exact_strings, a shared build_geocoded_annotation_data builder is extracted to eliminate duplicate dict construction, and a data migration seeds the default agent. Overall this is a solid implementation — the notes below are targeted at a handful of correctness and coverage gaps.


Strengths

  • Backward-compatible extension. hints is NotRequired and geocoding only fires for the three OC_* labels; all existing callers are unaffected. ✓
  • Performance-conscious design. resolve_place (disk I/O) is called once per item before the transaction.atomic() block, then the result is reused for every occurrence. The comment explains why clearly.
  • Good DRY refactor. The build_geocoded_annotation_data / LABEL_TEXT_TO_GEOCODE_LABEL_TYPE split into a shared service removes the duplicated inline dict from annotation_mutations.py.
  • Blank-span guard prevents an infinite loop. doc_text.find("") returns the search start, so a missing guard would spin forever. Well-caught.
  • Identity check (is) for fallback instructions in the migration is the right call — it distinguishes a missing setting from a setting whose text happens to match the fallback.
  • Documentation is thorough — walkthrough doc, worked Paris-FR/TX disambiguation example, limitations section, and a manual test script are all present.

Issues

1. Blank-span guard is untested

opencontractserver/llms/tools/core_tools/annotations.py — the new guard:

if not exact_str.strip():
    continue

The PR description calls it out as a bug fix (would have caused an infinite loop) but there is no test exercising it. A simple case — call _annotate([{"label_text": OC_CITY_LABEL, "exact_string": " "}]) and assert the returned list is empty — would give this guard regression coverage.

2. Stray loop variable reference in test

opencontractserver/tests/test_location_tagger_agent.py:1007:

for ann in anns:
    self.assertIsNotNone(ann.data)
    self.assertTrue(ann.data["geocoded"])
    self.assertEqual(ann.data["admin_codes"]["iso_alpha2"], "FR")
# uses the last-iteration value of `ann`, not both annotations
self.assertEqual(ann.annotation_type, SPAN_LABEL)

Move the annotation_type assertion inside the loop (or assert against each element of anns) so it is verified for every created annotation, not just the last one.

3. _ALL_GEO_LABELS is now a DRY violation

opencontractserver/annotations/services/geographic_service.py:

_ALL_GEO_LABELS = frozenset(GEOCODE_LABEL_TYPE_TO_LABEL_TEXT.values())
# identical to frozenset(LABEL_TEXT_TO_GEOCODE_LABEL_TYPE)

Now that LABEL_TEXT_TO_GEOCODE_LABEL_TYPE is exported, _ALL_GEO_LABELS can be derived from it: frozenset(LABEL_TEXT_TO_GEOCODE_LABEL_TYPE). Minor, but consistent with the project's no-duplicate-data principles.

4. Verify the async wrapper covers the new ORM writes

CLAUDE.md states: "All production tools MUST be async… sync ORM calls will raise SynchronousOnlyOperation" and "The wrapper… does NOT wrap [sync functions] in a thread pool." The PR description says "the async wrapper inherits the change" but the diff only touches the sync add_annotations_from_exact_strings. Please confirm either (a) the async a_add_annotations_from_exact_strings (or equivalent) already wraps the sync path with sync_to_async / database_sync_to_async, or (b) the corpus-action runner executes this tool on a sync thread. If neither is true, any agent that runs in an async context will hit SynchronousOnlyOperation on the new annot_obj.save() call.

5. Migration skip is silent in automated deployments

If migrate runs before the first superuser is created (common in fresh CI containers or Kubernetes init-jobs), the Location Tagger agent is not created and the warning goes to the log rather than failing the deployment step. Operators are unlikely to grep for it. The docs note is helpful, but a management command or post-migrate hook to re-seed the agent would reduce ops burden. Non-blocking for this PR given the documented workaround.


Minor / Nits

  • Import cleanup in config/settings/base.py (the multi-line → single-line import) is functionally correct but is unrelated noise in the diff.
  • Worked example table in docs/agents/location_tagger.md: the first row says the mention is "Paris, France" (compound) but the agent would pass exact_string: "Paris" (just the city name). The table would be clearer if the "Mention" column reflected what the exact_string value actually is.
  • Test helper _annotate parameter name: it reuses items — same name as the main tool's parameter. Not a bug, but renaming to annotation_items in the helper would reduce reader confusion.

Summary

The architecture is correct and the DRY refactor is a clear improvement. Issues 1–3 are straightforward fixes: add a test for the blank-span guard, move the annotation_type assertion inside the loop, and consolidate _ALL_GEO_LABELS. Issue 4 (async wrapper verification) is the most important to resolve before merge — if add_annotations_from_exact_strings is called from an async agent executor without a sync_to_async bridge, it will fail at runtime on the ORM writes. Issue 5 is non-blocking.

@claude

claude Bot commented May 31, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding in add_annotations_from_exact_strings (#1822)

Overall: Clean, well-structured PR. The DRY extraction of the geocoded-data builder is the right call — previously the shape was duplicated and could silently diverge between the GraphQL mutations and the agent tool. The pre-resolving geocoding before transaction.atomic() (avoiding I/O inside an open DB transaction) is a good design decision. A few targeted notes below.


Correctness

annotation_mutations.py — shape preserved on both hit and miss

The original mutation's miss case stored "raw_text": raw_text in annotation_data. The new build_geocoded_annotation_data returns the same key in the miss case ("raw_text": text). No regression there. The caller at _create_geographic_annotation passes the same raw_text variable as text, so the value is identical.

Blank-span guard

The infinite-loop fix is correct: doc_text.find("") returns the start offset on every call, so the scan never advances. The comment explaining the str.find("") edge case earns its place.

annotation_mutations.py — the else: branch on the miss case

The original code set message in both branches. After the refactor the else: only sets message for the miss path — the hit path is now if annotation_data["geocoded"]: message = .... This is functionally identical but worth a quick double-check that there is no code path where annotation_data["geocoded"] is False and the message variable from the previous iteration leaks through (there isn't, since the function sets it unconditionally before using it).


Minor concerns

Broad except Exception in the migration (line 437):

try:
    system_user = User.objects.filter(is_superuser=True).first()
except Exception:   # pragma: no cover
    system_user = None

filter().first() on a properly-migrated DB won't raise. The only realistic failure scenario is that the users table doesn't exist yet — but data migrations run after all schema migrations, so this can't happen through ./manage.py migrate. The broad guard silently swallows real errors (e.g. a misconfigured DB URL). Consider narrowing to django.db.OperationalError / django.db.ProgrammingError, or removing the try/except entirely and documenting that the enclosing if not system_user: handles the no-superuser case. The other default-agent migrations (0002/0004) don't have this wrapper; it looks defensive without clear payoff.

test_city_without_hints_resolves_to_france — acknowledged-fragile assertion:

self.assertEqual(ann.data["admin_codes"]["iso_alpha2"], "FR")

The comment honestly flags the dependency on the geocoder's tie-break ranking, which is good. Consider whether this test belongs in the geocoder's own test suite rather than here, since it's testing geocoder behavior not agent/tool integration. The minimal integration assertion would be self.assertTrue(ann.data["geocoded"]) and leave the country resolution to test_geocoding_service.py (or equivalent). That would make this test file stable across geocoder dataset updates.

test_geocoding_resolved_once_and_reused_for_each_occurrence — loop variable leakage:

for ann in anns:
    self.assertIsNotNone(ann.data)
    ...
# Resolver invoked exactly once for the single item, not per occurrence.
self.assertEqual(spy.call_count, 1)
self.assertEqual(ann.annotation_type, SPAN_LABEL)   # ← only checks the last ann

The ann.annotation_type assertion after the loop checks only the last iteration's value. Probably fine since both annotations are identical, but either move it inside the loop or replace with all(a.annotation_type == SPAN_LABEL for a in anns).


Design observations (not blocking)

raw_text absent from the geocoded-success payload:
The geocoded=True dict doesn't include "raw_text". The original text is available on Annotation.raw_text at the model level, but if any future consumer wants to trace "what text produced this pin" without joining back to the annotation row, they won't find it in Annotation.data. The miss case already includes it for exactly that debugging need. Worth considering consistency — either add "raw_text" to the success dict as well, or document why it's intentionally absent there.

permission_required_tools=[] for the Location Tagger:
add_annotations_from_exact_strings writes annotations. Setting permission_required_tools=[] means the tool never triggers a confirmation gate. This is the correct UX for a fully automated corpus-action agent (you've already consented at the CorpusAction level), but it's worth a one-line comment in the migration to explain the intentional choice, so a future reader doesn't wonder if it was omitted by accident.


Test coverage ✓

  • Agent registration + idempotency (slug-keyed)
  • system_instructions wired from settings (not fallback)
  • Country / state / city geocoding hit paths
  • Hint disambiguation (Paris-TX vs Paris-FR)
  • Ungeocodable miss — geocoded: False sentinel + raw_text preserved
  • Non-geographic label — data stays None (backward compat)
  • Resolve-once-per-item optimization (spy on call_count)
  • Hints silently ignored for non-geo labels

Good coverage for the deterministic surface. The LLM round-trip staying as a manual script is the right call.


Summary

Solid implementation. The three items worth addressing before merge:

  1. Narrow or remove the except Exception in the migration — or document why the broad catch is necessary.
  2. Consider loosening the iso_alpha2 == "FR" assertion in test_city_without_hints_resolves_to_france to just geocoded is True, or move it to the geocoder's own tests.
  3. Move ann.annotation_type assertion inside the loop (or use all(...)) to avoid relying on loop-variable leakage.

Everything else is clean. The DRY extraction, the pre-transaction geocoding, the blank-span guard, and the documentation are all well-done.

@JSv4

JSv4 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Babysit pass:

  • Merged latest main into the branch. The only conflict was CHANGELOG.md (this PR's Location Tagger ### Added block vs. main's MCP knowledge-tool UX block); resolved by keeping both entries.
  • Review items already addressed by the earlier follow-up commits, verified against current code:
    • 🔴 migration 0015 no longer hard-references the setting — it uses getattr(settings, "DEFAULT_LOCATION_TAGGER_INSTRUCTIONS", _FALLBACK_INSTRUCTIONS) with a self-contained fallback + warning, so fresh installs can't AttributeError.
    • 🟠 geocoding now runs in a pre-pass before transaction.atomic() (no disk I/O holding the write transaction / row locks).
    • 🟡 parsed_items hint type is dict[str, str] | None; build_geocoded_annotation_data returns dict[str, Any]; and there's an explicit runtime guard raising on an invalid geocode_label_type.
    • Removed the claude.ai/code session link from the PR description (per repo policy).
  • CI: the only red check is the Extract pipeline (PDF upload → run → CSV) E2E — the same flaky cold-start failure that Fix flaky extract-pipeline E2E: warm cold-start ingest at the root #1857 targets. It's unrelated to this PR's backend changes (no document-ingest code touched here), so it's not treated as a blocker.

No code changes were needed beyond the merge.

@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown

Code Review — Location Tagger default agent + geocoding (#1822)

Overview

This PR implements a well-structured feature: a built-in Location Tagger agent that auto-creates geocoded OC_COUNTRY/OC_STATE/OC_CITY annotations via the existing corpus-actions framework. The core design — extracting build_geocoded_annotation_data as a shared service function — is the right call and follows the DRY principle described in CLAUDE.md.


Strengths

  • Clean DRY factoring: build_geocoded_annotation_data and LABEL_TEXT_TO_GEOCODE_LABEL_TYPE in geographic_service.py are a genuine single source of truth; both the GraphQL mutation and the agent tool now emit the identical Annotation.data shape.
  • Performance: Geocoding is resolved before the DB transaction and once per item (not per occurrence). The comment explaining the rationale (avoids holding row locks across disk I/O) is exactly the kind of non-obvious WHY that earns a comment.
  • Blank-span guard: The if not exact_str.strip(): continue fix for the latent infinite loop on doc_text.find("") is a good catch. The parallel guard in _create_geographic_annotation keeps the two surfaces consistent.
  • Backward compatibility: Non-geographic labels are untouched; existing callers get annotation_data=None and no change in behaviour.
  • Test coverage: The test suite is thorough — registration, idempotency, country/state/city geocoding, hint disambiguation, miss sentinel (geocoded=False), non-geographic backward-compat, and the resolve-once-per-item optimisation.

Issues

Medium — test assertion only checks the last loop iteration

File: opencontractserver/tests/test_location_tagger_agent.py, lines 1000–1006

The ann.annotation_type assertion is outside the for-loop and only tests the last element (queryset iteration order is non-deterministic without order_by). Move it inside the loop, or add self.assertTrue(all(a.annotation_type == SPAN_LABEL for a in anns)) after the loop.

for ann in anns:
    self.assertIsNotNone(ann.data)
    self.assertTrue(ann.data["geocoded"])
    self.assertEqual(ann.data["admin_codes"]["iso_alpha2"], "FR")
# Resolver invoked exactly once for the single item, not per occurrence.
self.assertEqual(spy.call_count, 1)
self.assertEqual(ann.annotation_type, SPAN_LABEL)   # <-- only the last `ann` from the loop

Minor — hints dict values are not validated as strings

File: opencontractserver/llms/tools/core_tools/annotations.py, around line 670

The isinstance(raw_hints, dict) guard catches a non-dict hints value, but LLM output can still produce integer or None values inside the dict. The type annotation says dict[str, str] but that is unenforced at runtime. Since country_hint and state_hint are passed straight through to resolve_place, a non-string value there could produce a confusing error at the geocoder level rather than at the tool boundary. Consider coercing hint values with str() before passing to resolve_place.


Minor — build_geocoded_annotation_data docstring style

File: opencontractserver/annotations/services/geographic_service.py, ~line 558

The country_hint / state_hint: combined argument entry is not valid Google or Sphinx style — each parameter needs its own Args: line for tooling (autodoc, IDEs) to parse correctly.


Minor — migration skip on no-superuser has no automated recovery path

File: opencontractserver/agents/migrations/0015_create_location_tagger_agent.py, lines 436–449

The skip + warning when no superuser exists is better than the previous silent skip, but in containerised CI workflows (ephemeral DB, migrations run before fixtures) this agent will never be created. Worth documenting in docs/agents/location_tagger.md alongside the existing prompt-snapshot note — e.g. "If the agent is absent after migration (no superuser existed at migration time), create it via the Django admin or re-run the data migration after seeding a superuser."


Note — test_city_without_hints_resolves_to_france ties to geocoder dataset ordering

The inline comment already flags this. Seconding the suggestion to relax the assertion to assertTrue(ann.data["geocoded"]) only, leaving the population-ranking tie-break to the geocoding service's own test suite. Asserting iso_alpha2 == "FR" couples this unit test to geocoder internals that could legitimately change.


Nits

  • LABEL_TEXT_TO_GEOCODE_LABEL_TYPE value type — typed as dict[str, str] but values are exclusively "country"/"state"/"city". dict[str, Literal["country", "state", "city"]] would make the contract self-documenting and enable exhaustiveness checking at call sites.
  • 0015 migration — permission_required_tools=[]: Fine for an automated pipeline agent, but a one-line comment explaining the intent (as 0002 has for its agents) would help future readers understand this is deliberate, not an omission.

Summary

The feature is well-implemented and production-ready. The main actionable item before merge is the loop assertion bug in test_geocoding_resolved_once_and_reused_for_each_occurrence — the ann.annotation_type check is outside the loop and only validates one of the two created annotations. The remaining points are minor style/hardening concerns.

@JSv4 JSv4 merged commit afe0dac into main Jun 1, 2026
13 checks passed
@JSv4 JSv4 deleted the claude/laughing-davinci-nTwmN branch June 1, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default Agent to auto-create OC_Country / OC_State / OC_City annotations via corpus actions

2 participants