Skip to content

Commit afe0dac

Browse files
JSv4claude
andauthored
Location Tagger default agent + geocoding in add_annotations_from_exact_strings (#1822) (#1854)
* Add Location Tagger default agent with geocoding tool integration (#1822) - 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 * Fix CorpusAction example in Location Tagger manual test script (#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. * Apply black formatting to Location Tagger files (#1822) Linter CI flagged two files black would reformat (collapse wrapped single-statement lines). Run black/isort to satisfy pre-commit. * Address review feedback on Location Tagger (#1822) - 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. * Address review: replace assert with ValueError, drop dead isinstance/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. * Address PR #1854 review: tighten hint type, warn on fallback prompt, 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). * Address PR #1854 review: blank-span guard, prepared type, docs link/scope, 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82aeb32 commit afe0dac

10 files changed

Lines changed: 822 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Location Tagger default agent (#1822)** — a new global, public default agent
13+
that auto-creates geocoded `OC_COUNTRY` / `OC_STATE` / `OC_CITY` annotations so
14+
documents populate the Discover (#1820) and Corpus Home (#1821) maps at scale.
15+
Built on the existing corpus-actions framework (configure with an `ADD_DOCUMENT`
16+
trigger to auto-tag uploads, or `MANUAL_BATCH` to backfill a corpus).
17+
- **Default agent registration**
18+
(`opencontractserver/agents/migrations/0015_create_location_tagger_agent.py`,
19+
`DEFAULT_LOCATION_TAGGER_INSTRUCTIONS` in `config/settings/base.py`): a
20+
globe-badged `Location Tagger` agent wired to the
21+
`add_annotations_from_exact_strings` tool. Idempotent and slug-safe (mirrors
22+
`0002`/`0004`).
23+
- **Geocoding integrated into `add_annotations_from_exact_strings`**
24+
(`opencontractserver/llms/tools/core_tools/annotations.py`): each item gained
25+
an optional `hints` field (`{"country": "US", "state": "TX"}`). When
26+
`label_text` is one of the OC_* geographic labels the span is resolved via the
27+
offline geocoder (#1819) and `{canonical_name, lat, lng, admin_codes,
28+
geocoded}` is written to `Annotation.data`. Non-geographic labels are
29+
untouched — fully backward-compatible.
30+
- **Shared geocoding-data builder** (`build_geocoded_annotation_data` +
31+
`LABEL_TEXT_TO_GEOCODE_LABEL_TYPE` in
32+
`opencontractserver/annotations/services/geographic_service.py`): single source
33+
of truth for the `Annotation.data` payload, now reused by both the GraphQL
34+
geographic mutations (`config/graphql/annotation_mutations.py`) and the agent
35+
tool so map aggregation sees one identical shape (DRY — removes the duplicated
36+
inline dict from the mutation).
37+
- **Docs & tests**: `docs/agents/location_tagger.md` (configuration + worked
38+
example + limitations), `docs/test_scripts/location_tagger_end_to_end.md`
39+
(manual upload → corpus action → map pins), and
40+
`opencontractserver/tests/test_location_tagger_agent.py` (agent registration,
41+
geocoding + hint disambiguation, ungeocodable-miss sentinel, non-geographic
42+
backward-compatibility).
43+
- **Review follow-ups (#1822)**: tightened the `parsed_items` hint tuple type
44+
to `dict[str, str] | None`; the `0015` migration now logs a warning when it
45+
falls back to the concise built-in prompt (settings prompt absent) so the
46+
degraded state is visible; added tests pinning the migration→settings
47+
`system_instructions` wiring and proving geocoding is resolved once per item
48+
and reused for every occurrence (not re-resolved per match).
49+
- **Blank-span guard (#1822 review)**: `add_annotations_from_exact_strings`
50+
now skips items whose `exact_string` is blank/whitespace-only. Besides
51+
wasting a geocoder call, `doc_text.find("")` returns the search start so the
52+
occurrence loop never advanced — a blank span would have spun forever.
53+
Mirrors the blank-`raw_text` guard in `_create_geographic_annotation`.
1254
- **MCP knowledge-tool UX — make a corpus usable as a low-friction tool**
1355
(`opencontractserver/mcp/`, issues #1858–#1862). A hands-on evaluation of the
1456
live public MCP interface found search returned nothing, full text crashed on

config/graphql/annotation_mutations.py

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,6 @@ def _create_geographic_annotation(
508508
``data['geocoded']`` flag distinguishes resolved from un-resolved rows
509509
so the aggregation service excludes the latter.
510510
"""
511-
from opencontractserver.utils.geocoding import resolve_place
512-
513511
# Guard empty / whitespace-only ``raw_text`` up front — an empty span
514512
# produces a no-op annotation (``geocoded=False``, no canonical_name)
515513
# that pollutes the user's annotation set without contributing to the
@@ -526,37 +524,23 @@ def _create_geographic_annotation(
526524

527525
from opencontractserver.annotations.services.geographic_service import (
528526
GEOCODE_LABEL_TYPE_TO_LABEL_TEXT,
527+
build_geocoded_annotation_data,
529528
)
530529

531530
label_text = GEOCODE_LABEL_TYPE_TO_LABEL_TEXT[geocode_label_type]
532531
color, icon, description = _GEOCODE_LABEL_TYPE_TO_OC_LABEL_METADATA[
533532
geocode_label_type
534533
]
535534

536-
resolved = resolve_place(
537-
raw_text,
535+
annotation_data = build_geocoded_annotation_data(
538536
geocode_label_type,
537+
raw_text,
539538
country_hint=country_hint,
540539
state_hint=state_hint,
541540
)
542-
if resolved is not None:
543-
annotation_data = {
544-
"canonical_name": resolved.canonical_name,
545-
"lat": resolved.lat,
546-
"lng": resolved.lng,
547-
"admin_codes": resolved.admin_codes,
548-
"geocoded": True,
549-
}
550-
message = f"Resolved '{raw_text}' to '{resolved.canonical_name}'"
541+
if annotation_data["geocoded"]:
542+
message = f"Resolved '{raw_text}' to '{annotation_data['canonical_name']}'"
551543
else:
552-
annotation_data = {
553-
"canonical_name": None,
554-
"lat": None,
555-
"lng": None,
556-
"admin_codes": {},
557-
"geocoded": False,
558-
"raw_text": raw_text,
559-
}
560544
message = (
561545
f"Annotation created but '{raw_text}' did not resolve to a "
562546
f"known {geocode_label_type}; pin omitted from map "

config/settings/base.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
from opencontractserver.constants.agent_memory import (
1212
MEMORY_CURATION_CHECK_INTERVAL_SECONDS,
1313
)
14-
from opencontractserver.constants.celery import (
15-
CELERY_REDIS_VISIBILITY_TIMEOUT_SECONDS,
16-
)
14+
from opencontractserver.constants.celery import CELERY_REDIS_VISIBILITY_TIMEOUT_SECONDS
1715
from opencontractserver.constants.document_processing import MAX_FILE_UPLOAD_SIZE_BYTES
1816

1917
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
@@ -1491,6 +1489,43 @@
14911489
- Never pad answers with general knowledge or outside information. Your knowledge boundary is the boundary \
14921490
of your documents."""
14931491

1492+
DEFAULT_LOCATION_TAGGER_INSTRUCTIONS = """You are the Location Tagger — an automated agent that finds geographic place \
1493+
names in a document and turns them into geocoded annotations so they can be plotted on a map.
1494+
1495+
**YOUR TASK:**
1496+
Read the document, identify every mention of a country, U.S. state, or city, and create an \
1497+
annotation for each one using the `add_annotations_from_exact_strings` tool. Use exactly these \
1498+
three label types:
1499+
- `OC_COUNTRY` — for countries (e.g. "France", "United States").
1500+
- `OC_STATE` — for U.S. states / first-level administrative divisions (e.g. "Texas", "California").
1501+
- `OC_CITY` — for cities and localities (e.g. "Austin", "Paris").
1502+
1503+
**HOW TO CALL THE TOOL:**
1504+
Pass a list of items. Each item is `{"label_text": <one of OC_COUNTRY/OC_STATE/OC_CITY>, \
1505+
"exact_string": <text exactly as it appears in the document>, "hints": {...}}`.
1506+
- `exact_string` MUST match the document text character-for-character (the tool finds and \
1507+
annotates every exact occurrence). Do not paraphrase or change casing.
1508+
- Group multiple places into a single tool call where possible.
1509+
1510+
**DISAMBIGUATION RULES (IMPORTANT):**
1511+
Many place names are ambiguous ("Paris" is in both France and Texas; "Springfield" is in many \
1512+
U.S. states). Always supply hints so the geocoder resolves the right place:
1513+
- When tagging a CITY, include the country in `hints` and — if the city is in the United States — \
1514+
the two-letter state code. Example: for "Paris" in a document about Texas, send \
1515+
`"hints": {"country": "US", "state": "TX"}`; for "Paris" in a French context send \
1516+
`"hints": {"country": "FR"}`.
1517+
- When tagging a U.S. STATE, you may include `"hints": {"country": "US"}`.
1518+
- Countries are self-disambiguating; hints are optional for `OC_COUNTRY`.
1519+
Infer the hints from the surrounding context of the document (nearby country/state mentions, the \
1520+
document's subject matter). When the context is genuinely unclear, omit the hint rather than \
1521+
guessing wildly — the geocoder falls back to the most prominent match.
1522+
1523+
**RULES:**
1524+
- Only tag real geographic places. Do not tag organizations, person names, or adjectives that \
1525+
merely resemble place names unless they clearly refer to the place.
1526+
- Do not invent text. Every `exact_string` must be copied verbatim from the document.
1527+
- If you find no recognizable places, do nothing and say so briefly."""
1528+
14941529
# LLM Client Provider Settings
14951530
# ------------------------------------------------------------------------------
14961531
LLM_CLIENT_PROVIDER = env.str("LLM_CLIENT_PROVIDER", default="openai")

docs/agents/location_tagger.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Location Tagger Agent
2+
3+
The **Location Tagger** is a built-in, global default agent that automatically
4+
finds place names in a document and turns them into *geocoded* annotations:
5+
`OC_COUNTRY`, `OC_STATE`, and `OC_CITY`. Each annotation it creates carries the
6+
resolved coordinates and administrative codes in its `data` payload, so the
7+
places show up as pins on the **Discover** and **Corpus Home** maps.
8+
9+
It is wired to run through the ordinary
10+
[corpus-actions](../walkthrough/step-9-corpus-actions.md) framework — the
11+
same execution path used by the built-in Document Assistant and Corpus
12+
Assistant — so you configure it once on a corpus and it runs automatically.
13+
14+
## What it does
15+
16+
For every place mention it recognises, the agent calls the
17+
`add_annotations_from_exact_strings` tool with the appropriate label and a set
18+
of *hints*. When the label is one of the geographic labels
19+
(`OC_COUNTRY` / `OC_STATE` / `OC_CITY`), the tool routes the string through the
20+
offline geocoding service ([#1819](https://github.com/Open-Source-Legal/cite/issues/1819))
21+
and stores the result on the annotation:
22+
23+
```json
24+
{
25+
"canonical_name": "Austin",
26+
"lat": 30.2672,
27+
"lng": -97.7431,
28+
"admin_codes": { "iso_alpha2": "US", "admin1": "TX" },
29+
"geocoded": true
30+
}
31+
```
32+
33+
Non-geographic labels are unaffected — the `hints` field is simply ignored for
34+
them, so existing callers of the tool keep working unchanged.
35+
36+
## Configuration walkthrough
37+
38+
1. **Migrate.** After deploying, run migrations. The Location Tagger appears as
39+
a global, public agent in the agent picker:
40+
41+
```bash
42+
docker compose -f production.yml --profile migrate up migrate
43+
```
44+
45+
> **Note — the migration is a one-time snapshot.** It copies
46+
> `DEFAULT_LOCATION_TAGGER_INSTRUCTIONS` from settings into the agent's
47+
> `system_instructions` column at creation time. If you later improve the
48+
> prompt in `config/settings/base.py`, **existing databases keep the old
49+
> prompt** — update the `Location Tagger` agent record via the Django admin
50+
> (or a follow-up data migration) to pick up the revision.
51+
52+
2. **Add a corpus action.** On the corpus you want tagged, create a
53+
`CorpusAction` that points at the **Location Tagger** agent and choose a
54+
trigger:
55+
56+
| Trigger | When it fires | Use it to… |
57+
| -------------- | ------------------------------------------ | ----------------------------------- |
58+
| `ADD_DOCUMENT` | Every time a document is added to the corpus | Auto-tag new uploads as they arrive |
59+
| `MANUAL_BATCH` | When you run the action on demand | Back-fill an existing corpus |
60+
61+
3. **Upload or back-fill.** With `ADD_DOCUMENT`, upload a document containing
62+
place mentions and the action fires automatically. With `MANUAL_BATCH`,
63+
trigger the action to sweep documents already in the corpus.
64+
65+
4. **See the pins.** Open **Corpus Home** (or **Discover**) and switch to the
66+
map view; the geocoded annotations render as pins.
67+
68+
## Worked example
69+
70+
Given a paragraph such as:
71+
72+
> "The summit was held in **Paris, France**, with follow-up meetings in
73+
> **Austin, Texas** and **Paris, Texas**."
74+
75+
the agent emits three city annotations, disambiguated via hints:
76+
77+
| Mention | Label | `hints` sent by the agent | Resolved `admin_codes` |
78+
| --------------- | ----------- | ---------------------------------- | --------------------------------- |
79+
| Paris, France | `OC_CITY` | `{ "country": "FR" }` | `{ "iso_alpha2": "FR" }` |
80+
| Austin, Texas | `OC_CITY` | `{ "country": "US", "state": "TX" }` | `{ "iso_alpha2": "US", "admin1": "TX" }` |
81+
| Paris, Texas | `OC_CITY` | `{ "country": "US", "state": "TX" }` | `{ "iso_alpha2": "US", "admin1": "TX" }` |
82+
83+
Without the `state` hint, "Paris" would be ambiguous; the hint lets the
84+
geocoder pick **Paris, TX** over **Paris, France**.
85+
86+
## Limitations
87+
88+
- **Offline reference dataset.** Resolution uses the bundled reference data
89+
(ISO 3166-1 countries, US states, and a curated set of cities). Places not in
90+
the dataset cannot be geocoded and are skipped.
91+
- **No ambiguous-name resolution beyond hints.** Disambiguation relies on the
92+
`country` / `state` hints the agent supplies. A bare, ambiguous place name
93+
resolves to the dataset's best single match.
94+
- **Exact-string matching.** Annotations are created only where the place name
95+
appears verbatim in the document text.
96+
- **Re-runs are not deduplicated yet.** Running the tagger again over an
97+
already-tagged document may create duplicate annotations; idempotent
98+
re-tagging is a planned follow-up.
99+
100+
## Related
101+
102+
- [#1819](https://github.com/Open-Source-Legal/cite/issues/1819) — geocoding foundation (`resolve_place`, `OC_*` labels)
103+
- [#1820](https://github.com/Open-Source-Legal/cite/issues/1820) / [#1821](https://github.com/Open-Source-Legal/cite/issues/1821) — the Discover and Corpus Home map views
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Test: Location Tagger end-to-end (upload → corpus action → map pins)
2+
3+
## Purpose
4+
Verify that the **Location Tagger** default agent, when wired as a
5+
`CorpusAction`, auto-creates geocoded `OC_COUNTRY` / `OC_STATE` / `OC_CITY`
6+
annotations on documents and that those annotations surface as pins on the
7+
Corpus Home map.
8+
9+
## Prerequisites
10+
- Migrations applied through the migration that creates the Location Tagger
11+
agent (agents app `0015_create_location_tagger_agent`).
12+
- At least one superuser exists (the default agent is created with the first
13+
superuser as its creator).
14+
- A corpus you can add documents to.
15+
- An LLM backend configured (the agent runs through the normal agent stack).
16+
17+
## Steps
18+
19+
1. Confirm the default agent exists.
20+
```bash
21+
docker compose -f local.yml run --rm django python manage.py shell -c "
22+
from opencontractserver.agents.models import AgentConfiguration
23+
a = AgentConfiguration.objects.get(name='Location Tagger')
24+
print(a.scope, a.is_active, a.is_public, a.available_tools, a.badge_config)
25+
"
26+
```
27+
Expected: `GLOBAL True True ['add_annotations_from_exact_strings'] {...globe...}`.
28+
29+
2. Create a corpus action that runs the Location Tagger on document add.
30+
```bash
31+
docker compose -f local.yml run --rm django python manage.py shell -c "
32+
from opencontractserver.agents.models import AgentConfiguration
33+
from opencontractserver.corpuses.models import Corpus, CorpusAction
34+
from django.contrib.auth import get_user_model
35+
User = get_user_model()
36+
u = User.objects.filter(is_superuser=True).first()
37+
corpus = Corpus.objects.first()
38+
agent = AgentConfiguration.objects.get(name='Location Tagger')
39+
CorpusAction.objects.create(
40+
name='Auto location tagger',
41+
corpus=corpus,
42+
agent_config=agent,
43+
task_instructions=(
44+
'Find every country, U.S. state, and city mentioned in the document '
45+
'and tag them as OC_COUNTRY / OC_STATE / OC_CITY, supplying '
46+
'country/state hints to disambiguate ambiguous names.'
47+
),
48+
trigger='add_document',
49+
creator=u,
50+
)
51+
print('CorpusAction created for corpus', corpus.id)
52+
"
53+
```
54+
55+
3. Upload a document containing known place mentions (e.g. a text/PDF file
56+
mentioning 'Paris, France' and 'Austin, Texas'). The `ADD_DOCUMENT`
57+
trigger fires the agent asynchronously.
58+
59+
4. Inspect the created annotations and their geocoded `data`.
60+
```bash
61+
docker compose -f local.yml run --rm django python manage.py shell -c "
62+
from opencontractserver.annotations.models import Annotation
63+
from opencontractserver.constants.annotations import (
64+
OC_CITY_LABEL, OC_COUNTRY_LABEL, OC_STATE_LABEL,
65+
)
66+
qs = Annotation.objects.filter(
67+
annotation_label__text__in=[OC_COUNTRY_LABEL, OC_STATE_LABEL, OC_CITY_LABEL]
68+
)
69+
for a in qs:
70+
print(a.annotation_label.text, a.raw_text, a.data)
71+
"
72+
```
73+
74+
5. Open Corpus Home for the corpus in the frontend and switch to the map view.
75+
76+
## Expected Results
77+
- Step 1: the Location Tagger agent is present, active, public, global, and
78+
exposes only `add_annotations_from_exact_strings`.
79+
- Step 4: each geographic annotation's `data` contains `canonical_name`,
80+
`lat`, `lng`, and `admin_codes`; e.g. 'Austin' →
81+
`admin_codes == {"iso_alpha2": "US", "admin1": "TX"}`, and 'Paris, France' →
82+
`admin_codes["iso_alpha2"] == "FR"`.
83+
- Step 5: pins appear on the Corpus Home map at the resolved coordinates.
84+
85+
## Cleanup
86+
```bash
87+
docker compose -f local.yml run --rm django python manage.py shell -c "
88+
from opencontractserver.annotations.models import Annotation
89+
from opencontractserver.constants.annotations import (
90+
OC_CITY_LABEL, OC_COUNTRY_LABEL, OC_STATE_LABEL,
91+
)
92+
from opencontractserver.corpuses.models import CorpusAction
93+
# Scope the cleanup to the corpus this test targeted. A global
94+
# annotation_label__text__in delete would wipe OC_* annotations across EVERY
95+
# corpus in the database — never run that against a populated staging/prod DB.
96+
action = CorpusAction.objects.filter(name='Auto location tagger').first()
97+
if action is not None:
98+
Annotation.objects.filter(
99+
corpus=action.corpus,
100+
annotation_label__text__in=[OC_COUNTRY_LABEL, OC_STATE_LABEL, OC_CITY_LABEL],
101+
).delete()
102+
action.delete()
103+
"
104+
```

0 commit comments

Comments
 (0)