Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Location Tagger default agent (#1822)** — a new global, public 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.
Built on the existing corpus-actions framework (configure with an `ADD_DOCUMENT`
trigger to auto-tag uploads, or `MANUAL_BATCH` to backfill a corpus).
- **Default agent registration**
(`opencontractserver/agents/migrations/0015_create_location_tagger_agent.py`,
`DEFAULT_LOCATION_TAGGER_INSTRUCTIONS` in `config/settings/base.py`): a
globe-badged `Location Tagger` agent wired to the
`add_annotations_from_exact_strings` tool. Idempotent and slug-safe (mirrors
`0002`/`0004`).
- **Geocoding integrated into `add_annotations_from_exact_strings`**
(`opencontractserver/llms/tools/core_tools/annotations.py`): each item gained
an optional `hints` field (`{"country": "US", "state": "TX"}`). When
`label_text` is one of the OC_* geographic labels the span is resolved via the
offline geocoder (#1819) and `{canonical_name, lat, lng, admin_codes,
geocoded}` is written to `Annotation.data`. Non-geographic labels are
untouched — fully backward-compatible.
- **Shared geocoding-data builder** (`build_geocoded_annotation_data` +
`LABEL_TEXT_TO_GEOCODE_LABEL_TYPE` in
`opencontractserver/annotations/services/geographic_service.py`): single source
of truth for the `Annotation.data` payload, now reused by both the GraphQL
geographic mutations (`config/graphql/annotation_mutations.py`) and the agent
tool so map aggregation sees one identical shape (DRY — removes the duplicated
inline dict from the mutation).
- **Docs & tests**: `docs/agents/location_tagger.md` (configuration + worked
example + limitations), `docs/test_scripts/location_tagger_end_to_end.md`
(manual upload → corpus action → map pins), and
`opencontractserver/tests/test_location_tagger_agent.py` (agent registration,
geocoding + hint disambiguation, ungeocodable-miss sentinel, non-geographic
backward-compatibility).
- **Review follow-ups (#1822)**: tightened the `parsed_items` hint tuple type
to `dict[str, str] | None`; the `0015` migration now logs a warning when it
falls back to the concise built-in prompt (settings prompt absent) so the
degraded state is visible; added tests pinning the migration→settings
`system_instructions` wiring and proving geocoding is resolved once per item
and reused for every occurrence (not re-resolved per match).
- **Blank-span guard (#1822 review)**: `add_annotations_from_exact_strings`
now skips items whose `exact_string` is blank/whitespace-only. Besides
wasting a geocoder call, `doc_text.find("")` returns the search start so the
occurrence loop never advanced — a blank span would have spun forever.
Mirrors the blank-`raw_text` guard in `_create_geographic_annotation`.
- **MCP knowledge-tool UX — make a corpus usable as a low-friction tool**
(`opencontractserver/mcp/`, issues #1858–#1862). A hands-on evaluation of the
live public MCP interface found search returned nothing, full text crashed on
Expand Down
26 changes: 5 additions & 21 deletions config/graphql/annotation_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,6 @@ def _create_geographic_annotation(
``data['geocoded']`` flag distinguishes resolved from un-resolved rows
so the aggregation service excludes the latter.
"""
from opencontractserver.utils.geocoding import resolve_place

# Guard empty / whitespace-only ``raw_text`` up front — an empty span
# produces a no-op annotation (``geocoded=False``, no canonical_name)
# that pollutes the user's annotation set without contributing to the
Expand All @@ -526,37 +524,23 @@ def _create_geographic_annotation(

from opencontractserver.annotations.services.geographic_service import (
GEOCODE_LABEL_TYPE_TO_LABEL_TEXT,
build_geocoded_annotation_data,
)

label_text = GEOCODE_LABEL_TYPE_TO_LABEL_TEXT[geocode_label_type]
color, icon, description = _GEOCODE_LABEL_TYPE_TO_OC_LABEL_METADATA[
geocode_label_type
]

resolved = resolve_place(
raw_text,
annotation_data = build_geocoded_annotation_data(
geocode_label_type,
raw_text,
country_hint=country_hint,
state_hint=state_hint,
)
if resolved is not None:
annotation_data = {
"canonical_name": resolved.canonical_name,
"lat": resolved.lat,
"lng": resolved.lng,
"admin_codes": resolved.admin_codes,
"geocoded": True,
}
message = f"Resolved '{raw_text}' to '{resolved.canonical_name}'"
if annotation_data["geocoded"]:
message = f"Resolved '{raw_text}' to '{annotation_data['canonical_name']}'"
else:
annotation_data = {
"canonical_name": None,
"lat": None,
"lng": None,
"admin_codes": {},
"geocoded": False,
"raw_text": raw_text,
}
message = (
f"Annotation created but '{raw_text}' did not resolve to a "
f"known {geocode_label_type}; pin omitted from map "
Expand Down
41 changes: 38 additions & 3 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@
from opencontractserver.constants.agent_memory import (
MEMORY_CURATION_CHECK_INTERVAL_SECONDS,
)
from opencontractserver.constants.celery import (
CELERY_REDIS_VISIBILITY_TIMEOUT_SECONDS,
)
from opencontractserver.constants.celery import CELERY_REDIS_VISIBILITY_TIMEOUT_SECONDS
from opencontractserver.constants.document_processing import MAX_FILE_UPLOAD_SIZE_BYTES

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

DEFAULT_LOCATION_TAGGER_INSTRUCTIONS = """You are the Location Tagger — an automated agent that finds geographic place \
names in a document and turns them into geocoded annotations so they can be plotted on a map.

**YOUR TASK:**
Read the document, identify every mention of a country, U.S. state, or city, and create an \
annotation for each one using the `add_annotations_from_exact_strings` tool. Use exactly these \
three label types:
- `OC_COUNTRY` — for countries (e.g. "France", "United States").
- `OC_STATE` — for U.S. states / first-level administrative divisions (e.g. "Texas", "California").
- `OC_CITY` — for cities and localities (e.g. "Austin", "Paris").

**HOW TO CALL THE TOOL:**
Pass a list of items. Each item is `{"label_text": <one of OC_COUNTRY/OC_STATE/OC_CITY>, \
"exact_string": <text exactly as it appears in the document>, "hints": {...}}`.
- `exact_string` MUST match the document text character-for-character (the tool finds and \
annotates every exact occurrence). Do not paraphrase or change casing.
- Group multiple places into a single tool call where possible.

**DISAMBIGUATION RULES (IMPORTANT):**
Many place names are ambiguous ("Paris" is in both France and Texas; "Springfield" is in many \
U.S. states). Always supply hints so the geocoder resolves the right place:
- When tagging a CITY, include the country in `hints` and — if the city is in the United States — \
the two-letter state code. Example: for "Paris" in a document about Texas, send \
`"hints": {"country": "US", "state": "TX"}`; for "Paris" in a French context send \
`"hints": {"country": "FR"}`.
- When tagging a U.S. STATE, you may include `"hints": {"country": "US"}`.
- Countries are self-disambiguating; hints are optional for `OC_COUNTRY`.
Infer the hints from the surrounding context of the document (nearby country/state mentions, the \
document's subject matter). When the context is genuinely unclear, omit the hint rather than \
guessing wildly — the geocoder falls back to the most prominent match.

**RULES:**
- Only tag real geographic places. Do not tag organizations, person names, or adjectives that \
merely resemble place names unless they clearly refer to the place.
- Do not invent text. Every `exact_string` must be copied verbatim from the document.
- If you find no recognizable places, do nothing and say so briefly."""

# LLM Client Provider Settings
# ------------------------------------------------------------------------------
LLM_CLIENT_PROVIDER = env.str("LLM_CLIENT_PROVIDER", default="openai")
Expand Down
103 changes: 103 additions & 0 deletions docs/agents/location_tagger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Location Tagger Agent

The **Location Tagger** is a built-in, global default agent that automatically
finds place names in a document and turns them into *geocoded* annotations:
`OC_COUNTRY`, `OC_STATE`, and `OC_CITY`. Each annotation it creates carries the
resolved coordinates and administrative codes in its `data` payload, so the
places show up as pins on the **Discover** and **Corpus Home** maps.

It is wired to run through the ordinary
[corpus-actions](../walkthrough/step-9-corpus-actions.md) framework — the
same execution path used by the built-in Document Assistant and Corpus
Assistant — so you configure it once on a corpus and it runs automatically.

## What it does

For every place mention it recognises, the agent calls the
`add_annotations_from_exact_strings` tool with the appropriate label and a set
of *hints*. When the label is one of the geographic labels
(`OC_COUNTRY` / `OC_STATE` / `OC_CITY`), the tool routes the string through the
offline geocoding service ([#1819](https://github.com/Open-Source-Legal/cite/issues/1819))
and stores the result on the annotation:

```json
{
"canonical_name": "Austin",
"lat": 30.2672,
"lng": -97.7431,
"admin_codes": { "iso_alpha2": "US", "admin1": "TX" },
"geocoded": true
}
```

Non-geographic labels are unaffected — the `hints` field is simply ignored for
them, so existing callers of the tool keep working unchanged.

## Configuration walkthrough

1. **Migrate.** After deploying, run migrations. The Location Tagger appears as
a global, public agent in the agent picker:

```bash
docker compose -f production.yml --profile migrate up migrate
```

> **Note — the migration is a one-time snapshot.** It copies
> `DEFAULT_LOCATION_TAGGER_INSTRUCTIONS` from settings into the agent's
> `system_instructions` column at creation time. If you later improve the
> prompt in `config/settings/base.py`, **existing databases keep the old
> prompt** — update the `Location Tagger` agent record via the Django admin
> (or a follow-up data migration) to pick up the revision.

2. **Add a corpus action.** On the corpus you want tagged, create a
`CorpusAction` that points at the **Location Tagger** agent and choose a
trigger:

| Trigger | When it fires | Use it to… |
| -------------- | ------------------------------------------ | ----------------------------------- |
| `ADD_DOCUMENT` | Every time a document is added to the corpus | Auto-tag new uploads as they arrive |
| `MANUAL_BATCH` | When you run the action on demand | Back-fill an existing corpus |

3. **Upload or back-fill.** With `ADD_DOCUMENT`, upload a document containing
place mentions and the action fires automatically. With `MANUAL_BATCH`,
trigger the action to sweep documents already in the corpus.

4. **See the pins.** Open **Corpus Home** (or **Discover**) and switch to the
map view; the geocoded annotations render as pins.

## Worked example

Given a paragraph such as:

> "The summit was held in **Paris, France**, with follow-up meetings in
> **Austin, Texas** and **Paris, Texas**."

the agent emits three city annotations, disambiguated via hints:

| Mention | Label | `hints` sent by the agent | Resolved `admin_codes` |
| --------------- | ----------- | ---------------------------------- | --------------------------------- |
| Paris, France | `OC_CITY` | `{ "country": "FR" }` | `{ "iso_alpha2": "FR" }` |
| Austin, Texas | `OC_CITY` | `{ "country": "US", "state": "TX" }` | `{ "iso_alpha2": "US", "admin1": "TX" }` |
| Paris, Texas | `OC_CITY` | `{ "country": "US", "state": "TX" }` | `{ "iso_alpha2": "US", "admin1": "TX" }` |

Without the `state` hint, "Paris" would be ambiguous; the hint lets the
geocoder pick **Paris, TX** over **Paris, France**.

## Limitations

- **Offline reference dataset.** Resolution uses the bundled reference data
(ISO 3166-1 countries, US states, and a curated set of cities). Places not in
the dataset cannot be geocoded and are skipped.
- **No ambiguous-name resolution beyond hints.** Disambiguation relies on the
`country` / `state` hints the agent supplies. A bare, ambiguous place name
resolves to the dataset's best single match.
- **Exact-string matching.** Annotations are created only where the place name
appears verbatim in the document text.
- **Re-runs are not deduplicated yet.** Running the tagger again over an
already-tagged document may create duplicate annotations; idempotent
re-tagging is a planned follow-up.

## Related

- [#1819](https://github.com/Open-Source-Legal/cite/issues/1819) — geocoding foundation (`resolve_place`, `OC_*` labels)
- [#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
104 changes: 104 additions & 0 deletions docs/test_scripts/location_tagger_end_to_end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Test: Location Tagger end-to-end (upload → corpus action → map pins)

## Purpose
Verify that the **Location Tagger** default agent, when wired as a
`CorpusAction`, auto-creates geocoded `OC_COUNTRY` / `OC_STATE` / `OC_CITY`
annotations on documents and that those annotations surface as pins on the
Corpus Home map.

## Prerequisites
- Migrations applied through the migration that creates the Location Tagger
agent (agents app `0015_create_location_tagger_agent`).
- At least one superuser exists (the default agent is created with the first
superuser as its creator).
- A corpus you can add documents to.
- An LLM backend configured (the agent runs through the normal agent stack).

## Steps

1. Confirm the default agent exists.
```bash
docker compose -f local.yml run --rm django python manage.py shell -c "
from opencontractserver.agents.models import AgentConfiguration
a = AgentConfiguration.objects.get(name='Location Tagger')
print(a.scope, a.is_active, a.is_public, a.available_tools, a.badge_config)
"
```
Expected: `GLOBAL True True ['add_annotations_from_exact_strings'] {...globe...}`.

2. Create a corpus action that runs the Location Tagger on document add.
```bash
docker compose -f local.yml run --rm django python manage.py shell -c "
from opencontractserver.agents.models import AgentConfiguration
from opencontractserver.corpuses.models import Corpus, CorpusAction
from django.contrib.auth import get_user_model
User = get_user_model()
u = User.objects.filter(is_superuser=True).first()
corpus = Corpus.objects.first()
agent = AgentConfiguration.objects.get(name='Location Tagger')
CorpusAction.objects.create(
name='Auto location tagger',
corpus=corpus,
agent_config=agent,
task_instructions=(
'Find every country, U.S. state, and city mentioned in the document '
'and tag them as OC_COUNTRY / OC_STATE / OC_CITY, supplying '
'country/state hints to disambiguate ambiguous names.'
),
trigger='add_document',
creator=u,
)
print('CorpusAction created for corpus', corpus.id)
"
```

3. Upload a document containing known place mentions (e.g. a text/PDF file
mentioning 'Paris, France' and 'Austin, Texas'). The `ADD_DOCUMENT`
trigger fires the agent asynchronously.

4. Inspect the created annotations and their geocoded `data`.
```bash
docker compose -f local.yml run --rm django python manage.py shell -c "
from opencontractserver.annotations.models import Annotation
from opencontractserver.constants.annotations import (
OC_CITY_LABEL, OC_COUNTRY_LABEL, OC_STATE_LABEL,
)
qs = Annotation.objects.filter(
annotation_label__text__in=[OC_COUNTRY_LABEL, OC_STATE_LABEL, OC_CITY_LABEL]
)
for a in qs:
print(a.annotation_label.text, a.raw_text, a.data)
"
```

5. Open Corpus Home for the corpus in the frontend and switch to the map view.

## Expected Results
- Step 1: the Location Tagger agent is present, active, public, global, and
exposes only `add_annotations_from_exact_strings`.
- Step 4: each geographic annotation's `data` contains `canonical_name`,
`lat`, `lng`, and `admin_codes`; e.g. 'Austin' →
`admin_codes == {"iso_alpha2": "US", "admin1": "TX"}`, and 'Paris, France' →
`admin_codes["iso_alpha2"] == "FR"`.
- Step 5: pins appear on the Corpus Home map at the resolved coordinates.

## Cleanup
```bash
docker compose -f local.yml run --rm django python manage.py shell -c "
from opencontractserver.annotations.models import Annotation
from opencontractserver.constants.annotations import (
OC_CITY_LABEL, OC_COUNTRY_LABEL, OC_STATE_LABEL,
)
from opencontractserver.corpuses.models import CorpusAction
# Scope the cleanup to the corpus this test targeted. A global
# annotation_label__text__in delete would wipe OC_* annotations across EVERY
# corpus in the database — never run that against a populated staging/prod DB.
action = CorpusAction.objects.filter(name='Auto location tagger').first()
if action is not None:
Annotation.objects.filter(
corpus=action.corpus,
annotation_label__text__in=[OC_COUNTRY_LABEL, OC_STATE_LABEL, OC_CITY_LABEL],
).delete()
action.delete()
"
```
Loading